Compare commits
No commits in common. "20f32ba0de78e1ac391e72c84b363cbfdb5f6ee6" and "f59009af579db4d42183702a9a74517bc5fddd46" have entirely different histories.
20f32ba0de
...
f59009af57
@ -11,9 +11,9 @@ import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "About Aarthalabs",
|
||||
title: "About LedgerOne",
|
||||
description:
|
||||
"Learn how Aarthalabs builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -52,9 +52,9 @@ export default function AboutPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "About Aarthalabs",
|
||||
name: "About LedgerOne",
|
||||
description:
|
||||
"Learn how Aarthalabs builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||
url: `${siteInfo.url}/about`
|
||||
},
|
||||
{
|
||||
@ -80,12 +80,12 @@ export default function AboutPage() {
|
||||
Our Story
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
||||
Aarthalabs keeps every transaction ready for{" "}
|
||||
LedgerOne keeps every transaction ready for{" "}
|
||||
<span className="heading-hero-accent">ready for audits, review, and action.</span>
|
||||
</h1>
|
||||
<div className="space-y-4 text-lg text-muted-foreground">
|
||||
<p>
|
||||
We built Aarthalabs for teams that manage high volumes of transactions but
|
||||
We built LedgerOne for teams that manage high volumes of transactions but
|
||||
still need each decision documented. Our ledger-first workflow keeps the
|
||||
raw truth intact while allowing intelligent categorization and rule-driven
|
||||
automation.
|
||||
@ -119,7 +119,7 @@ export default function AboutPage() {
|
||||
Built for US operators
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Aarthalabs is built around US accounting workflows, audit readiness, and
|
||||
LedgerOne is built around US accounting workflows, audit readiness, and
|
||||
tax reporting cycles.
|
||||
</p>
|
||||
</div>
|
||||
@ -167,7 +167,7 @@ export default function AboutPage() {
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Traditional tools collapse data into summaries. Aarthalabs keeps each raw
|
||||
Traditional tools collapse data into summaries. LedgerOne keeps each raw
|
||||
entry intact and layers in decisions, reviews, and approvals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Household = { id: string; name: string };
|
||||
type Person = { id: string; email: string; fullName?: string | null };
|
||||
type AccountantTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
taskType: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate?: string | null;
|
||||
completedAt?: string | null;
|
||||
assignedTo?: Person | null;
|
||||
createdBy?: Person | null;
|
||||
};
|
||||
|
||||
export default function AccountantPage() {
|
||||
const [households, setHouseholds] = useState<Household[]>([]);
|
||||
const [householdId, setHouseholdId] = useState("");
|
||||
const [tasks, setTasks] = useState<AccountantTask[]>([]);
|
||||
const [draft, setDraft] = useState({ title: "", description: "", taskType: "review", priority: "medium", dueDate: "" });
|
||||
const [status, setStatus] = useState("");
|
||||
const selectedHousehold = useMemo(() => households.find((item) => item.id === householdId), [households, householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHouseholds();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (householdId) void loadTasks(householdId);
|
||||
}, [householdId]);
|
||||
|
||||
const loadHouseholds = async () => {
|
||||
const res = await apiFetch<Household[]>("/api/households");
|
||||
if (res.error) {
|
||||
setStatus(res.error.message);
|
||||
return;
|
||||
}
|
||||
const list = res.data ?? [];
|
||||
setHouseholds(list);
|
||||
setHouseholdId((current) => current || list[0]?.id || "");
|
||||
};
|
||||
|
||||
const loadTasks = async (id = householdId) => {
|
||||
const res = await apiFetch<AccountantTask[]>(`/api/households/${id}/accountant-tasks`);
|
||||
if (res.error) {
|
||||
setStatus(res.error.message);
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
setStatus("");
|
||||
setTasks(res.data ?? []);
|
||||
};
|
||||
|
||||
const createTask = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!householdId || !draft.title.trim()) return;
|
||||
const res = await apiFetch<AccountantTask>(`/api/households/${householdId}/accountant-tasks`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
description: draft.description || undefined,
|
||||
taskType: draft.taskType,
|
||||
priority: draft.priority,
|
||||
dueDate: draft.dueDate || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message);
|
||||
return;
|
||||
}
|
||||
setDraft({ title: "", description: "", taskType: "review", priority: "medium", dueDate: "" });
|
||||
await loadTasks();
|
||||
};
|
||||
|
||||
const updateTask = async (task: AccountantTask, nextStatus: string) => {
|
||||
if (!householdId) return;
|
||||
const res = await apiFetch<AccountantTask>(`/api/households/${householdId}/accountant-tasks/${task.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message);
|
||||
return;
|
||||
}
|
||||
await loadTasks();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Accountant Workflow" subtitle="Track household advisor requests, reviews, and follow-ups.">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
{status ? <div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">{status}</div> : null}
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-950">Household</h2>
|
||||
<p className="text-sm text-slate-600">{selectedHousehold?.name ?? "Choose a household to manage advisor work."}</p>
|
||||
</div>
|
||||
<select value={householdId} onChange={(event) => setHouseholdId(event.target.value)} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
|
||||
{households.map((household) => (
|
||||
<option key={household.id} value={household.id}>{household.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form onSubmit={createTask} className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-950">New Task</h2>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<input value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Task title" className="rounded-md border border-slate-300 px-3 py-2 text-sm" />
|
||||
<input type="date" value={draft.dueDate} onChange={(event) => setDraft({ ...draft, dueDate: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm" />
|
||||
<select value={draft.taskType} onChange={(event) => setDraft({ ...draft, taskType: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="review">Review</option>
|
||||
<option value="document_request">Document request</option>
|
||||
<option value="tax_prep">Tax prep</option>
|
||||
<option value="advice">Advice</option>
|
||||
</select>
|
||||
<select value={draft.priority} onChange={(event) => setDraft({ ...draft, priority: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} placeholder="Notes or request details" className="mt-3 min-h-24 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
|
||||
<button className="mt-3 rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white">Create task</button>
|
||||
</form>
|
||||
|
||||
<section className="grid gap-3">
|
||||
{tasks.length ? tasks.map((task) => (
|
||||
<article key={task.id} className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-950">{task.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">{task.description || "No details added."}</p>
|
||||
<p className="mt-2 text-xs uppercase text-slate-500">
|
||||
{task.taskType.replace(/_/g, " ")} · {task.priority} · {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "No due date"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => updateTask(task, "open")} className="rounded-md border border-slate-300 px-3 py-1.5 text-xs">Open</button>
|
||||
<button onClick={() => updateTask(task, "in_progress")} className="rounded-md border border-blue-200 px-3 py-1.5 text-xs text-blue-700">In progress</button>
|
||||
<button onClick={() => updateTask(task, "completed")} className="rounded-md border border-emerald-200 px-3 py-1.5 text-xs text-emerald-700">Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">Status: {task.status.replace(/_/g, " ")}</div>
|
||||
</article>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-sm text-slate-500">No accountant tasks yet.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/2fa/disable");
|
||||
return proxyRequest(req, "2fa/disable");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/2fa/enable");
|
||||
return proxyRequest(req, "2fa/enable");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/2fa/generate");
|
||||
return proxyRequest(req, "2fa/generate");
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link");
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link-token");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/accounts");
|
||||
return proxyRequest(req, "accounts");
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `api-keys/${params.id}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "api-keys");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "api-keys");
|
||||
}
|
||||
@ -1,15 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const res = await proxyRequest(req, "auth/login");
|
||||
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
|
||||
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
return proxyRequest(req, "auth/login");
|
||||
}
|
||||
|
||||
@ -1,36 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBackendUrl } from "@/lib/backend";
|
||||
import { ACCESS_COOKIE, NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies } from "@/lib/auth-cookies";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const accessToken = req.cookies.get(ACCESS_COOKIE)?.value;
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const sessionNonce = req.cookies.get(NONCE_COOKIE)?.value;
|
||||
const backendRes = refreshToken
|
||||
? await fetch(getBackendUrl("auth/logout"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(sessionNonce ? { "X-LedgerOne-Session-Nonce": sessionNonce } : {}),
|
||||
"User-Agent": req.headers.get("user-agent") ?? "",
|
||||
"X-Forwarded-For": req.headers.get("x-forwarded-for") ?? "",
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
})
|
||||
: null;
|
||||
|
||||
const payload = backendRes
|
||||
? await backendRes.text()
|
||||
: JSON.stringify({
|
||||
data: { message: "Logged out." },
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: null,
|
||||
});
|
||||
const res = new NextResponse(payload, {
|
||||
status: backendRes?.status ?? 200,
|
||||
headers: { "Content-Type": backendRes?.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
clearAuthCookies(res);
|
||||
return res;
|
||||
return proxyRequest(req, "auth/logout");
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me/data-export");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me/privacy-summary");
|
||||
}
|
||||
@ -4,7 +4,3 @@ import { proxyRequest } from "@/lib/backend";
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
|
||||
@ -1,46 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBackendUrl } from "@/lib/backend";
|
||||
import { NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const sessionNonce = req.cookies.get(NONCE_COOKIE)?.value;
|
||||
if (!refreshToken) {
|
||||
const res = NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Missing refresh token." },
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
clearAuthCookies(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
const backendRes = await fetch(getBackendUrl("auth/refresh"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": req.headers.get("user-agent") ?? "",
|
||||
"X-Forwarded-For": req.headers.get("x-forwarded-for") ?? "",
|
||||
...(sessionNonce ? { "X-LedgerOne-Session-Nonce": sessionNonce } : {}),
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
const payload = await backendRes.text();
|
||||
const parsed = JSON.parse(payload) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } };
|
||||
const responsePayload = backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken && parsed.data.sessionNonce
|
||||
? JSON.stringify({ ...parsed, data: {} })
|
||||
: payload;
|
||||
const res = new NextResponse(responsePayload, {
|
||||
status: backendRes.status,
|
||||
headers: { "Content-Type": backendRes.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
if (backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken && parsed.data.sessionNonce) {
|
||||
setAuthCookies(res, parsed.data.accessToken, parsed.data.refreshToken, parsed.data.sessionNonce);
|
||||
} else {
|
||||
clearAuthCookies(res);
|
||||
}
|
||||
return res;
|
||||
return proxyRequest(req, "auth/refresh");
|
||||
}
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const res = await proxyRequest(req, "auth/register");
|
||||
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
|
||||
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
return proxyRequest(req, "auth/register");
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { provider: string } },
|
||||
) {
|
||||
const res = await proxyRequest(req, `auth/social/${params.provider}/callback`);
|
||||
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
|
||||
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { provider: string } },
|
||||
) {
|
||||
return proxyRequest(req, `auth/social/${params.provider}/url`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `bill-pay/bills/${params.id}/initiate-payment`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `bill-pay/bills/${params.id}/pay`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `bill-pay/bills/${params.id}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/bills");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/bills");
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/payees");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/payees");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "bill-pay/summary");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/checkout");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/portal");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/subscription");
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/entries");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/entries");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/pull");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "credit-score/summary");
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBackendUrl } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { token: string } }
|
||||
) {
|
||||
const targetUrl = getBackendUrl(`exports/download/${params.token}`);
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
|
||||
const userAgent = req.headers.get("user-agent") ?? "";
|
||||
const headers: Record<string, string> = {};
|
||||
if (forwardedFor) headers["X-Forwarded-For"] = forwardedFor;
|
||||
if (userAgent) headers["User-Agent"] = userAgent;
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, { method: "GET", headers });
|
||||
const body = await res.arrayBuffer();
|
||||
return new NextResponse(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/octet-stream",
|
||||
"Content-Disposition": res.headers.get("content-disposition") ?? "attachment",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." },
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/json");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/pdf");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/xlsx");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "google/data-system-mode");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string; taskId: string } }) {
|
||||
return proxyRequest(req, `households/${params.id}/accountant-tasks/${params.taskId}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `households/${params.id}/accountant-tasks`);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `households/${params.id}/accountant-tasks`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/dashboard`);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/debt-payoff`);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/fair-split`);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/future-scenarios`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string; goalId: string };
|
||||
};
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/goals/${params.goalId}`);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/goals`);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/goals`);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/invites`);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/invites`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string; memberId: string };
|
||||
};
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/members/${params.memberId}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
type RouteContext = {
|
||||
params: { id: string };
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest, { params }: RouteContext) {
|
||||
return proxyRequest(req, `households/${params.id}/members`);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/money-date-prompts`);
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `households/${params.id}/privacy`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "households");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "households");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `notifications/${params.id}/read`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/preferences");
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/preferences");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/push-subscriptions");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/read-all");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/test");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "notifications/vapid-public-key");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/repair-complete");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/update-link-token");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `planning/budgets/${params.id}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/budgets");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/budgets");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `planning/goals/${params.id}`);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/goals");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/goals");
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/investments");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/investments");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/net-worth");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/net-worth/snapshots");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/recurring/detect");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "planning/recurring");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
return proxyRequest(req, `rules/${params.id}/execute`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "security/risk");
|
||||
}
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/checkout");
|
||||
return proxyRequest(req, "stripe/checkout");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/portal");
|
||||
return proxyRequest(req, "stripe/portal");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/subscription");
|
||||
return proxyRequest(req, "stripe/subscription");
|
||||
}
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
return proxyRequest(req, `tax/returns/${params.id}/documents`);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
return proxyRequest(req, `tax/returns/${params.id}/efile`);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
return proxyRequest(req, `tax/returns/${params.id}/efile`);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
return proxyRequest(req, `tax/returns/${params.id}/intake`);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
return proxyRequest(req, `tax/returns/${params.id}/intake`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/config");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/enrollment");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/sync");
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `transactions/${params.id}/comments`);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `transactions/${params.id}/comments`);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import/batch");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import/preview");
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/transactions");
|
||||
return proxyRequest(req, "transactions");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/accounts");
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "view/transactions");
|
||||
}
|
||||
@ -5,72 +5,21 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { usePlaidLink } from "react-plaid-link";
|
||||
|
||||
type Account = {
|
||||
viewRef: string;
|
||||
id: string;
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string | null;
|
||||
syncStatus?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
plaidWebhookCode?: string | null;
|
||||
tellerConnected?: boolean;
|
||||
};
|
||||
|
||||
type TellerEnrollment = {
|
||||
accessToken: string;
|
||||
user?: { id?: string };
|
||||
enrollment?: {
|
||||
id?: string;
|
||||
institution?: { name?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
googleEmail?: string;
|
||||
driveMirror?: {
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
url?: string | null;
|
||||
lastSyncedAt?: string | null;
|
||||
};
|
||||
dataSystem?: {
|
||||
mode: string;
|
||||
operationalSystemOfRecord: string;
|
||||
userOwnedMirror: boolean;
|
||||
note: string;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TellerConnect?: {
|
||||
setup(config: {
|
||||
applicationId: string;
|
||||
environment?: string;
|
||||
products: string[];
|
||||
enrollmentId?: string;
|
||||
onSuccess(enrollment: TellerEnrollment): void;
|
||||
onExit?(): void;
|
||||
onFailure?(error: unknown): void;
|
||||
}): { open(): void };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||
const [updateAccountRef, setUpdateAccountRef] = useState<string | null>(null);
|
||||
const [linkMode, setLinkMode] = useState<"connect" | "update">("connect");
|
||||
const [pendingOpen, setPendingOpen] = useState(false);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [manualBank, setManualBank] = useState("");
|
||||
const [manualRouting, setManualRouting] = useState("");
|
||||
const [manualAccount, setManualAccount] = useState("");
|
||||
const [manualType, setManualType] = useState("checking");
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [tellerReady, setTellerReady] = useState(false);
|
||||
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
|
||||
|
||||
const createLinkToken = useCallback(async () => {
|
||||
setStatus("Requesting Plaid link token...");
|
||||
@ -94,94 +43,36 @@ export default function ConnectPage() {
|
||||
}, []);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
const res = await fetch("/api/accounts");
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await res.json();
|
||||
setAccounts(payload.data?.accounts ?? payload.data ?? []);
|
||||
}, []);
|
||||
|
||||
const loadGoogleStatus = useCallback(async () => {
|
||||
const res = await fetch("/api/google/status");
|
||||
if (!res.ok) return;
|
||||
const payload = await res.json();
|
||||
setGoogleStatus(payload.data ?? payload);
|
||||
}, []);
|
||||
|
||||
const loadTellerScript = useCallback(() => {
|
||||
if (typeof window === "undefined") return Promise.resolve(false);
|
||||
if (window.TellerConnect) {
|
||||
setTellerReady(true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>("script[data-teller-connect]");
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => {
|
||||
setTellerReady(Boolean(window.TellerConnect));
|
||||
resolve(Boolean(window.TellerConnect));
|
||||
});
|
||||
existing.addEventListener("error", () => resolve(false));
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.teller.io/connect/connect.js";
|
||||
script.dataset.tellerConnect = "true";
|
||||
script.onload = () => {
|
||||
setTellerReady(Boolean(window.TellerConnect));
|
||||
resolve(Boolean(window.TellerConnect));
|
||||
};
|
||||
script.onerror = () => resolve(false);
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
setAccounts(payload.data ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
createLinkToken();
|
||||
loadAccounts();
|
||||
loadGoogleStatus();
|
||||
loadTellerScript();
|
||||
}, [createLinkToken, loadAccounts, loadGoogleStatus, loadTellerScript]);
|
||||
}, [createLinkToken, loadAccounts]);
|
||||
|
||||
const onSuccess = useCallback(
|
||||
async (publicToken: string | null) => {
|
||||
if (linkMode === "update" && updateAccountRef) {
|
||||
setStatus("Finishing bank reconnection...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/repair-complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: updateAccountRef })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to finish bank reconnection.");
|
||||
return;
|
||||
}
|
||||
setStatus("Bank connection repaired.");
|
||||
setUpdateAccountRef(null);
|
||||
setLinkMode("connect");
|
||||
setLinkToken(null);
|
||||
await loadAccounts();
|
||||
await createLinkToken();
|
||||
} catch {
|
||||
setStatus("Unable to finish bank reconnection.");
|
||||
}
|
||||
async (publicToken: string) => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
setStatus("Missing user id. Please sign in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!publicToken) {
|
||||
setStatus("Plaid did not return a public token.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Exchanging public token...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ publicToken })
|
||||
body: JSON.stringify({ publicToken, userId })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
@ -189,14 +80,12 @@ export default function ConnectPage() {
|
||||
return;
|
||||
}
|
||||
setStatus("Bank account connected.");
|
||||
setLinkToken(null);
|
||||
await loadAccounts();
|
||||
await createLinkToken();
|
||||
} catch {
|
||||
setStatus("Unable to exchange token.");
|
||||
}
|
||||
},
|
||||
[createLinkToken, linkMode, loadAccounts, updateAccountRef]
|
||||
[loadAccounts]
|
||||
);
|
||||
|
||||
const { open, ready } = usePlaidLink({
|
||||
@ -207,16 +96,15 @@ export default function ConnectPage() {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingOpen && ready) {
|
||||
setPendingOpen(false);
|
||||
open();
|
||||
}
|
||||
}, [open, pendingOpen, ready]);
|
||||
|
||||
const onManualSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
setStatus("Missing user id. Please sign in again.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
userId,
|
||||
institutionName: manualBank,
|
||||
accountType: manualType,
|
||||
mask: manualAccount.slice(-4)
|
||||
@ -244,105 +132,6 @@ export default function ConnectPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const startUpdateMode = async (accountRef: string) => {
|
||||
setStatus("Requesting Plaid update-mode link token...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/update-link-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: accountRef })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to create update-mode link token.");
|
||||
return;
|
||||
}
|
||||
const token = payload.data?.linkToken ?? payload.data?.link_token;
|
||||
if (!token) {
|
||||
setStatus("Unable to create update-mode link token.");
|
||||
return;
|
||||
}
|
||||
setUpdateAccountRef(accountRef);
|
||||
setLinkMode("update");
|
||||
setLinkToken(token);
|
||||
setStatus("Reconnect token ready. Opening Plaid...");
|
||||
setPendingOpen(true);
|
||||
} catch {
|
||||
setStatus("Unable to create update-mode link token.");
|
||||
}
|
||||
};
|
||||
|
||||
const needsReconnect = (account: Account) =>
|
||||
["needs_reauth", "attention_required"].includes(account.syncStatus ?? "");
|
||||
|
||||
const startTellerConnect = async () => {
|
||||
setStatus("Preparing Teller Connect...");
|
||||
const loaded = await loadTellerScript();
|
||||
if (!loaded || !window.TellerConnect) {
|
||||
setStatus("Unable to load Teller Connect.");
|
||||
return;
|
||||
}
|
||||
|
||||
const configRes = await fetch("/api/teller/config");
|
||||
const configPayload = await configRes.json();
|
||||
if (!configRes.ok || configPayload.error) {
|
||||
setStatus(configPayload.error?.message ?? "Teller is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const connect = window.TellerConnect.setup({
|
||||
applicationId: configPayload.data.applicationId,
|
||||
environment: configPayload.data.environment,
|
||||
products: configPayload.data.products,
|
||||
onSuccess: async (enrollment) => {
|
||||
setStatus("Importing Teller accounts...");
|
||||
const res = await fetch("/api/teller/enrollment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(enrollment)
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to import Teller accounts.");
|
||||
return;
|
||||
}
|
||||
setStatus(`Teller connected ${payload.data?.accountCount ?? 0} account(s).`);
|
||||
await loadAccounts();
|
||||
},
|
||||
onExit: () => setStatus("Teller Connect closed."),
|
||||
onFailure: () => setStatus("Teller Connect failed.")
|
||||
});
|
||||
connect.open();
|
||||
};
|
||||
|
||||
const syncTeller = async () => {
|
||||
setStatus("Syncing Teller transactions...");
|
||||
const res = await fetch("/api/teller/sync", { method: "POST" });
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to sync Teller transactions.");
|
||||
return;
|
||||
}
|
||||
setStatus(`Synced ${payload.data?.created ?? 0} Teller transaction(s).`);
|
||||
await loadAccounts();
|
||||
};
|
||||
|
||||
const enableSheetsMirror = async () => {
|
||||
setStatus("Enabling Sheets-first mirror mode...");
|
||||
const res = await fetch("/api/google/data-system-mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: "google_sheets_mirror" })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to enable Sheets-first mirror mode.");
|
||||
return;
|
||||
}
|
||||
setStatus("Sheets-first mirror mode enabled.");
|
||||
await loadGoogleStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Connect a bank"
|
||||
@ -362,21 +151,6 @@ export default function ConnectPage() {
|
||||
>
|
||||
Connect with Plaid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
|
||||
onClick={startTellerConnect}
|
||||
>
|
||||
Connect with Teller
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={syncTeller}
|
||||
disabled={!accounts.some((account) => account.tellerConnected)}
|
||||
>
|
||||
Sync Teller
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
|
||||
@ -393,35 +167,18 @@ export default function ConnectPage() {
|
||||
</p>
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.viewRef}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm md:flex-row md:items-center md:justify-between"
|
||||
key={account.id}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold text-foreground">{account.institutionName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.accountType} {account.mask ? `- ${account.mask}` : ""}
|
||||
</p>
|
||||
{account.lastSyncError ? (
|
||||
<p className="mt-1 text-xs text-destructive">{account.lastSyncError}</p>
|
||||
) : null}
|
||||
{account.plaidWebhookCode ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{account.plaidWebhookCode}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{account.tellerConnected ? "teller" : account.syncStatus ?? "connected"}
|
||||
</span>
|
||||
{needsReconnect(account) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
onClick={() => startUpdateMode(account.viewRef)}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -486,37 +243,9 @@ export default function ConnectPage() {
|
||||
</form>
|
||||
) : null}
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Free includes two connections. Pro supports ten active accounts, and Elite supports unlimited accounts.
|
||||
Your first two connections are free. Upgrade to add unlimited accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel mt-6 p-6 rounded-2xl shadow-sm">
|
||||
<h2 className="text-lg font-bold text-foreground">Google Sheets ownership</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Keep a user-owned Google Sheets mirror synced from Aarthalabs transaction changes.
|
||||
</p>
|
||||
<div className="mt-4 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-foreground">
|
||||
{googleStatus?.connected ? `Connected: ${googleStatus.googleEmail}` : "Google is not connected"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Mode: {googleStatus?.dataSystem?.mode ?? "backend_db"} · Mirror: {googleStatus?.driveMirror?.status ?? "not_started"}
|
||||
</p>
|
||||
{googleStatus?.driveMirror?.url ? (
|
||||
<a className="mt-2 inline-block text-xs font-semibold text-primary" href={googleStatus.driveMirror.url} target="_blank" rel="noreferrer">
|
||||
Open Google Sheet
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
onClick={enableSheetsMirror}
|
||||
disabled={!googleStatus?.connected || googleStatus?.dataSystem?.mode === "google_sheets_mirror"}
|
||||
>
|
||||
Enable Sheets-first mirror
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,10 +33,11 @@ type MerchantInsight = {
|
||||
};
|
||||
|
||||
type TxRow = {
|
||||
viewRef: string;
|
||||
id: string;
|
||||
date: string;
|
||||
description: string;
|
||||
category?: string | null;
|
||||
accountId?: string | null;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
@ -506,14 +507,14 @@ export default function AppHomePage() {
|
||||
apiFetch<Summary>("/api/transactions/summary"),
|
||||
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
||||
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
||||
apiFetch<{ accounts: { viewRef: string }[]; total: number }>("/api/view/accounts?limit=5"),
|
||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/view/transactions?limit=5"),
|
||||
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
|
||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
|
||||
])
|
||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
||||
if (!summaryRes.error) setSummary(summaryRes.data);
|
||||
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data?.total ?? accountsRes.data?.accounts?.length ?? 0);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
|
||||
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
@ -736,7 +737,7 @@ export default function AppHomePage() {
|
||||
const fmtAmt = formatCurrency(amt);
|
||||
const isIncome = amt >= 0;
|
||||
return (
|
||||
<tr key={tx.viewRef} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
|
||||
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
||||
<td className="py-3">
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { storeAuthTokens } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type SocialAuthData = {
|
||||
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
next?: string;
|
||||
};
|
||||
|
||||
function decodeProvider(state: string | null) {
|
||||
if (!state) return "";
|
||||
try {
|
||||
const [body] = state.split(".");
|
||||
const normalized = body.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(body.length / 4) * 4, "=");
|
||||
const json = JSON.parse(atob(normalized)) as { provider?: string };
|
||||
return json.provider === "apple" || json.provider === "google" ? json.provider : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function SocialCallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [message, setMessage] = useState("Completing sign in...");
|
||||
|
||||
useEffect(() => {
|
||||
const error = searchParams.get("error");
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const provider = decodeProvider(state);
|
||||
|
||||
if (error) {
|
||||
setStatus("error");
|
||||
setMessage(error === "access_denied" ? "You declined social sign in." : `Provider returned an error: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (!provider || !code || !state) {
|
||||
setStatus("error");
|
||||
setMessage("Social sign in callback is missing required data.");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/auth/social/${provider}/callback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code, state }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
const payload = (await res.json()) as ApiResponse<SocialAuthData>;
|
||||
if (!res.ok || payload.error) throw new Error(payload.error?.message ?? "Social sign in failed.");
|
||||
storeAuthTokens({
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus("success");
|
||||
setMessage(`Signed in as ${payload.data.user.email}.`);
|
||||
setTimeout(() => router.replace(payload.data.next || "/app"), 800);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setStatus("error");
|
||||
setMessage(err.message || "Social sign in failed.");
|
||||
});
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-6">
|
||||
<div className="glass-panel rounded-2xl p-10 text-center max-w-sm w-full shadow-lg">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">Signed in</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{message}</p>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">Sign in failed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{message}</p>
|
||||
<button onClick={() => router.replace("/login")} className="mt-4 text-xs text-primary hover:underline">
|
||||
Back to sign in
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SocialCallbackPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SocialCallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -1,324 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Payee = {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
accountNumberLast4?: string | null;
|
||||
};
|
||||
|
||||
type Bill = {
|
||||
id: string;
|
||||
payeeId?: string | null;
|
||||
name: string;
|
||||
amount: string | number;
|
||||
currency: string;
|
||||
dueDate: string;
|
||||
status: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
|
||||
computedStatus: string;
|
||||
recurrence: string;
|
||||
autopay: boolean;
|
||||
reminderDays: number;
|
||||
paidAt?: string | null;
|
||||
payee?: Payee | null;
|
||||
};
|
||||
|
||||
type BillSummary = {
|
||||
activeCount: number;
|
||||
upcomingCount: number;
|
||||
overdueCount: number;
|
||||
totalDueNext30: string;
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function BillsPage() {
|
||||
const [payees, setPayees] = useState<Payee[]>([]);
|
||||
const [bills, setBills] = useState<Bill[]>([]);
|
||||
const [summary, setSummary] = useState<BillSummary | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [payeeForm, setPayeeForm] = useState({ name: "", category: "", accountNumberLast4: "" });
|
||||
const [billForm, setBillForm] = useState({
|
||||
payeeId: "",
|
||||
name: "",
|
||||
amount: "",
|
||||
dueDate: today,
|
||||
status: "pending",
|
||||
recurrence: "none",
|
||||
autopay: false,
|
||||
reminderDays: "3",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
if (filter === "all") return bills;
|
||||
if (filter === "overdue") return bills.filter((bill) => bill.computedStatus === "overdue");
|
||||
return bills.filter((bill) => bill.status === filter);
|
||||
}, [bills, filter]);
|
||||
|
||||
const money = (value: string | number, currency = "USD") =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value ?? 0));
|
||||
|
||||
const load = async () => {
|
||||
const [payeesRes, billsRes, summaryRes] = await Promise.all([
|
||||
apiFetch<Payee[]>("/api/bill-pay/payees"),
|
||||
apiFetch<Bill[]>("/api/bill-pay/bills"),
|
||||
apiFetch<BillSummary>("/api/bill-pay/summary"),
|
||||
]);
|
||||
if (!payeesRes.error) setPayees(payeesRes.data ?? []);
|
||||
if (!billsRes.error) setBills(billsRes.data ?? []);
|
||||
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => setStatus("Unable to load bills."));
|
||||
}, []);
|
||||
|
||||
const createPayee = async () => {
|
||||
if (!payeeForm.name.trim()) {
|
||||
setStatus("Payee name is required.");
|
||||
return;
|
||||
}
|
||||
const res = await apiFetch<Payee>("/api/bill-pay/payees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payeeForm),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to create payee.");
|
||||
return;
|
||||
}
|
||||
setPayeeForm({ name: "", category: "", accountNumberLast4: "" });
|
||||
setStatus("Payee created.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const createBill = async () => {
|
||||
if (!billForm.name.trim() || !billForm.amount || !billForm.dueDate) {
|
||||
setStatus("Bill name, amount, and due date are required.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...billForm,
|
||||
payeeId: billForm.payeeId || undefined,
|
||||
amount: Number(billForm.amount),
|
||||
reminderDays: Number(billForm.reminderDays || 3),
|
||||
};
|
||||
const res = await apiFetch<Bill>("/api/bill-pay/bills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to create bill.");
|
||||
return;
|
||||
}
|
||||
setBillForm({ payeeId: "", name: "", amount: "", dueDate: today, status: "pending", recurrence: "none", autopay: false, reminderDays: "3", notes: "" });
|
||||
setStatus("Bill created.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const markPaid = async (bill: Bill) => {
|
||||
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/pay`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
amount: Number(bill.amount),
|
||||
paidAt: new Date().toISOString(),
|
||||
method: bill.autopay ? "autopay" : "manual",
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to mark bill paid.");
|
||||
return;
|
||||
}
|
||||
setStatus(`${bill.name} marked paid.`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const initiatePayment = async (bill: Bill) => {
|
||||
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/initiate-payment`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
amount: Number(bill.amount),
|
||||
scheduledFor: new Date().toISOString(),
|
||||
method: "ach",
|
||||
memo: `Aarthalabs payment for ${bill.name}`,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to initiate payment.");
|
||||
return;
|
||||
}
|
||||
setStatus(`${bill.name} payment initiated.`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const updateStatus = async (bill: Bill, nextStatus: Bill["status"]) => {
|
||||
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to update bill.");
|
||||
return;
|
||||
}
|
||||
setStatus("Bill updated.");
|
||||
await load();
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell title="Bills" subtitle="Track payees, due dates, reminders, and recorded payments.">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Active bills</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.activeCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Due next 30 days</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.upcomingCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.overdueCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Total due next 30 days</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{money(summary?.totalDueNext30 ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Payees</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Name</label>
|
||||
<input value={payeeForm.name} onChange={(event) => setPayeeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Category</label>
|
||||
<input value={payeeForm.category} onChange={(event) => setPayeeForm((prev) => ({ ...prev, category: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Account last 4</label>
|
||||
<input maxLength={4} value={payeeForm.accountNumberLast4} onChange={(event) => setPayeeForm((prev) => ({ ...prev, accountNumberLast4: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<button onClick={createPayee} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Payee</button>
|
||||
</div>
|
||||
<div className="mt-6 divide-y divide-border">
|
||||
{payees.length === 0 && <p className="py-4 text-sm text-muted-foreground">No payees yet.</p>}
|
||||
{payees.map((payee) => (
|
||||
<div key={payee.id} className="py-3">
|
||||
<p className="text-sm font-semibold text-foreground">{payee.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{payee.category || "Uncategorized"}{payee.accountNumberLast4 ? ` · ${payee.accountNumberLast4}` : ""}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">New Bill</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelCls}>Payee</label>
|
||||
<select value={billForm.payeeId} onChange={(event) => setBillForm((prev) => ({ ...prev, payeeId: event.target.value }))} className={inputCls}>
|
||||
<option value="">No payee</option>
|
||||
{payees.map((payee) => <option key={payee.id} value={payee.id}>{payee.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Bill name</label>
|
||||
<input value={billForm.name} onChange={(event) => setBillForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Amount</label>
|
||||
<input type="number" step="0.01" value={billForm.amount} onChange={(event) => setBillForm((prev) => ({ ...prev, amount: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Due date</label>
|
||||
<input type="date" value={billForm.dueDate} onChange={(event) => setBillForm((prev) => ({ ...prev, dueDate: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Recurrence</label>
|
||||
<select value={billForm.recurrence} onChange={(event) => setBillForm((prev) => ({ ...prev, recurrence: event.target.value }))} className={inputCls}>
|
||||
<option value="none">None</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Reminder days</label>
|
||||
<input type="number" min={0} max={30} value={billForm.reminderDays} onChange={(event) => setBillForm((prev) => ({ ...prev, reminderDays: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={billForm.autopay} onChange={(event) => setBillForm((prev) => ({ ...prev, autopay: event.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||
Autopay enabled externally
|
||||
</label>
|
||||
<button onClick={createBill} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Bill</button>
|
||||
</div>
|
||||
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Bill Schedule</p>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
|
||||
<option value="all">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{filteredBills.length === 0 && <p className="py-8 text-sm text-muted-foreground">No bills for this view.</p>}
|
||||
{filteredBills.map((bill) => (
|
||||
<article key={bill.id} className="py-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-bold text-foreground">{bill.name}</p>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{bill.computedStatus}</span>
|
||||
{bill.autopay && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">autopay</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{money(bill.amount, bill.currency)} due {new Date(bill.dueDate).toLocaleDateString()} · {bill.recurrence}
|
||||
</p>
|
||||
{bill.payee && <p className="mt-1 text-xs text-muted-foreground">Payee: {bill.payee.name}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{bill.status !== "paid" && (
|
||||
<button onClick={() => initiatePayment(bill)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Initiate Payment</button>
|
||||
)}
|
||||
{bill.status !== "paid" && (
|
||||
<button onClick={() => markPaid(bill)} className="rounded-lg border border-primary/30 px-3 py-2 text-xs font-bold text-primary hover:bg-primary/10">Mark Paid</button>
|
||||
)}
|
||||
{bill.status !== "skipped" && bill.status !== "paid" && (
|
||||
<button onClick={() => updateStatus(bill, "skipped")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Skip</button>
|
||||
)}
|
||||
{bill.status !== "cancelled" && bill.status !== "paid" && (
|
||||
<button onClick={() => updateStatus(bill, "cancelled")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Cancel</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -42,7 +42,7 @@ export default function BlogPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs Blog",
|
||||
name: "LedgerOne Blog",
|
||||
description: "Insights on financial control, audit readiness, and ledger automation.",
|
||||
url: `${siteInfo.url}/blog`
|
||||
},
|
||||
|
||||
@ -15,7 +15,7 @@ const inputClass =
|
||||
export const metadata = {
|
||||
title: "Book a Demo",
|
||||
description:
|
||||
"Book a Aarthalabs demo to see audit-ready ledgering and transparent rules in action.",
|
||||
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -24,9 +24,9 @@ export default function BookDemoPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Book a Aarthalabs Demo",
|
||||
name: "Book a LedgerOne Demo",
|
||||
description:
|
||||
"Book a Aarthalabs demo to see audit-ready ledgering and transparent rules in action.",
|
||||
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
||||
url: `${siteInfo.url}/book-demo`
|
||||
},
|
||||
{
|
||||
@ -52,7 +52,7 @@ export default function BookDemoPage() {
|
||||
Book a demo
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
||||
Schedule time with the Aarthalabs team.
|
||||
Schedule time with the LedgerOne team.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
We will walk you through account connections, rule automation, and
|
||||
|
||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
||||
import { siteInfo } from "../../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Aarthalabs vs Copilot",
|
||||
description: "Compare Aarthalabs's cross-platform business solution with Copilot's personal finance app.",
|
||||
title: "LedgerOne vs Copilot",
|
||||
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -16,8 +16,8 @@ export default function CompareCopilotPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs vs Copilot",
|
||||
description: "Comparison of Aarthalabs and Copilot.",
|
||||
name: "LedgerOne vs Copilot",
|
||||
description: "Comparison of LedgerOne and Copilot.",
|
||||
url: `${siteInfo.url}/compare/vs-copilot`
|
||||
}
|
||||
];
|
||||
@ -36,14 +36,14 @@ export default function CompareCopilotPage() {
|
||||
Financial control for everyone.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Copilot is great for iPhone users. Aarthalabs is for serious business owners who need access everywhere, on any device.
|
||||
Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="col-span-1">Feature</div>
|
||||
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
|
||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||
<div className="col-span-1 text-center">Copilot</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Background } from "../../../components/background";
|
||||
import { SiteFooter } from "../../../components/site-footer";
|
||||
import { SiteHeader } from "../../../components/site-header";
|
||||
import { PageSchema } from "../../../components/page-schema";
|
||||
import { siteInfo } from "../../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Aarthalabs vs Monarch Money",
|
||||
description:
|
||||
"Compare Aarthalabs's export-first ledger workflow with Monarch Money's household finance platform.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
export default function CompareMonarchPage() {
|
||||
const schema = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs vs Monarch Money",
|
||||
description: "Comparison of Aarthalabs and Monarch Money.",
|
||||
url: `${siteInfo.url}/compare/vs-monarch`
|
||||
}
|
||||
];
|
||||
|
||||
const rows = [
|
||||
{ feature: "Primary Focus", l1: "Export-ready ledgers and audit workflow", sheet: "Household budgeting and net worth" },
|
||||
{ feature: "Exports", l1: "CSV, JSON, XLSX, PDF, and Google Sheets", sheet: "Consumer finance exports" },
|
||||
{ feature: "Rules", l1: "Transparent rules with regex and DSL mode", sheet: "Consumer categorization rules" },
|
||||
{ feature: "Data Ownership", l1: "User-owned Google Sheets mirror plus secured exports", sheet: "App-managed financial workspace" },
|
||||
{ feature: "Developer Access", l1: "Public API key flow for power users", sheet: "No public API-first workflow" },
|
||||
{ feature: "Tax Workflow", l1: "Tax intake, package export, and sandbox e-file flow", sheet: "Personal finance reporting" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
|
||||
<Background />
|
||||
<SiteHeader />
|
||||
<main className="relative z-10 flex-1 pt-24 pb-16">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="text-center max-w-3xl mx-auto mb-10">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||
Comparison
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
||||
Built for ledger control, not just household tracking.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Monarch Money is built around household financial visibility. Aarthalabs is built for users who need structured transactions, export control, rules, audit trails, and tax-ready handoff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="col-span-1">Feature</div>
|
||||
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
|
||||
<div className="col-span-1 text-center">Monarch Money</div>
|
||||
</div>
|
||||
|
||||
{rows.map((row, index) => (
|
||||
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${index % 2 === 0 ? "bg-background/50" : "bg-secondary/10"}`}>
|
||||
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
||||
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{row.l1}
|
||||
</div>
|
||||
<div className="col-span-1 text-center text-muted-foreground">
|
||||
{row.sheet}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-6">Need export-first financial control?</h2>
|
||||
<Link href="/register" className="btn-primary">
|
||||
Start your free trial
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="relative z-10">
|
||||
<SiteFooter />
|
||||
</div>
|
||||
<PageSchema schema={schema} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
||||
import { siteInfo } from "../../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Aarthalabs vs Quicken",
|
||||
description: "Move from legacy desktop software to Aarthalabs's modern, cloud-native financial platform.",
|
||||
title: "LedgerOne vs Quicken",
|
||||
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -16,8 +16,8 @@ export default function CompareQuickenPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs vs Quicken",
|
||||
description: "Comparison of Aarthalabs and Quicken.",
|
||||
name: "LedgerOne vs Quicken",
|
||||
description: "Comparison of LedgerOne and Quicken.",
|
||||
url: `${siteInfo.url}/compare/vs-quicken`
|
||||
}
|
||||
];
|
||||
@ -36,14 +36,14 @@ export default function CompareQuickenPage() {
|
||||
The modern alternative to Quicken.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Stop syncing desktop files. Aarthalabs gives you the power of Quicken with the speed, security, and accessibility of the modern web.
|
||||
Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="col-span-1">Feature</div>
|
||||
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
|
||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||
<div className="col-span-1 text-center">Quicken</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
||||
import { siteInfo } from "../../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Aarthalabs vs Spreadsheets",
|
||||
description: "See why modern businesses are switching from manual spreadsheets to Aarthalabs's automated financial platform.",
|
||||
title: "LedgerOne vs Spreadsheets",
|
||||
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -16,8 +16,8 @@ export default function CompareSpreadsheetsPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs vs Spreadsheets",
|
||||
description: "Comparison of Aarthalabs automated platform versus manual spreadsheets.",
|
||||
name: "LedgerOne vs Spreadsheets",
|
||||
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
|
||||
url: `${siteInfo.url}/compare/vs-spreadsheets`
|
||||
}
|
||||
];
|
||||
@ -36,14 +36,14 @@ export default function CompareSpreadsheetsPage() {
|
||||
Stop breaking your spreadsheets.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Spreadsheets are great for scratchpads, but terrible for financial systems. See why Aarthalabs is the upgrade your business needs.
|
||||
Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="col-span-1">Feature</div>
|
||||
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
|
||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||
<div className="col-span-1 text-center">Spreadsheets</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
||||
import { siteInfo } from "../../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Aarthalabs vs YNAB",
|
||||
description: "Compare Aarthalabs's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
||||
title: "LedgerOne vs YNAB",
|
||||
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -16,8 +16,8 @@ export default function CompareYnabPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Aarthalabs vs YNAB",
|
||||
description: "Comparison of Aarthalabs and YNAB.",
|
||||
name: "LedgerOne vs YNAB",
|
||||
description: "Comparison of LedgerOne and YNAB.",
|
||||
url: `${siteInfo.url}/compare/vs-ynab`
|
||||
}
|
||||
];
|
||||
@ -36,14 +36,14 @@ export default function CompareYnabPage() {
|
||||
Beyond Budgeting.
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
YNAB is great for personal envelopes. Aarthalabs is built for business growth, audit trails, and total financial control.
|
||||
YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="col-span-1">Feature</div>
|
||||
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
|
||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||
<div className="col-span-1 text-center">YNAB</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import { siteInfo } from "../../data/site";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contact Us",
|
||||
description: "Get in touch with the Aarthalabs team for support, sales, or partnerships.",
|
||||
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
@ -18,8 +18,8 @@ export default function ContactPage() {
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "Contact Aarthalabs",
|
||||
description: "Get in touch with the Aarthalabs team for support, sales, or partnerships.",
|
||||
name: "Contact LedgerOne",
|
||||
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
|
||||
url: `${siteInfo.url}/contact`
|
||||
},
|
||||
{
|
||||
@ -116,7 +116,7 @@ export default function ContactPage() {
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
support@aarthalabs.com
|
||||
support@ledgerone.com
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@ -1,266 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type CreditScoreEntry = {
|
||||
id: string;
|
||||
score: number;
|
||||
bureau: string;
|
||||
source: string;
|
||||
model: string;
|
||||
scoreDate: string;
|
||||
factors?: Record<string, unknown>;
|
||||
change?: number | null;
|
||||
};
|
||||
|
||||
type CreditScoreSummary = {
|
||||
latest: CreditScoreEntry | null;
|
||||
previous: CreditScoreEntry | null;
|
||||
change: number | null;
|
||||
averageScore: number | null;
|
||||
entryCount: number;
|
||||
latestByBureau: CreditScoreEntry[];
|
||||
trend: CreditScoreEntry[];
|
||||
};
|
||||
|
||||
const bureaus = [
|
||||
{ value: "experian", label: "Experian" },
|
||||
{ value: "equifax", label: "Equifax" },
|
||||
{ value: "transunion", label: "TransUnion" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function CreditScorePage() {
|
||||
const [summary, setSummary] = useState<CreditScoreSummary | null>(null);
|
||||
const [entries, setEntries] = useState<CreditScoreEntry[]>([]);
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [status, setStatus] = useState("");
|
||||
const [form, setForm] = useState({
|
||||
score: "",
|
||||
bureau: "experian",
|
||||
source: "manual",
|
||||
model: "fico_8",
|
||||
scoreDate: today,
|
||||
positiveFactors: "",
|
||||
negativeFactors: "",
|
||||
});
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (filter === "all") return entries;
|
||||
return entries.filter((entry) => entry.bureau === filter);
|
||||
}, [entries, filter]);
|
||||
|
||||
const scoreBand = (score?: number | null) => {
|
||||
if (!score) return "No score";
|
||||
if (score >= 800) return "Exceptional";
|
||||
if (score >= 740) return "Very good";
|
||||
if (score >= 670) return "Good";
|
||||
if (score >= 580) return "Fair";
|
||||
return "Needs work";
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const query = filter === "all" ? "" : `?bureau=${filter}`;
|
||||
const [summaryRes, entriesRes] = await Promise.all([
|
||||
apiFetch<CreditScoreSummary>("/api/credit-score/summary"),
|
||||
apiFetch<CreditScoreEntry[]>(`/api/credit-score/entries${query}`),
|
||||
]);
|
||||
if (!summaryRes.error) setSummary(summaryRes.data ?? null);
|
||||
if (!entriesRes.error) setEntries(entriesRes.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => setStatus("Unable to load credit score history."));
|
||||
}, [filter]);
|
||||
|
||||
const addEntry = async () => {
|
||||
const score = Number(form.score);
|
||||
if (!score || score < 300 || score > 850) {
|
||||
setStatus("Score must be between 300 and 850.");
|
||||
return;
|
||||
}
|
||||
const factors = {
|
||||
positive: form.positiveFactors.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
negative: form.negativeFactors.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
};
|
||||
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
score,
|
||||
bureau: form.bureau,
|
||||
source: form.source,
|
||||
model: form.model,
|
||||
scoreDate: form.scoreDate,
|
||||
factors,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to add score entry.");
|
||||
return;
|
||||
}
|
||||
const change = res.data?.change;
|
||||
setStatus(change === null || change === undefined ? "Credit score entry added." : `Credit score entry added. Change: ${change > 0 ? "+" : ""}${change}.`);
|
||||
setForm((prev) => ({ ...prev, score: "", positiveFactors: "", negativeFactors: "" }));
|
||||
await load();
|
||||
};
|
||||
|
||||
const pullScore = async () => {
|
||||
const bureau = ["experian", "equifax", "transunion"].includes(form.bureau) ? form.bureau : "experian";
|
||||
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/pull", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
bureau,
|
||||
consent: {
|
||||
acceptedAt: new Date().toISOString(),
|
||||
purpose: "credit_score_monitoring",
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to pull credit score.");
|
||||
return;
|
||||
}
|
||||
setStatus(`${bureau} score pulled: ${res.data.score}.`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell title="Credit Score" subtitle="Track bureau scores, history, factors, and score movement alerts.">
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Latest score</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.latest?.score ?? "--"}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{scoreBand(summary?.latest?.score)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Last change</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">
|
||||
{summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Compared with previous same-bureau entry</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Average score</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.averageScore ?? "--"}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Across saved entries</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-5">
|
||||
<p className="text-xs text-muted-foreground">Entries</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.entryCount ?? 0}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Manual or imported history</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Add Score Entry</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelCls}>Score</label>
|
||||
<input type="number" min={300} max={850} value={form.score} onChange={(event) => setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Bureau</label>
|
||||
<select value={form.bureau} onChange={(event) => setForm((prev) => ({ ...prev, bureau: event.target.value }))} className={inputCls}>
|
||||
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Model</label>
|
||||
<select value={form.model} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} className={inputCls}>
|
||||
<option value="fico_8">FICO 8</option>
|
||||
<option value="fico_9">FICO 9</option>
|
||||
<option value="vantage_score_3">VantageScore 3</option>
|
||||
<option value="vantage_score_4">VantageScore 4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Score date</label>
|
||||
<input type="date" value={form.scoreDate} onChange={(event) => setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Positive factors</label>
|
||||
<input value={form.positiveFactors} onChange={(event) => setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Negative factors</label>
|
||||
<input value={form.negativeFactors} onChange={(event) => setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button onClick={addEntry} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
|
||||
Add Score
|
||||
</button>
|
||||
<button onClick={pullScore} className="rounded-lg border border-primary/30 px-4 py-2.5 text-sm font-bold text-primary hover:bg-primary/10">
|
||||
Pull From Provider
|
||||
</button>
|
||||
</div>
|
||||
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Bureau Snapshot</p>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
|
||||
<option value="all">All bureaus</option>
|
||||
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{(summary?.latestByBureau ?? []).map((entry) => (
|
||||
<div key={entry.bureau} className="rounded-lg border border-border bg-background/50 p-4">
|
||||
<p className="text-xs uppercase text-muted-foreground">{entry.bureau}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{entry.score}</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
))}
|
||||
{(summary?.latestByBureau?.length ?? 0) === 0 && <p className="text-sm text-muted-foreground">No bureau scores yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<p className="text-sm font-bold text-foreground">Score History</p>
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{filteredEntries.length === 0 && <p className="py-8 text-sm text-muted-foreground">No score entries for this view.</p>}
|
||||
{filteredEntries.map((entry) => {
|
||||
const factors = entry.factors ?? {};
|
||||
const positive = Array.isArray(factors.positive) ? factors.positive : [];
|
||||
const negative = Array.isArray(factors.negative) ? factors.negative : [];
|
||||
return (
|
||||
<article key={entry.id} className="py-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-lg font-bold text-foreground">{entry.score}</p>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.bureau}</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.model}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}</p>
|
||||
{(positive.length > 0 || negative.length > 0) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""}
|
||||
{positive.length > 0 && negative.length > 0 ? " · " : ""}
|
||||
{negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{scoreBand(entry.score)}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -56,7 +56,7 @@ export default function DemoPage() {
|
||||
<div className="mx-auto max-w-6xl px-6 lg:px-8 space-y-8">
|
||||
<header className="space-y-3">
|
||||
<p className="text-xs font-semibold tracking-[0.25em] text-emerald-400 uppercase">
|
||||
Demo · Aarthalabs
|
||||
Demo · LedgerOne
|
||||
</p>
|
||||
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-slate-50">
|
||||
AI-powered cash control dashboard
|
||||
@ -246,7 +246,7 @@ export default function DemoPage() {
|
||||
AI
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
<p className="font-medium">Aarthalabs Copilot</p>
|
||||
<p className="font-medium">LedgerOne Copilot</p>
|
||||
<p className="text-[11px] text-slate-500">Monitors cash flow in real-time</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type KeyListResponse = { keys: ApiKey[] };
|
||||
type CreatedKey = ApiKey & { key: string };
|
||||
|
||||
const apiExample = `curl -H "Authorization: Bearer l1_your_api_key" \\
|
||||
"https://api.aarthalabs.com/api/public/v1/transactions?limit=25"`;
|
||||
|
||||
const sdkExample = `async function ledgerOne(path, apiKey) {
|
||||
const res = await fetch(\`https://api.aarthalabs.com/api/public/v1\${path}\`, {
|
||||
headers: { Authorization: \`Bearer \${apiKey}\` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const transactions = await ledgerOne("/transactions?limit=25", process.env.LEDGERONE_API_KEY);`;
|
||||
|
||||
export default function DeveloperPage() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [name, setName] = useState("Automation key");
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = async () => {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<KeyListResponse>("/api/api-keys");
|
||||
if (res.error) {
|
||||
setMessage(res.error.message);
|
||||
} else {
|
||||
setKeys(res.data.keys);
|
||||
setMessage(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadKeys();
|
||||
}, []);
|
||||
|
||||
const createKey = async () => {
|
||||
const res = await apiFetch<CreatedKey>("/api/api-keys", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scopes: ["transactions:read"] }),
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(res.error.message);
|
||||
return;
|
||||
}
|
||||
setCreatedKey(res.data.key);
|
||||
setName("Automation key");
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const revokeKey = async (id: string) => {
|
||||
const res = await apiFetch<{ revoked: boolean }>(`/api/api-keys/${id}`, { method: "DELETE" });
|
||||
if (res.error) {
|
||||
setMessage(res.error.message);
|
||||
return;
|
||||
}
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Developer" subtitle="API keys, public endpoint docs, and integration examples.">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
{message ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">{message}</div>
|
||||
) : null}
|
||||
|
||||
{createdKey ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<h2 className="text-sm font-semibold text-emerald-950">New API key</h2>
|
||||
<p className="mt-1 text-xs text-emerald-800">This secret is shown once. Store it in your password manager or server environment.</p>
|
||||
<pre className="mt-3 overflow-x-auto rounded-md bg-white p-3 text-xs text-slate-900">{createdKey}</pre>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-950">Create Key</h2>
|
||||
<label className="mt-4 block text-sm font-medium text-slate-700">Key name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={createKey}
|
||||
className="mt-4 rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
Create API key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-950">Read Endpoints</h2>
|
||||
<div className="mt-3 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<span>/public/v1/transactions</span>
|
||||
<span>/public/v1/transactions/summary</span>
|
||||
<span>/public/v1/transactions/cashflow</span>
|
||||
<span>/public/v1/transactions/merchants</span>
|
||||
</div>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-3 text-xs text-slate-50">{apiExample}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-slate-950">API Keys</h2>
|
||||
<button onClick={loadKeys} className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-sm">
|
||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Prefix</th>
|
||||
<th className="py-2">Scopes</th>
|
||||
<th className="py-2">Last used</th>
|
||||
<th className="py-2">Status</th>
|
||||
<th className="py-2 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="py-4 text-slate-500" colSpan={6}>Loading keys...</td></tr>
|
||||
) : keys.length ? (
|
||||
keys.map((key) => (
|
||||
<tr key={key.id} className="border-b border-slate-100">
|
||||
<td className="py-3 font-medium text-slate-900">{key.name}</td>
|
||||
<td className="py-3 text-slate-600">{key.prefix}</td>
|
||||
<td className="py-3 text-slate-600">{key.scopes.join(", ")}</td>
|
||||
<td className="py-3 text-slate-600">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td>
|
||||
<td className="py-3 text-slate-600">{key.revokedAt ? "Revoked" : "Active"}</td>
|
||||
<td className="py-3 text-right">
|
||||
{!key.revokedAt ? (
|
||||
<button onClick={() => revokeKey(key.id)} className="rounded-md border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700">
|
||||
Revoke
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td className="py-4 text-slate-500" colSpan={6}>No API keys yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h2 className="text-base font-semibold text-slate-950">Minimal JavaScript Client</h2>
|
||||
<pre className="mt-3 overflow-x-auto rounded-md bg-slate-950 p-3 text-xs text-slate-50">{sdkExample}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user