feat: implement LedgerOne frontend backlog features
This commit is contained in:
parent
f59009af57
commit
2b17c36ed2
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/disable");
|
||||
return proxyRequest(req, "auth/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, "2fa/enable");
|
||||
return proxyRequest(req, "auth/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, "2fa/generate");
|
||||
return proxyRequest(req, "auth/2fa/generate");
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link-token");
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link");
|
||||
}
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/login");
|
||||
const res = await proxyRequest(req, "auth/login");
|
||||
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken) {
|
||||
const { accessToken, refreshToken, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@ -1,6 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBackendUrl } from "@/lib/backend";
|
||||
import { REFRESH_COOKIE, clearAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/logout");
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const backendRes = refreshToken
|
||||
? await fetch(getBackendUrl("auth/logout"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"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;
|
||||
}
|
||||
|
||||
@ -4,3 +4,7 @@ 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,6 +1,44 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getBackendUrl } from "@/lib/backend";
|
||||
import { REFRESH_COOKIE, clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/refresh");
|
||||
const refreshToken = req.cookies.get(REFRESH_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") ?? "",
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
const payload = await backendRes.text();
|
||||
const parsed = JSON.parse(payload) as { data?: { accessToken?: string; refreshToken?: string; [key: string]: unknown } };
|
||||
const responsePayload = backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken
|
||||
? 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) {
|
||||
setAuthCookies(res, parsed.data.accessToken, parsed.data.refreshToken);
|
||||
} else {
|
||||
clearAuthCookies(res);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/register");
|
||||
const res = await proxyRequest(req, "auth/register");
|
||||
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken) {
|
||||
const { accessToken, refreshToken, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
18
app/api/auth/social/[provider]/callback/route.ts
Normal file
18
app/api/auth/social/[provider]/callback/route.ts
Normal file
@ -0,0 +1,18 @@
|
||||
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; [key: string]: unknown } } | null;
|
||||
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken) {
|
||||
const { accessToken, refreshToken, ...safeData } = payload.data;
|
||||
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
|
||||
setAuthCookies(safeRes, accessToken, refreshToken);
|
||||
return safeRes;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
9
app/api/auth/social/[provider]/url/route.ts
Normal file
9
app/api/auth/social/[provider]/url/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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`);
|
||||
}
|
||||
6
app/api/billing/checkout/route.ts
Normal file
6
app/api/billing/checkout/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/checkout");
|
||||
}
|
||||
6
app/api/billing/portal/route.ts
Normal file
6
app/api/billing/portal/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/portal");
|
||||
}
|
||||
6
app/api/billing/subscription/route.ts
Normal file
6
app/api/billing/subscription/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "billing/subscription");
|
||||
}
|
||||
36
app/api/exports/download/[token]/route.ts
Normal file
36
app/api/exports/download/[token]/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
6
app/api/exports/json/route.ts
Normal file
6
app/api/exports/json/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/json");
|
||||
}
|
||||
6
app/api/exports/pdf/route.ts
Normal file
6
app/api/exports/pdf/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/pdf");
|
||||
}
|
||||
6
app/api/exports/xlsx/route.ts
Normal file
6
app/api/exports/xlsx/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/xlsx");
|
||||
}
|
||||
10
app/api/households/[id]/dashboard/route.ts
Normal file
10
app/api/households/[id]/dashboard/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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`);
|
||||
}
|
||||
10
app/api/households/route.ts
Normal file
10
app/api/households/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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");
|
||||
}
|
||||
6
app/api/plaid/repair-complete/route.ts
Normal file
6
app/api/plaid/repair-complete/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/repair-complete");
|
||||
}
|
||||
6
app/api/plaid/update-link-token/route.ts
Normal file
6
app/api/plaid/update-link-token/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/update-link-token");
|
||||
}
|
||||
6
app/api/rules/[id]/execute/route.ts
Normal file
6
app/api/rules/[id]/execute/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
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`);
|
||||
}
|
||||
6
app/api/security/risk/route.ts
Normal file
6
app/api/security/risk/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
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, "stripe/checkout");
|
||||
return proxyRequest(req, "billing/checkout");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/portal");
|
||||
return proxyRequest(req, "billing/portal");
|
||||
}
|
||||
|
||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/subscription");
|
||||
return proxyRequest(req, "billing/subscription");
|
||||
}
|
||||
|
||||
9
app/api/tax/returns/[id]/documents/route.ts
Normal file
9
app/api/tax/returns/[id]/documents/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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`);
|
||||
}
|
||||
16
app/api/tax/returns/[id]/efile/route.ts
Normal file
16
app/api/tax/returns/[id]/efile/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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 GET(
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
|
||||
16
app/api/tax/returns/[id]/intake/route.ts
Normal file
16
app/api/tax/returns/[id]/intake/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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`);
|
||||
}
|
||||
6
app/api/teller/config/route.ts
Normal file
6
app/api/teller/config/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/config");
|
||||
}
|
||||
6
app/api/teller/enrollment/route.ts
Normal file
6
app/api/teller/enrollment/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/enrollment");
|
||||
}
|
||||
6
app/api/teller/sync/route.ts
Normal file
6
app/api/teller/sync/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "teller/sync");
|
||||
}
|
||||
6
app/api/transactions/import/batch/route.ts
Normal file
6
app/api/transactions/import/batch/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import/batch");
|
||||
}
|
||||
6
app/api/transactions/import/preview/route.ts
Normal file
6
app/api/transactions/import/preview/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import/preview");
|
||||
}
|
||||
@ -9,17 +9,50 @@ type Account = {
|
||||
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 };
|
||||
};
|
||||
};
|
||||
|
||||
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 [updateAccountId, setUpdateAccountId] = 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 createLinkToken = useCallback(async () => {
|
||||
setStatus("Requesting Plaid link token...");
|
||||
@ -43,36 +76,86 @@ export default function ConnectPage() {
|
||||
}, []);
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
|
||||
const res = await fetch("/api/accounts");
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await res.json();
|
||||
setAccounts(payload.data ?? []);
|
||||
setAccounts(payload.data?.accounts ?? payload.data ?? []);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
createLinkToken();
|
||||
loadAccounts();
|
||||
}, [createLinkToken, loadAccounts]);
|
||||
loadTellerScript();
|
||||
}, [createLinkToken, loadAccounts, loadTellerScript]);
|
||||
|
||||
const onSuccess = useCallback(
|
||||
async (publicToken: string) => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
setStatus("Missing user id. Please sign in again.");
|
||||
async (publicToken: string | null) => {
|
||||
if (linkMode === "update" && updateAccountId) {
|
||||
setStatus("Finishing bank reconnection...");
|
||||
try {
|
||||
const res = await fetch("/api/plaid/repair-complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ accountId: updateAccountId })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Unable to finish bank reconnection.");
|
||||
return;
|
||||
}
|
||||
setStatus("Bank connection repaired.");
|
||||
setUpdateAccountId(null);
|
||||
setLinkMode("connect");
|
||||
setLinkToken(null);
|
||||
await loadAccounts();
|
||||
await createLinkToken();
|
||||
} catch {
|
||||
setStatus("Unable to finish bank reconnection.");
|
||||
}
|
||||
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, userId })
|
||||
body: JSON.stringify({ publicToken })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.error) {
|
||||
@ -80,12 +163,14 @@ export default function ConnectPage() {
|
||||
return;
|
||||
}
|
||||
setStatus("Bank account connected.");
|
||||
setLinkToken(null);
|
||||
await loadAccounts();
|
||||
await createLinkToken();
|
||||
} catch {
|
||||
setStatus("Unable to exchange token.");
|
||||
}
|
||||
},
|
||||
[loadAccounts]
|
||||
[createLinkToken, linkMode, loadAccounts, updateAccountId]
|
||||
);
|
||||
|
||||
const { open, ready } = usePlaidLink({
|
||||
@ -96,15 +181,16 @@ 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)
|
||||
@ -132,6 +218,89 @@ export default function ConnectPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const startUpdateMode = async (accountId: 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 })
|
||||
});
|
||||
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;
|
||||
}
|
||||
setUpdateAccountId(accountId);
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Connect a bank"
|
||||
@ -151,6 +320,21 @@ 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"
|
||||
@ -168,17 +352,34 @@ export default function ConnectPage() {
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
Connected
|
||||
{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.id)}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -243,7 +444,7 @@ export default function ConnectPage() {
|
||||
</form>
|
||||
) : null}
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Your first two connections are free. Upgrade to add unlimited accounts.
|
||||
Free includes two connections. Pro supports ten active accounts, and Elite supports unlimited accounts.
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
127
app/auth/social/callback/page.tsx
Normal file
127
app/auth/social/callback/page.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
91
app/compare/vs-monarch/page.tsx
Normal file
91
app/compare/vs-monarch/page.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
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: "LedgerOne vs Monarch Money",
|
||||
description:
|
||||
"Compare LedgerOne'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: "LedgerOne vs Monarch Money",
|
||||
description: "Comparison of LedgerOne 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. LedgerOne 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">LedgerOne</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>
|
||||
);
|
||||
}
|
||||
@ -5,11 +5,15 @@ import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ExportData = { status: string; csv?: string; rowCount?: number };
|
||||
type SignedExportData = { status: string; downloadUrl?: string; expiresAt?: string; expiresInSeconds?: number; singleUse?: boolean };
|
||||
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
|
||||
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
|
||||
|
||||
export default function ExportsPage() {
|
||||
const [csvStatus, setCsvStatus] = useState("");
|
||||
const [jsonStatus, setJsonStatus] = useState("");
|
||||
const [xlsxStatus, setXlsxStatus] = useState("");
|
||||
const [pdfStatus, setPdfStatus] = useState("");
|
||||
const [sheetsStatus, setSheetsStatus] = useState("");
|
||||
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
|
||||
const [sheetsLoading, setSheetsLoading] = useState(false);
|
||||
@ -65,23 +69,64 @@ export default function ExportsPage() {
|
||||
return params;
|
||||
};
|
||||
|
||||
const onExportCsv = async () => {
|
||||
setCsvStatus("Generating export...");
|
||||
const params = buildParams();
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
|
||||
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
|
||||
if (res.data?.csv) {
|
||||
const blob = new Blob([res.data.csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const downloadSignedFile = (url: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.rel = "noopener";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
|
||||
};
|
||||
|
||||
const requestSignedDownload = async (format: "csv" | "json" | "xlsx" | "pdf") => {
|
||||
const params = buildParams();
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch<SignedExportData>(`/api/exports/${format}${query}`);
|
||||
};
|
||||
|
||||
const onExportCsv = async () => {
|
||||
setCsvStatus("Generating export...");
|
||||
const res = await requestSignedDownload("csv");
|
||||
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
|
||||
if (res.data?.downloadUrl) {
|
||||
downloadSignedFile(res.data.downloadUrl);
|
||||
setCsvStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
|
||||
} else {
|
||||
setCsvStatus("Export ready.");
|
||||
setCsvStatus("Export link created.");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportJson = async () => {
|
||||
setJsonStatus("Generating JSON export...");
|
||||
const res = await requestSignedDownload("json");
|
||||
if (res.error) { setJsonStatus(res.error.message ?? "JSON export failed."); return; }
|
||||
if (res.data?.downloadUrl) {
|
||||
downloadSignedFile(res.data.downloadUrl);
|
||||
setJsonStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
|
||||
} else {
|
||||
setJsonStatus("JSON export link created.");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportXlsx = async () => {
|
||||
setXlsxStatus("Generating XLSX export...");
|
||||
const res = await requestSignedDownload("xlsx");
|
||||
if (res.error) { setXlsxStatus(res.error.message ?? "XLSX export failed."); return; }
|
||||
if (res.data?.downloadUrl) {
|
||||
downloadSignedFile(res.data.downloadUrl);
|
||||
setXlsxStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
|
||||
} else {
|
||||
setXlsxStatus("XLSX export link created.");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportPdf = async () => {
|
||||
setPdfStatus("Generating PDF export...");
|
||||
const res = await requestSignedDownload("pdf");
|
||||
if (res.error) { setPdfStatus(res.error.message ?? "PDF export failed."); return; }
|
||||
if (res.data?.downloadUrl) {
|
||||
downloadSignedFile(res.data.downloadUrl);
|
||||
setPdfStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
|
||||
} else {
|
||||
setPdfStatus("PDF export link created.");
|
||||
}
|
||||
};
|
||||
|
||||
@ -140,7 +185,7 @@ export default function ExportsPage() {
|
||||
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
|
||||
<AppShell title="Exports" subtitle="Generate CSV, JSON, XLSX, PDF, or Google Sheets exports.">
|
||||
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
@ -185,7 +230,7 @@ export default function ExportsPage() {
|
||||
<div className="mt-6" />
|
||||
|
||||
{/* Export cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{/* CSV */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
@ -208,6 +253,72 @@ export default function ExportsPage() {
|
||||
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* JSON */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l-3 3 3 3m8-6l3 3-3 3M13 5l-2 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Download JSON</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Structured transaction export for custom tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportJson}
|
||||
className="mt-4 w-full rounded-lg border border-amber-500/30 bg-amber-500/10 py-2.5 px-4 text-sm font-bold text-amber-500 hover:bg-amber-500/20 transition-all"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
{jsonStatus && <p className="mt-2 text-xs text-muted-foreground">{jsonStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* XLSX */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-6m3 6V7m3 10v-4m4 8H5a2 2 0 01-2-2V5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Download XLSX</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Excel workbook with filtered transaction data.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportXlsx}
|
||||
className="mt-4 w-full rounded-lg border border-blue-500/30 bg-blue-500/10 py-2.5 px-4 text-sm font-bold text-blue-500 hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
Export XLSX
|
||||
</button>
|
||||
{xlsxStatus && <p className="mt-2 text-xs text-muted-foreground">{xlsxStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* PDF */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9l-6-6H7a2 2 0 00-2 2v14a2 2 0 002 2zm2-8h6m-6 4h6m-6-8h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Download PDF</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Printable ledger snapshot for audit packets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportPdf}
|
||||
className="mt-4 w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
{pdfStatus && <p className="mt-2 text-xs text-muted-foreground">{pdfStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* Google Sheets */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@ -19,8 +19,8 @@ type ApiResponse<T> = {
|
||||
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
};
|
||||
|
||||
@ -37,9 +37,31 @@ function LoginForm() {
|
||||
const [staySignedIn, setStaySignedIn] = useState(true);
|
||||
const [totpToken, setTotpToken] = useState("");
|
||||
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
|
||||
const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const startSocialLogin = async (provider: "google" | "apple") => {
|
||||
setSocialLoading(provider);
|
||||
setStatus(`Redirecting to ${provider === "google" ? "Google" : "Apple"}...`);
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch(`/api/auth/social/${provider}/url?next=${encodeURIComponent(nextPath)}`);
|
||||
const payload = (await res.json()) as ApiResponse<{ authUrl: string }>;
|
||||
if (!res.ok || payload.error || !payload.data?.authUrl) {
|
||||
setStatus(payload.error?.message ?? `${provider === "google" ? "Google" : "Apple"} sign in is unavailable.`);
|
||||
setIsError(true);
|
||||
setSocialLoading(null);
|
||||
return;
|
||||
}
|
||||
window.location.href = payload.data.authUrl;
|
||||
} catch {
|
||||
setStatus(`${provider === "google" ? "Google" : "Apple"} sign in failed to start.`);
|
||||
setIsError(true);
|
||||
setSocialLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Signing in...");
|
||||
@ -64,8 +86,6 @@ function LoginForm() {
|
||||
}
|
||||
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus(`Welcome back, ${payload.data.user.email}`);
|
||||
@ -84,16 +104,20 @@ function LoginForm() {
|
||||
type="button"
|
||||
className={socialButtonClass}
|
||||
aria-label="Continue with Apple"
|
||||
disabled={socialLoading !== null}
|
||||
onClick={() => startSocialLogin("apple")}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
Continue with Apple
|
||||
{socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={socialButtonClass}
|
||||
aria-label="Continue with Google"
|
||||
disabled={socialLoading !== null}
|
||||
onClick={() => startSocialLogin("google")}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
@ -101,7 +125,7 @@ function LoginForm() {
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
{socialLoading === "google" ? "Redirecting..." : "Continue with Google"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Background } from "../../components/background";
|
||||
import { ContactSection } from "../../components/contact-section";
|
||||
import { DemoCta } from "../../components/demo-cta";
|
||||
@ -9,23 +8,25 @@ import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
import { PricingAction } from "./pricing-actions";
|
||||
|
||||
export const metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
||||
"LedgerOne pricing: Free includes two accounts, Pro includes ten accounts, and Elite includes unlimited accounts.",
|
||||
keywords: siteInfo.keywords
|
||||
};
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Starter",
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price: "Free",
|
||||
tagline: "Best for getting your first ledger online.",
|
||||
badge: "2 accounts",
|
||||
features: [
|
||||
"First two connected accounts",
|
||||
"Unlimited exports",
|
||||
"2 active accounts",
|
||||
"Core ledger views",
|
||||
"Rule engine access",
|
||||
"Audit logs included"
|
||||
],
|
||||
@ -33,27 +34,44 @@ const plans = [
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
name: "Unlimited",
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: "$9",
|
||||
tagline: "Scale your ledger without limits.",
|
||||
tagline: "For teams that need secure exports and more accounts.",
|
||||
badge: "10 accounts",
|
||||
features: [
|
||||
"10 active accounts",
|
||||
"Unlimited secure exports",
|
||||
"Google Sheets sync",
|
||||
"Public API access"
|
||||
],
|
||||
cta: "Choose Pro",
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
id: "elite",
|
||||
name: "Elite",
|
||||
price: "Custom",
|
||||
tagline: "For larger ledgers and dedicated support.",
|
||||
badge: "Unlimited accounts",
|
||||
features: [
|
||||
"Unlimited connected accounts",
|
||||
"Priority sync cadence",
|
||||
"Advanced rule automation",
|
||||
"Team-ready exports"
|
||||
"Unlimited active accounts",
|
||||
"Unlimited secure exports",
|
||||
"Everything in Pro",
|
||||
"Dedicated support"
|
||||
],
|
||||
cta: "Choose Unlimited",
|
||||
primary: true
|
||||
cta: "Choose Elite",
|
||||
primary: false
|
||||
}
|
||||
];
|
||||
] as const;
|
||||
|
||||
const comparisons = [
|
||||
{ label: "Connected accounts", starter: "2", pro: "Unlimited" },
|
||||
{ label: "Exports", starter: "Unlimited", pro: "Unlimited" },
|
||||
{ label: "Rule engine", starter: "Core rules", pro: "Advanced rules" },
|
||||
{ label: "Audit logs", starter: "Included", pro: "Included" },
|
||||
{ label: "Support", starter: "Standard", pro: "Priority" }
|
||||
{ label: "Connected accounts", free: "2", pro: "10", elite: "Unlimited" },
|
||||
{ label: "Exports", free: "Not included", pro: "Unlimited", elite: "Unlimited" },
|
||||
{ label: "Google Sheets sync", free: "Not included", pro: "Included", elite: "Included" },
|
||||
{ label: "Public API", free: "Not included", pro: "Included", elite: "Included" },
|
||||
{ label: "Rule engine", free: "Core rules", pro: "Advanced rules", elite: "Advanced rules" },
|
||||
{ label: "Support", free: "Standard", pro: "Priority", elite: "Dedicated" }
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
@ -63,7 +81,7 @@ export default function PricingPage() {
|
||||
"@type": "WebPage",
|
||||
name: "LedgerOne Pricing",
|
||||
description:
|
||||
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
||||
"LedgerOne pricing: Free includes two accounts, Pro includes ten accounts, and Elite includes unlimited accounts.",
|
||||
url: `${siteInfo.url}/pricing`
|
||||
},
|
||||
{
|
||||
@ -88,12 +106,12 @@ export default function PricingPage() {
|
||||
Simple, transparent pricing.
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Start with the essentials and upgrade only when you need more accounts.
|
||||
Both plans include unlimited exports and audit logs.
|
||||
Start with two accounts for free, move to Pro for secure exports and ten accounts,
|
||||
or choose Elite when your ledger needs unlimited account coverage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.name}
|
||||
@ -112,7 +130,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
<div className="mt-4 flex items-baseline text-foreground">
|
||||
<span className="text-4xl font-bold tracking-tight">{plan.price}</span>
|
||||
{plan.price !== "Free" && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
|
||||
{plan.price.startsWith("$") && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p>
|
||||
|
||||
@ -129,12 +147,7 @@ export default function PricingPage() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/register"
|
||||
className={`mt-8 block w-full text-center ${plan.primary ? "btn-primary" : "btn-secondary"}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
<PricingAction plan={plan.id} label={plan.cta} primary={plan.primary} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -148,16 +161,18 @@ export default function PricingPage() {
|
||||
<thead className="bg-secondary/30">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Feature</th>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Starter</th>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Unlimited</th>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Free</th>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Pro</th>
|
||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Elite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background divide-y divide-border">
|
||||
{comparisons.map((item) => (
|
||||
<tr key={item.label}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">{item.label}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.starter}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.free}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.pro}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.elite}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
54
app/pricing/pricing-actions.tsx
Normal file
54
app/pricing/pricing-actions.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type PricingActionProps = {
|
||||
plan: "free" | "pro" | "elite";
|
||||
label: string;
|
||||
primary: boolean;
|
||||
};
|
||||
|
||||
export function PricingAction({ plan, label, primary }: PricingActionProps) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const className = `mt-8 block w-full text-center ${primary ? "btn-primary" : "btn-secondary"} disabled:opacity-60`;
|
||||
|
||||
if (plan === "free") {
|
||||
return (
|
||||
<Link href="/register" className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const startCheckout = async () => {
|
||||
setLoading(true);
|
||||
setStatus("Opening checkout...");
|
||||
const appUrl = window.location.origin;
|
||||
const response = await apiFetch<{ url: string }>("/api/billing/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
|
||||
cancelUrl: `${appUrl}/pricing`,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (response.error || !response.data?.url) {
|
||||
setStatus(response.error?.message ?? "Sign in before choosing a paid plan.");
|
||||
return;
|
||||
}
|
||||
window.location.href = response.data.url;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={startCheckout} disabled={loading} className={className}>
|
||||
{loading ? "Opening..." : label}
|
||||
</button>
|
||||
{status ? <p className="mt-3 text-center text-xs text-muted-foreground">{status}</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
@ -26,7 +27,6 @@ type ProfileData = {
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
@ -38,25 +38,12 @@ export default function ProfilePage() {
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("ledgerone_token") ?? "";
|
||||
setToken(stored);
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
setStatus("Please sign in to update your profile.");
|
||||
return;
|
||||
}
|
||||
setStatus("Saving profile...");
|
||||
try {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
const payload = await apiFetch<ProfileData>("/api/auth/profile", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fullName,
|
||||
phone: phone || undefined,
|
||||
@ -69,8 +56,7 @@ export default function ProfilePage() {
|
||||
country: country || undefined
|
||||
})
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<ProfileData>;
|
||||
if (!res.ok || payload.error) {
|
||||
if (payload.error) {
|
||||
setStatus(payload.error?.message ?? "Profile update failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -20,8 +20,8 @@ type ApiResponse<T> = {
|
||||
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@ -39,6 +39,7 @@ export default function RegisterPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [agreeTerms, setAgreeTerms] = useState(false);
|
||||
const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
@ -83,8 +84,6 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus("Account created! Redirecting...");
|
||||
@ -95,6 +94,27 @@ export default function RegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startSocialLogin = async (provider: "google" | "apple") => {
|
||||
setSocialLoading(provider);
|
||||
setStatus(`Redirecting to ${provider === "google" ? "Google" : "Apple"}...`);
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch(`/api/auth/social/${provider}/url?next=${encodeURIComponent("/app")}`);
|
||||
const payload = (await res.json()) as ApiResponse<{ authUrl: string }>;
|
||||
if (!res.ok || payload.error || !payload.data?.authUrl) {
|
||||
setStatus(payload.error?.message ?? `${provider === "google" ? "Google" : "Apple"} sign up is unavailable.`);
|
||||
setIsError(true);
|
||||
setSocialLoading(null);
|
||||
return;
|
||||
}
|
||||
window.location.href = payload.data.authUrl;
|
||||
} catch {
|
||||
setStatus(`${provider === "google" ? "Google" : "Apple"} sign up failed to start.`);
|
||||
setIsError(true);
|
||||
setSocialLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
|
||||
<Background />
|
||||
@ -120,16 +140,20 @@ export default function RegisterPage() {
|
||||
type="button"
|
||||
className={socialButtonClass}
|
||||
aria-label="Continue with Apple"
|
||||
disabled={socialLoading !== null}
|
||||
onClick={() => startSocialLogin("apple")}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
Continue with Apple
|
||||
{socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={socialButtonClass}
|
||||
aria-label="Continue with Google"
|
||||
disabled={socialLoading !== null}
|
||||
onClick={() => startSocialLogin("google")}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
@ -149,7 +173,7 @@ export default function RegisterPage() {
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
{socialLoading === "google" ? "Redirecting..." : "Continue with Google"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,12 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type RuleRow = {
|
||||
id: string;
|
||||
@ -24,6 +19,9 @@ type Suggestion = {
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
confidence: number;
|
||||
reason?: string;
|
||||
matchCount?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
@ -31,28 +29,47 @@ export default function RulesPage() {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [status, setStatus] = useState("Loading rules...");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [builderMode, setBuilderMode] = useState<"simple" | "advanced">("simple");
|
||||
const [advancedConditions, setAdvancedConditions] = useState(`{
|
||||
"all": [
|
||||
{ "field": "description", "operator": "contains", "value": "coffee" },
|
||||
{
|
||||
"any": [
|
||||
{ "field": "amount", "operator": ">", "value": 5 },
|
||||
{ "field": "source", "operator": "equals", "value": "csv" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}`);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
priority: "",
|
||||
textContains: "",
|
||||
textNotContains: "",
|
||||
textRegex: "",
|
||||
amountGreater: "",
|
||||
amountLess: "",
|
||||
amountEquals: "",
|
||||
sourceEquals: "",
|
||||
categoryEquals: "",
|
||||
dateAfter: "",
|
||||
dateBefore: "",
|
||||
setCategory: "",
|
||||
setHidden: false,
|
||||
clearCategory: false,
|
||||
setNote: "",
|
||||
appendNote: "",
|
||||
clearNote: false,
|
||||
hiddenAction: "none",
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||
try {
|
||||
const [rulesRes, suggestionsRes] = await Promise.all([
|
||||
fetch(`/api/rules${query}`),
|
||||
fetch(`/api/rules/suggestions${query}`)
|
||||
const [rulesPayload, suggestionsPayload] = await Promise.all([
|
||||
apiFetch<RuleRow[]>("/api/rules"),
|
||||
apiFetch<Suggestion[]>("/api/rules/suggestions")
|
||||
]);
|
||||
const rulesPayload = (await rulesRes.json()) as ApiResponse<RuleRow[]>;
|
||||
const suggestionsPayload = (await suggestionsRes.json()) as ApiResponse<Suggestion[]>;
|
||||
if (!rulesRes.ok || rulesPayload.error) {
|
||||
if (rulesPayload.error) {
|
||||
setStatus(rulesPayload.error?.message ?? "Unable to load rules.");
|
||||
return;
|
||||
}
|
||||
@ -69,34 +86,49 @@ export default function RulesPage() {
|
||||
}, []);
|
||||
|
||||
const onCreate = async () => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
setStatus("Missing user id.");
|
||||
let conditions: Record<string, unknown>;
|
||||
if (builderMode === "advanced") {
|
||||
try {
|
||||
conditions = JSON.parse(advancedConditions) as Record<string, unknown>;
|
||||
} catch {
|
||||
setStatus("Advanced rule DSL must be valid JSON.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
conditions = {
|
||||
textContains: form.textContains || undefined,
|
||||
textNotContains: form.textNotContains || undefined,
|
||||
textRegex: form.textRegex || undefined,
|
||||
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
|
||||
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined,
|
||||
amountEquals: form.amountEquals ? Number(form.amountEquals) : undefined,
|
||||
sourceEquals: form.sourceEquals || undefined,
|
||||
categoryEquals: form.categoryEquals || undefined,
|
||||
dateAfter: form.dateAfter || undefined,
|
||||
dateBefore: form.dateBefore || undefined
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
userId,
|
||||
name: form.name || "Untitled rule",
|
||||
priority: form.priority ? Number(form.priority) : undefined,
|
||||
isActive: form.isActive,
|
||||
conditions: {
|
||||
textContains: form.textContains || undefined,
|
||||
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
|
||||
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined
|
||||
},
|
||||
conditions,
|
||||
actions: {
|
||||
setCategory: form.setCategory || undefined,
|
||||
setHidden: form.setHidden
|
||||
clearCategory: form.clearCategory || undefined,
|
||||
setNote: form.setNote || undefined,
|
||||
appendNote: form.appendNote || undefined,
|
||||
clearNote: form.clearNote || undefined,
|
||||
setHidden: form.hiddenAction === "hide" ? true : form.hiddenAction === "unhide" ? false : undefined
|
||||
}
|
||||
};
|
||||
try {
|
||||
const res = await fetch("/api/rules", {
|
||||
const data = await apiFetch<RuleRow>("/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = (await res.json()) as ApiResponse<RuleRow>;
|
||||
if (!res.ok || data.error) {
|
||||
if (data.error) {
|
||||
setStatus(data.error?.message ?? "Unable to create rule.");
|
||||
return;
|
||||
}
|
||||
@ -105,10 +137,21 @@ export default function RulesPage() {
|
||||
name: "",
|
||||
priority: "",
|
||||
textContains: "",
|
||||
textNotContains: "",
|
||||
textRegex: "",
|
||||
amountGreater: "",
|
||||
amountLess: "",
|
||||
amountEquals: "",
|
||||
sourceEquals: "",
|
||||
categoryEquals: "",
|
||||
dateAfter: "",
|
||||
dateBefore: "",
|
||||
setCategory: "",
|
||||
setHidden: false,
|
||||
clearCategory: false,
|
||||
setNote: "",
|
||||
appendNote: "",
|
||||
clearNote: false,
|
||||
hiddenAction: "none",
|
||||
isActive: true
|
||||
});
|
||||
setRules((prev) => [data.data, ...prev]);
|
||||
@ -118,6 +161,38 @@ export default function RulesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onExecute = async (ruleId: string) => {
|
||||
setStatus("Executing rule...");
|
||||
const result = await apiFetch<{ applied: number; status: string }>(`/api/rules/${ruleId}/execute`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (result.error) {
|
||||
setStatus(result.error.message ?? "Unable to execute rule.");
|
||||
return;
|
||||
}
|
||||
setStatus(`Rule ${result.data.status}. Applied to ${result.data.applied ?? 0} transaction(s).`);
|
||||
};
|
||||
|
||||
const onAcceptSuggestion = async (item: Suggestion) => {
|
||||
setStatus("Saving suggested rule...");
|
||||
const result = await apiFetch<RuleRow>("/api/rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: item.name,
|
||||
conditions: item.conditions,
|
||||
actions: item.actions,
|
||||
isActive: true,
|
||||
}),
|
||||
});
|
||||
if (result.error) {
|
||||
setStatus(result.error.message ?? "Unable to save suggested rule.");
|
||||
return;
|
||||
}
|
||||
setRules((prev) => [result.data, ...prev]);
|
||||
setSuggestions((prev) => prev.filter((suggestion) => suggestion.id !== item.id));
|
||||
setStatus("Suggested rule saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Rules" subtitle="Priority-ordered rules with full transparency.">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
@ -139,6 +214,30 @@ export default function RulesPage() {
|
||||
{showNew ? (
|
||||
<div className="mb-6 rounded-xl border border-border bg-background/50 p-4">
|
||||
<p className="text-sm font-bold text-foreground">Create a rule</p>
|
||||
<div className="mt-4 inline-flex rounded-xl border border-border bg-background p-1 text-xs font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBuilderMode("simple")}
|
||||
className={`rounded-lg px-3 py-1.5 ${builderMode === "simple" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Simple
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBuilderMode("advanced")}
|
||||
className={`rounded-lg px-3 py-1.5 ${builderMode === "advanced" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Advanced DSL
|
||||
</button>
|
||||
</div>
|
||||
{builderMode === "advanced" ? (
|
||||
<textarea
|
||||
value={advancedConditions}
|
||||
onChange={(event) => setAdvancedConditions(event.target.value)}
|
||||
spellCheck={false}
|
||||
className="mt-4 min-h-56 w-full rounded-xl border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
@ -156,6 +255,8 @@ export default function RulesPage() {
|
||||
placeholder="Priority (optional)"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
{builderMode === "simple" ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={form.textContains}
|
||||
@ -165,6 +266,26 @@ export default function RulesPage() {
|
||||
placeholder="Description contains"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.textNotContains}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, textNotContains: event.target.value }))
|
||||
}
|
||||
placeholder="Description does not contain"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.textRegex}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, textRegex: event.target.value }))
|
||||
}
|
||||
placeholder="Description regex"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
type="text"
|
||||
value={form.setCategory}
|
||||
@ -174,6 +295,26 @@ export default function RulesPage() {
|
||||
placeholder="Set category"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.setNote}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, setNote: event.target.value }))
|
||||
}
|
||||
placeholder="Set note"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.appendNote}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, appendNote: event.target.value }))
|
||||
}
|
||||
placeholder="Append note"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
{builderMode === "simple" ? (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
value={form.amountGreater}
|
||||
@ -192,18 +333,85 @@ export default function RulesPage() {
|
||||
placeholder="Amount less than"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={form.amountEquals}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, amountEquals: event.target.value }))
|
||||
}
|
||||
placeholder="Amount equals"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sourceEquals}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, sourceEquals: event.target.value }))
|
||||
}
|
||||
placeholder="Source equals, e.g. plaid"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.categoryEquals}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, categoryEquals: event.target.value }))
|
||||
}
|
||||
placeholder="Current category equals"
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={form.dateAfter}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, dateAfter: event.target.value }))
|
||||
}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={form.dateBefore}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, dateBefore: event.target.value }))
|
||||
}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<select
|
||||
value={form.hiddenAction}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, hiddenAction: event.target.value }))
|
||||
}
|
||||
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
||||
>
|
||||
<option value="none">Do not change hidden state</option>
|
||||
<option value="hide">Hide matching transactions</option>
|
||||
<option value="unhide">Unhide matching transactions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.setHidden}
|
||||
checked={form.clearCategory}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, setHidden: event.target.checked }))
|
||||
setForm((prev) => ({ ...prev, clearCategory: event.target.checked }))
|
||||
}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
Hide matching transactions
|
||||
Clear category
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.clearNote}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, clearNote: event.target.checked }))
|
||||
}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
Clear note
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
@ -260,6 +468,13 @@ export default function RulesPage() {
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(rule.actions)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExecute(rule.id)}
|
||||
className="mt-3 rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
Run rule
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -268,7 +483,7 @@ export default function RulesPage() {
|
||||
|
||||
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground font-bold">AI Suggestions</p>
|
||||
<h2 className="mt-3 text-xl font-bold text-foreground">Pattern-based rule ideas</h2>
|
||||
<h2 className="mt-3 text-xl font-bold text-foreground">Rule suggestions</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{suggestions.length ? (
|
||||
suggestions.map((item) => (
|
||||
@ -277,6 +492,9 @@ export default function RulesPage() {
|
||||
className="rounded-xl border border-border bg-background/50 p-4"
|
||||
>
|
||||
<p className="font-bold text-foreground">{item.name}</p>
|
||||
{item.reason ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{item.reason}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(item.conditions)}
|
||||
</p>
|
||||
@ -285,7 +503,15 @@ export default function RulesPage() {
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-primary">
|
||||
Confidence: {(item.confidence * 100).toFixed(0)}%
|
||||
{item.matchCount ? ` - ${item.matchCount} matches` : ""}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcceptSuggestion(item)}
|
||||
className="mt-3 rounded-full bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Accept suggestion
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
360
app/settings/households/page.tsx
Normal file
360
app/settings/households/page.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
"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;
|
||||
members?: HouseholdMember[];
|
||||
};
|
||||
|
||||
type HouseholdMember = {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
user?: {
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type HouseholdAccount = {
|
||||
displayId: string;
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string | null;
|
||||
currentBalance: number;
|
||||
availableBalance: number;
|
||||
isoCurrencyCode: string;
|
||||
ownerUserId?: string | null;
|
||||
ownershipType: "mine" | "theirs" | "joint" | string;
|
||||
lastBalanceSync?: string | null;
|
||||
syncStatus: string;
|
||||
};
|
||||
|
||||
type CashflowMonth = {
|
||||
month: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
net: number;
|
||||
transactionCount: number;
|
||||
};
|
||||
|
||||
type RecentTransaction = {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
source: string;
|
||||
category: string;
|
||||
account?: {
|
||||
institutionName?: string;
|
||||
mask?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
ownershipType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DashboardData = {
|
||||
household: Household;
|
||||
members: HouseholdMember[];
|
||||
summary: {
|
||||
memberCount: number;
|
||||
accountCount: number;
|
||||
totalBalance: number;
|
||||
availableBalance: number;
|
||||
monthlyIncome: number;
|
||||
monthlyExpenses: number;
|
||||
monthlyNet: number;
|
||||
};
|
||||
ownershipBreakdown: Record<string, { accountCount: number; balance: number }>;
|
||||
accounts: HouseholdAccount[];
|
||||
cashflow: CashflowMonth[];
|
||||
recentTransactions: RecentTransaction[];
|
||||
};
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
|
||||
const cardClass = "rounded-xl border border-border bg-secondary/10 p-5";
|
||||
|
||||
function formatMoney(value: number) {
|
||||
return money.format(value || 0);
|
||||
}
|
||||
|
||||
function monthLabel(value: string) {
|
||||
const [year, month] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, 1).toLocaleDateString("en-US", { month: "short" });
|
||||
}
|
||||
|
||||
function accountLabel(account: HouseholdAccount | RecentTransaction["account"]) {
|
||||
if (!account) return "Household account";
|
||||
const mask = account.mask ? ` ending ${account.mask}` : "";
|
||||
return `${account.institutionName ?? "Account"}${mask}`;
|
||||
}
|
||||
|
||||
export default function HouseholdSettingsPage() {
|
||||
const [households, setHouseholds] = useState<Household[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const selectedHousehold = useMemo(
|
||||
() => households.find((household) => household.id === selectedId) ?? null,
|
||||
[households, selectedId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadHouseholds();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setDashboard(null);
|
||||
return;
|
||||
}
|
||||
loadDashboard(selectedId);
|
||||
}, [selectedId]);
|
||||
|
||||
const loadHouseholds = async () => {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<Household[]>("/api/households");
|
||||
setLoading(false);
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to load households.");
|
||||
return;
|
||||
}
|
||||
const list = res.data ?? [];
|
||||
setHouseholds(list);
|
||||
setSelectedId((current) => current || list[0]?.id || "");
|
||||
};
|
||||
|
||||
const loadDashboard = async (id: string) => {
|
||||
setStatus("Loading shared dashboard...");
|
||||
const res = await apiFetch<DashboardData>(`/api/households/${id}/dashboard`);
|
||||
if (res.error) {
|
||||
setDashboard(null);
|
||||
setStatus(res.error.message ?? "Unable to load shared dashboard.");
|
||||
return;
|
||||
}
|
||||
setDashboard(res.data);
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const createHousehold = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setSaving(true);
|
||||
const res = await apiFetch<Household>("/api/households", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to create household.");
|
||||
return;
|
||||
}
|
||||
setNewName("");
|
||||
await loadHouseholds();
|
||||
if (res.data?.id) setSelectedId(res.data.id);
|
||||
};
|
||||
|
||||
const maxCashflow = Math.max(
|
||||
1,
|
||||
...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)),
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell title="Households" subtitle="Shared financial dashboard for partner and family money.">
|
||||
<div className="space-y-6">
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Active household</p>
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)} className={inputClass}>
|
||||
{households.map((household) => (
|
||||
<option key={household.id} value={household.id}>
|
||||
{household.name}
|
||||
</option>
|
||||
))}
|
||||
{!households.length && <option value="">No households yet</option>}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedId && loadDashboard(selectedId)}
|
||||
disabled={!selectedId}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{selectedHousehold && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{selectedHousehold.name} combines joint, mine, and partner-owned accounts without exposing stable account IDs in the page data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={createHousehold} className="rounded-xl border border-border bg-background/60 p-4">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Create household</label>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="Household name"
|
||||
className={`${inputClass} mt-2`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !newName.trim()}
|
||||
className="mt-3 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{status && <p className="mt-4 text-sm text-muted-foreground">{status}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">Loading households...</div>
|
||||
) : dashboard ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total balance</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.totalBalance)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{dashboard.summary.accountCount} shared accounts</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Available</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.availableBalance)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Current liquid view</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Monthly net</p>
|
||||
<p className={`mt-2 text-3xl font-bold ${dashboard.summary.monthlyNet >= 0 ? "text-green-500" : "text-red-500"}`}>
|
||||
{formatMoney(dashboard.summary.monthlyNet)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{formatMoney(dashboard.summary.monthlyIncome)} in, {formatMoney(dashboard.summary.monthlyExpenses)} out
|
||||
</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Members</p>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{dashboard.summary.memberCount}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Active household access</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-bold text-foreground">Accounts by ownership</h2>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine / theirs / joint</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{(["mine", "theirs", "joint"] as const).map((key) => {
|
||||
const item = dashboard.ownershipBreakdown[key] ?? { accountCount: 0, balance: 0 };
|
||||
return (
|
||||
<div key={key} className="rounded-xl border border-border bg-background/50 p-4">
|
||||
<p className="text-sm font-semibold capitalize text-foreground">{key}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(item.balance)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.accountCount} accounts</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{dashboard.accounts.map((account) => (
|
||||
<div key={account.displayId} className="flex flex-col gap-3 rounded-xl border border-border bg-background/40 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{accountLabel(account)}</p>
|
||||
<p className="text-sm capitalize text-muted-foreground">
|
||||
{account.accountType} · {account.ownershipType} · {account.syncStatus}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">{formatMoney(account.currentBalance)}</p>
|
||||
</div>
|
||||
))}
|
||||
{!dashboard.accounts.length && <p className="text-sm text-muted-foreground">No household accounts linked yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-foreground">Members</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{dashboard.members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{member.user?.fullName || member.user?.email || member.userId}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{member.user?.email}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold capitalize text-muted-foreground">
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-foreground">Six-month cashflow</h2>
|
||||
<div className="mt-5 space-y-4">
|
||||
{dashboard.cashflow.map((month) => (
|
||||
<div key={month.month}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-foreground">{monthLabel(month.month)}</span>
|
||||
<span className={month.net >= 0 ? "text-green-500" : "text-red-500"}>{formatMoney(month.net)}</span>
|
||||
</div>
|
||||
<div className="grid h-2 grid-cols-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="bg-green-500" style={{ width: `${Math.max(4, (month.income / maxCashflow) * 100)}%` }} />
|
||||
<div className="justify-self-end bg-red-500" style={{ width: `${Math.max(4, (month.expenses / maxCashflow) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-6 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-foreground">Recent shared transactions</h2>
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{dashboard.recentTransactions.map((transaction, index) => (
|
||||
<div key={`${transaction.date}-${index}`} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{transaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(transaction.date).toLocaleDateString()} · {transaction.category} · {accountLabel(transaction.account)}
|
||||
</p>
|
||||
</div>
|
||||
<p className={`font-bold ${transaction.amount < 0 ? "text-green-500" : "text-foreground"}`}>
|
||||
{formatMoney(transaction.amount)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{!dashboard.recentTransactions.length && <p className="py-4 text-sm text-muted-foreground">No shared transactions yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">
|
||||
Create a household to start viewing shared accounts and cashflow.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -17,6 +17,11 @@ const settingsItems = [
|
||||
description: "View plan details, upgrade options, and billing cadence.",
|
||||
href: "/settings/subscription",
|
||||
},
|
||||
{
|
||||
title: "Households",
|
||||
description: "Manage shared financial dashboards, partner access, and account ownership.",
|
||||
href: "/settings/households",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
||||
@ -13,7 +13,7 @@ type ApiResponse<T> = {
|
||||
type SubscriptionData = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
billingCycleAnchor?: number;
|
||||
currentPeriodEnd?: string;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
};
|
||||
|
||||
@ -24,9 +24,14 @@ const PLAN_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const PLAN_DESCRIPTIONS: Record<string, string> = {
|
||||
free: "Up to 2 accounts, basic CSV export, 30-day history.",
|
||||
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
|
||||
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
|
||||
free: "Up to 2 active accounts, no paid exports, and core ledger tools.",
|
||||
pro: "Up to 10 active accounts, secure exports, Google Sheets sync, and public API access.",
|
||||
elite: "Unlimited active accounts with every Pro capability and dedicated support.",
|
||||
};
|
||||
|
||||
const PLAN_PRICES: Record<"pro" | "elite", string> = {
|
||||
pro: "$9/mo",
|
||||
elite: "Custom",
|
||||
};
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
@ -36,7 +41,7 @@ export default function SubscriptionPage() {
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
apiFetch<SubscriptionData>("/api/billing/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error) setSub(res.data);
|
||||
})
|
||||
@ -48,7 +53,7 @@ export default function SubscriptionPage() {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to checkout...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
|
||||
const res = await apiFetch<{ url: string }>("/api/billing/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
@ -68,7 +73,7 @@ export default function SubscriptionPage() {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to billing portal...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
|
||||
const res = await apiFetch<{ url: string }>("/api/billing/portal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
|
||||
});
|
||||
@ -86,7 +91,7 @@ export default function SubscriptionPage() {
|
||||
|
||||
return (
|
||||
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
{/* Current plan card */}
|
||||
<div className="glass-panel rounded-2xl p-8">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
|
||||
@ -109,6 +114,11 @@ export default function SubscriptionPage() {
|
||||
{sub?.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
|
||||
)}
|
||||
{sub?.currentPeriodEnd && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Current period ends {new Date(sub.currentPeriodEnd).toLocaleDateString()}.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -123,39 +133,34 @@ export default function SubscriptionPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upgrade options */}
|
||||
{currentPlan === "free" && (
|
||||
{currentPlan !== "elite" && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["pro", "elite"] as const).map((plan) => (
|
||||
{(["pro", "elite"] as const)
|
||||
.filter((plan) => currentPlan === "free" || plan === "elite")
|
||||
.map((plan) => (
|
||||
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-foreground">{PLAN_LABELS[plan]}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-primary">{PLAN_PRICES[plan]}</p>
|
||||
</div>
|
||||
{currentPlan === plan && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">Current</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan)}
|
||||
disabled={actionLoading}
|
||||
disabled={actionLoading || currentPlan === plan}
|
||||
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to {PLAN_LABELS[plan]}
|
||||
{currentPlan === plan ? "Current plan" : `Upgrade to ${PLAN_LABELS[plan]}`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPlan === "pro" && (
|
||||
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground">Elite</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade("elite")}
|
||||
disabled={actionLoading}
|
||||
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to Elite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionStatus && (
|
||||
<p className="text-sm text-muted-foreground">{actionStatus}</p>
|
||||
)}
|
||||
|
||||
390
app/tax/page.tsx
390
app/tax/page.tsx
@ -2,27 +2,81 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type TaxReturn = {
|
||||
id: string;
|
||||
taxYear: number;
|
||||
filingType: "individual" | "business";
|
||||
jurisdictions: string[];
|
||||
status: "draft" | "ready" | "exported";
|
||||
status: "draft" | "ready" | "exported" | "efile_submitted" | "efile_accepted";
|
||||
summary?: { intake?: TaxIntake; intakeReadiness?: TaxReadiness; eFile?: EFileStatus };
|
||||
updatedAt: string;
|
||||
documents?: TaxDocument[];
|
||||
};
|
||||
|
||||
type TaxDocument = {
|
||||
id: string;
|
||||
docType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TaxIntake = {
|
||||
taxpayer: {
|
||||
name?: string;
|
||||
filingStatus?: string;
|
||||
address?: string;
|
||||
};
|
||||
income: {
|
||||
wages?: number;
|
||||
business?: number;
|
||||
interest?: number;
|
||||
dividends?: number;
|
||||
total?: number;
|
||||
};
|
||||
deductions: {
|
||||
standard?: boolean;
|
||||
charitable?: number;
|
||||
studentLoanInterest?: number;
|
||||
};
|
||||
credits: {
|
||||
education?: number;
|
||||
childTax?: number;
|
||||
};
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
type TaxReadiness = {
|
||||
complete: boolean;
|
||||
missingFields: string[];
|
||||
};
|
||||
|
||||
type EFileStatus = {
|
||||
provider?: string;
|
||||
providerMode?: string;
|
||||
submissionId?: string;
|
||||
status?: string;
|
||||
acknowledgementId?: string;
|
||||
lastCheckedAt?: string;
|
||||
};
|
||||
|
||||
const emptyIntake: TaxIntake = {
|
||||
taxpayer: { name: "", filingStatus: "Single", address: "" },
|
||||
income: { wages: 0, business: 0, interest: 0, dividends: 0, total: 0 },
|
||||
deductions: { standard: true, charitable: 0, studentLoanInterest: 0 },
|
||||
credits: { education: 0, childTax: 0 },
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export default function TaxPage() {
|
||||
const [returns, setReturns] = useState<TaxReturn[]>([]);
|
||||
const [status, setStatus] = useState("");
|
||||
const [useLocal, setUseLocal] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [useSample, setUseSample] = useState(true);
|
||||
const [selectedReturnId, setSelectedReturnId] = useState("");
|
||||
const [intake, setIntake] = useState<TaxIntake>(emptyIntake);
|
||||
const [readiness, setReadiness] = useState<TaxReadiness | null>(null);
|
||||
const [efileConsent, setEfileConsent] = useState(false);
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [filingType, setFilingType] = useState<"individual" | "business">("individual");
|
||||
const [jurisdictions, setJurisdictions] = useState<string[]>(["CA", "NY"]);
|
||||
@ -51,12 +105,12 @@ export default function TaxPage() {
|
||||
childTax: 0
|
||||
},
|
||||
documents: [
|
||||
"W-2 (Northwind Labs)",
|
||||
"1099-INT (City Bank)",
|
||||
"1099-DIV (Index Fund)",
|
||||
"1099-NEC (Doe Consulting)",
|
||||
"Health Insurance 1095-A",
|
||||
"State withholding statement"
|
||||
{ docType: "w2_or_1099", label: "W-2 / 1099 income forms" },
|
||||
{ docType: "interest_and_dividend_forms", label: "1099-INT / 1099-DIV forms" },
|
||||
{ docType: "deduction_support", label: "Deduction support" },
|
||||
{ docType: "identity_information", label: "Identity information" },
|
||||
{ docType: "state_withholding_statement", label: "State withholding statement" },
|
||||
{ docType: "health_insurance_statement", label: "Health insurance 1095-A" }
|
||||
]
|
||||
};
|
||||
|
||||
@ -113,46 +167,21 @@ export default function TaxPage() {
|
||||
{ code: "WY", name: "Wyoming" }
|
||||
];
|
||||
|
||||
const localKey = "ledgerone_tax_returns";
|
||||
|
||||
const ensureUserId = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
let userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
userId = `demo_${crypto.randomUUID()}`;
|
||||
localStorage.setItem("ledgerone_user_id", userId);
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
const loadReturns = async () => {
|
||||
if (useLocal && typeof window !== "undefined") {
|
||||
const raw = localStorage.getItem(localKey);
|
||||
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
|
||||
setStatus("Running in local-only mode.");
|
||||
setLoading(true);
|
||||
const payload = await apiFetch<TaxReturn[]>("/api/tax/returns");
|
||||
setLoading(false);
|
||||
if (payload.error) {
|
||||
setStatus(payload.error.message ?? "Unable to load returns.");
|
||||
return;
|
||||
}
|
||||
const userId = ensureUserId();
|
||||
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||
try {
|
||||
const res = await fetch(`/api/tax/returns${query}`);
|
||||
const payload = (await res.json()) as ApiResponse<TaxReturn[]>;
|
||||
if (!res.ok || payload.error) {
|
||||
throw new Error(payload.error?.message ?? "Unable to load returns.");
|
||||
}
|
||||
setReturns(payload.data);
|
||||
setUseLocal(false);
|
||||
return;
|
||||
} catch {
|
||||
if (typeof window !== "undefined") {
|
||||
const raw = localStorage.getItem(localKey);
|
||||
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
|
||||
setUseLocal(true);
|
||||
setStatus("Running in local-only mode (no backend).");
|
||||
}
|
||||
setReturns(payload.data ?? []);
|
||||
if (!selectedReturnId && payload.data?.[0]) {
|
||||
setSelectedReturnId(payload.data[0].id);
|
||||
setIntake({ ...emptyIntake, ...(payload.data[0].summary?.intake ?? {}) });
|
||||
setReadiness(payload.data[0].summary?.intakeReadiness ?? null);
|
||||
}
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -162,66 +191,117 @@ export default function TaxPage() {
|
||||
}, []);
|
||||
|
||||
const createReturn = async () => {
|
||||
const userId = ensureUserId();
|
||||
setStatus("Creating return...");
|
||||
const payload = {
|
||||
userId,
|
||||
taxYear: year,
|
||||
filingType,
|
||||
jurisdictions
|
||||
};
|
||||
if (useLocal) {
|
||||
const nextReturn: TaxReturn = {
|
||||
id: `local_${crypto.randomUUID()}`,
|
||||
taxYear: payload.taxYear,
|
||||
filingType: payload.filingType,
|
||||
jurisdictions: payload.jurisdictions,
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
const next = [...returns, nextReturn];
|
||||
localStorage.setItem(localKey, JSON.stringify(next));
|
||||
setReturns(next);
|
||||
setStatus("Return created locally.");
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/tax/returns", {
|
||||
const response = await apiFetch<TaxReturn>("/api/tax/returns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const response = (await res.json()) as ApiResponse<TaxReturn>;
|
||||
if (!res.ok || response.error) {
|
||||
if (response.error) {
|
||||
setStatus(response.error?.message ?? "Unable to create return.");
|
||||
return;
|
||||
}
|
||||
if (useSample) {
|
||||
await Promise.all(
|
||||
sampleProfile.documents.map((document) =>
|
||||
apiFetch(`/api/tax/returns/${response.data.id}/documents`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
docType: document.docType,
|
||||
metadata: { source: "sample", label: document.label, taxpayer: sampleProfile.taxpayer.name },
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
setStatus("Return created.");
|
||||
await loadReturns();
|
||||
setSelectedReturnId(response.data.id);
|
||||
if (useSample) {
|
||||
const sampleIntake = intakeFromSample();
|
||||
setIntake(sampleIntake);
|
||||
await saveIntake(response.data.id, sampleIntake, true);
|
||||
}
|
||||
};
|
||||
|
||||
const intakeFromSample = (): TaxIntake => {
|
||||
const wages = sampleProfile.income.w2.reduce((sum, item) => sum + item.wages, 0);
|
||||
const business = sampleProfile.income.selfEmployment.reduce((sum, item) => sum + item.income - item.expenses, 0);
|
||||
const interest = sampleProfile.income.interest.reduce((sum, item) => sum + item.amount, 0);
|
||||
const dividends = sampleProfile.income.dividends.reduce((sum, item) => sum + item.amount, 0);
|
||||
return {
|
||||
taxpayer: {
|
||||
name: sampleProfile.taxpayer.name,
|
||||
filingStatus: sampleProfile.taxpayer.filingStatus,
|
||||
address: sampleProfile.taxpayer.address,
|
||||
},
|
||||
income: { wages, business, interest, dividends, total: wages + business + interest + dividends },
|
||||
deductions: sampleProfile.deductions,
|
||||
credits: sampleProfile.credits,
|
||||
notes: "Sample intake generated from John Doe dataset.",
|
||||
};
|
||||
};
|
||||
|
||||
const selectReturn = async (ret: TaxReturn) => {
|
||||
setSelectedReturnId(ret.id);
|
||||
setStatus("Loading intake...");
|
||||
const response = await apiFetch<{ intake: TaxIntake; readiness: TaxReadiness }>(`/api/tax/returns/${ret.id}/intake`);
|
||||
if (response.error) {
|
||||
setStatus(response.error.message ?? "Unable to load intake.");
|
||||
return;
|
||||
}
|
||||
setIntake({ ...emptyIntake, ...(response.data.intake ?? {}) });
|
||||
setReadiness(response.data.readiness);
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const saveIntake = async (returnId = selectedReturnId, nextIntake = intake, submit = false) => {
|
||||
if (!returnId) {
|
||||
setStatus("Select a return before saving intake.");
|
||||
return;
|
||||
}
|
||||
const total =
|
||||
Number(nextIntake.income.wages ?? 0) +
|
||||
Number(nextIntake.income.business ?? 0) +
|
||||
Number(nextIntake.income.interest ?? 0) +
|
||||
Number(nextIntake.income.dividends ?? 0);
|
||||
const normalized = { ...nextIntake, income: { ...nextIntake.income, total } };
|
||||
setStatus(submit ? "Submitting intake..." : "Saving intake...");
|
||||
const response = await apiFetch<{ intake: TaxIntake; readiness: TaxReadiness }>(`/api/tax/returns/${returnId}/intake`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ intake: normalized, submit }),
|
||||
});
|
||||
if (response.error) {
|
||||
setStatus(response.error.message ?? "Unable to save intake.");
|
||||
return;
|
||||
}
|
||||
setIntake(response.data.intake);
|
||||
setReadiness(response.data.readiness);
|
||||
setStatus(submit ? "Intake submitted." : "Intake saved.");
|
||||
await loadReturns();
|
||||
};
|
||||
|
||||
const updateIntake = (section: keyof TaxIntake, key: string, value: string | number | boolean) => {
|
||||
setIntake((current) => ({
|
||||
...current,
|
||||
[section]: {
|
||||
...(current[section] as Record<string, unknown>),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const exportReturn = async (id: string) => {
|
||||
setStatus("Exporting return...");
|
||||
if (useLocal) {
|
||||
const ret = returns.find((item) => item.id === id);
|
||||
const payload = {
|
||||
return: ret,
|
||||
documents: useSample ? sampleProfile.documents : [],
|
||||
sampleData: useSample ? sampleProfile : null
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], {
|
||||
type: "application/json"
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setStatus("Export ready (local).");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/tax/returns/${id}/export`, { method: "POST" });
|
||||
const response = (await res.json()) as ApiResponse<{
|
||||
const response = await apiFetch<{
|
||||
return: TaxReturn;
|
||||
documents: unknown[];
|
||||
}>;
|
||||
if (!res.ok || response.error) {
|
||||
}>(`/api/tax/returns/${id}/export`, { method: "POST" });
|
||||
if (response.error) {
|
||||
setStatus(response.error?.message ?? "Export failed.");
|
||||
return;
|
||||
}
|
||||
@ -234,6 +314,31 @@ export default function TaxPage() {
|
||||
await loadReturns();
|
||||
};
|
||||
|
||||
const submitEFile = async (id: string) => {
|
||||
setStatus("Submitting e-file package...");
|
||||
const response = await apiFetch<{ eFile: EFileStatus }>(`/api/tax/returns/${id}/efile`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ consentAccepted: efileConsent }),
|
||||
});
|
||||
if (response.error) {
|
||||
setStatus(response.error?.message ?? "E-file submission failed.");
|
||||
return;
|
||||
}
|
||||
setStatus(`E-file submitted to ${response.data.eFile.provider ?? "provider"}.`);
|
||||
await loadReturns();
|
||||
};
|
||||
|
||||
const refreshEFileStatus = async (id: string) => {
|
||||
setStatus("Checking e-file status...");
|
||||
const response = await apiFetch<{ eFile?: EFileStatus; status?: string }>(`/api/tax/returns/${id}/efile`);
|
||||
if (response.error) {
|
||||
setStatus(response.error?.message ?? "Unable to check e-file status.");
|
||||
return;
|
||||
}
|
||||
setStatus(response.data.eFile?.status ? `E-file status: ${response.data.eFile.status}.` : "Return has not been e-filed.");
|
||||
await loadReturns();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Tax" subtitle="Prepare returns and export audit-ready packages.">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
@ -314,8 +419,8 @@ export default function TaxPage() {
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Required documents</p>
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground">
|
||||
{sampleProfile.documents.map((doc) => (
|
||||
<div key={doc} className="flex items-center justify-between">
|
||||
<span>{doc}</span>
|
||||
<div key={doc.docType} className="flex items-center justify-between">
|
||||
<span>{doc.label}</span>
|
||||
<span className="rounded-full bg-secondary px-2 py-1 text-foreground font-medium">Pending</span>
|
||||
</div>
|
||||
))}
|
||||
@ -335,7 +440,9 @@ export default function TaxPage() {
|
||||
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
||||
<h2 className="text-lg font-bold text-foreground">Your returns</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{returns.length ? (
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading returns...</p>
|
||||
) : returns.length ? (
|
||||
returns.map((ret) => (
|
||||
<div key={ret.id} className="rounded-xl border border-border bg-background/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -346,21 +453,55 @@ export default function TaxPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
States: {ret.jurisdictions.join(", ")}
|
||||
</p>
|
||||
{useSample ? (
|
||||
<p className="text-xs text-muted-foreground">Sample: John Doe</p>
|
||||
{ret.documents?.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Documents: {ret.documents.length}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium text-foreground">
|
||||
{ret.status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectReturn(ret)}
|
||||
className="mt-3 rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
Open intake
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportReturn(ret.id)}
|
||||
className="mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
className="ml-2 mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Export package
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input type="checkbox" checked={efileConsent} onChange={(event) => setEfileConsent(event.target.checked)} />
|
||||
E-file consent
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitEFile(ret.id)}
|
||||
className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Submit e-file
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshEFileStatus(ret.id)}
|
||||
className="rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
Check status
|
||||
</button>
|
||||
</div>
|
||||
{ret.summary?.eFile?.submissionId ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
E-file: {ret.summary.eFile.status} - {ret.summary.eFile.submissionId}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@ -369,6 +510,63 @@ export default function TaxPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 glass-panel p-6 rounded-2xl shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Tax intake</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Capture taxpayer details, income, deductions, and credits before export.
|
||||
</p>
|
||||
</div>
|
||||
{readiness ? (
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${readiness.complete ? "bg-primary/10 text-primary" : "bg-yellow-500/10 text-yellow-500"}`}>
|
||||
{readiness.complete ? "Ready" : `${readiness.missingFields.length} missing`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedReturnId ? (
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-4">
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Taxpayer</p>
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Name" value={intake.taxpayer.name ?? ""} onChange={(e) => updateIntake("taxpayer", "name", e.target.value)} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Filing status" value={intake.taxpayer.filingStatus ?? ""} onChange={(e) => updateIntake("taxpayer", "filingStatus", e.target.value)} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Address" value={intake.taxpayer.address ?? ""} onChange={(e) => updateIntake("taxpayer", "address", e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Income</p>
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Wages" value={intake.income.wages ?? 0} onChange={(e) => updateIntake("income", "wages", Number(e.target.value))} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Business net" value={intake.income.business ?? 0} onChange={(e) => updateIntake("income", "business", Number(e.target.value))} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Interest" value={intake.income.interest ?? 0} onChange={(e) => updateIntake("income", "interest", Number(e.target.value))} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Dividends" value={intake.income.dividends ?? 0} onChange={(e) => updateIntake("income", "dividends", Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Deductions / Credits</p>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input type="checkbox" checked={Boolean(intake.deductions.standard)} onChange={(e) => updateIntake("deductions", "standard", e.target.checked)} />
|
||||
Standard deduction
|
||||
</label>
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Charitable" value={intake.deductions.charitable ?? 0} onChange={(e) => updateIntake("deductions", "charitable", Number(e.target.value))} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Student loan interest" value={intake.deductions.studentLoanInterest ?? 0} onChange={(e) => updateIntake("deductions", "studentLoanInterest", Number(e.target.value))} />
|
||||
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Education credit" value={intake.credits.education ?? 0} onChange={(e) => updateIntake("credits", "education", Number(e.target.value))} />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Review</p>
|
||||
<textarea className="min-h-32 rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Reviewer notes" value={intake.notes ?? ""} onChange={(e) => setIntake((current) => ({ ...current, notes: e.target.value }))} />
|
||||
{readiness?.missingFields?.length ? (
|
||||
<p className="text-xs text-yellow-500">Missing: {readiness.missingFields.join(", ")}</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => saveIntake()} className="rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary">Save draft</button>
|
||||
<button type="button" onClick={() => saveIntake(selectedReturnId, intake, true)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Submit intake</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-muted-foreground">Create or select a return to start intake.</p>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,13 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch, getStoredToken } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type TransactionRow = {
|
||||
id: string;
|
||||
@ -18,6 +12,14 @@ type TransactionRow = {
|
||||
amount: string;
|
||||
category?: string | null;
|
||||
note?: string | null;
|
||||
attribution?: "mine" | "yours" | "ours";
|
||||
split?: {
|
||||
mode: "none" | "equal" | "custom";
|
||||
minePercent: number;
|
||||
yoursPercent: number;
|
||||
mineAmount: number;
|
||||
yoursAmount: number;
|
||||
};
|
||||
status?: string;
|
||||
hidden?: boolean;
|
||||
date: string;
|
||||
@ -34,15 +36,65 @@ type Account = {
|
||||
type ImportResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total?: number;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
type ImportBatchResult = {
|
||||
totalFiles: number;
|
||||
processedFiles: number;
|
||||
failedFiles: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
results: Array<{
|
||||
fileName: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CsvMapping = {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
category?: string;
|
||||
notes?: string;
|
||||
amountMultiplier?: 1 | -1;
|
||||
};
|
||||
|
||||
type CsvPreview = {
|
||||
fileName: string;
|
||||
headerSignature: string;
|
||||
headers: string[];
|
||||
sampleRows: Record<string, string>[];
|
||||
mapping: Partial<CsvMapping>;
|
||||
remembered: boolean;
|
||||
};
|
||||
|
||||
type CashflowRow = {
|
||||
month: string;
|
||||
income: string;
|
||||
expense: string;
|
||||
net: string;
|
||||
};
|
||||
|
||||
type MerchantInsight = {
|
||||
merchant: string;
|
||||
total: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||
const [status, setStatus] = useState("Loading transactions...");
|
||||
const [summary, setSummary] = useState<{
|
||||
total: string; count: number; income?: string; expense?: string; net?: string;
|
||||
} | null>(null);
|
||||
const [cashflow, setCashflow] = useState<CashflowRow[]>([]);
|
||||
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
|
||||
const [datePreset, setDatePreset] = useState("this_month");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
@ -52,6 +104,14 @@ export default function TransactionsPage() {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState("");
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [csvFiles, setCsvFiles] = useState<File[]>([]);
|
||||
const [csvPreview, setCsvPreview] = useState<CsvPreview | null>(null);
|
||||
const [csvMapping, setCsvMapping] = useState<CsvMapping>({
|
||||
date: "",
|
||||
description: "",
|
||||
amount: "",
|
||||
amountMultiplier: 1,
|
||||
});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [manualForm, setManualForm] = useState({
|
||||
accountId: "",
|
||||
@ -60,9 +120,21 @@ export default function TransactionsPage() {
|
||||
amount: "",
|
||||
category: "",
|
||||
note: "",
|
||||
attribution: "mine" as "mine" | "yours" | "ours",
|
||||
splitMode: "none" as "none" | "equal" | "custom",
|
||||
splitMinePercent: "50",
|
||||
splitYoursPercent: "50",
|
||||
});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false });
|
||||
const [editForm, setEditForm] = useState({
|
||||
category: "",
|
||||
note: "",
|
||||
attribution: "mine" as "mine" | "yours" | "ours",
|
||||
splitMode: "none" as "none" | "equal" | "custom",
|
||||
splitMinePercent: "50",
|
||||
splitYoursPercent: "50",
|
||||
hidden: false,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: "", endDate: "", minAmount: "", maxAmount: "",
|
||||
category: "", source: "", search: "", includeHidden: false,
|
||||
@ -125,10 +197,20 @@ export default function TransactionsPage() {
|
||||
if (!res.error) setSummary(res.data);
|
||||
};
|
||||
|
||||
const loadInsights = async () => {
|
||||
const [cashflowRes, merchantsRes] = await Promise.all([
|
||||
apiFetch<CashflowRow[]>("/api/transactions/cashflow?months=6"),
|
||||
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=6"),
|
||||
]);
|
||||
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyPreset("this_month");
|
||||
load();
|
||||
loadSummary();
|
||||
loadInsights();
|
||||
loadAccounts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@ -156,31 +238,97 @@ export default function TransactionsPage() {
|
||||
setStatus("Sync complete.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
await loadInsights();
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
const onImportCsv = async (file: File) => {
|
||||
const onPreviewCsv = async (files: File[]) => {
|
||||
const csvFiles = files.filter((file) => file.name.toLowerCase().endsWith(".csv"));
|
||||
if (!csvFiles.length) {
|
||||
setImportStatus("Select one or more CSV files.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFiles(csvFiles);
|
||||
setCsvPreview(null);
|
||||
setImportLoading(true);
|
||||
setImportStatus("Uploading...");
|
||||
setImportStatus(`Reading ${csvFiles[0].name}...`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const token = getStoredToken();
|
||||
formData.append("file", csvFiles[0]);
|
||||
try {
|
||||
const res = await fetch("/api/transactions/import", {
|
||||
const payload = await apiFetch<CsvPreview>("/api/transactions/import/preview", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<ImportResult>;
|
||||
if (!res.ok || payload.error) {
|
||||
if (payload.error) {
|
||||
setImportStatus(payload.error?.message ?? "Preview failed.");
|
||||
setImportLoading(false);
|
||||
return;
|
||||
}
|
||||
const preview = payload.data;
|
||||
setCsvPreview(preview);
|
||||
setCsvMapping({
|
||||
date: preview.mapping.date ?? "",
|
||||
description: preview.mapping.description ?? "",
|
||||
amount: preview.mapping.amount ?? "",
|
||||
category: preview.mapping.category,
|
||||
notes: preview.mapping.notes,
|
||||
amountMultiplier: preview.mapping.amountMultiplier === -1 ? -1 : 1,
|
||||
});
|
||||
setImportStatus(
|
||||
`${csvFiles.length} CSV file${csvFiles.length === 1 ? "" : "s"} selected. ${preview.remembered ? "Using remembered mapping." : "Review the column mapping before import."}`
|
||||
);
|
||||
} catch {
|
||||
setImportStatus("Preview failed. Please try again.");
|
||||
}
|
||||
setImportLoading(false);
|
||||
};
|
||||
|
||||
const onImportCsv = async () => {
|
||||
const selectedFiles = csvFiles.filter((file) => file.name.toLowerCase().endsWith(".csv"));
|
||||
if (!selectedFiles.length) {
|
||||
setImportStatus("Select one or more CSV files.");
|
||||
return;
|
||||
}
|
||||
if (!csvMapping.date || !csvMapping.description || !csvMapping.amount) {
|
||||
setImportStatus("Map date, description, and amount columns before importing.");
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
setImportStatus(`Uploading ${selectedFiles.length} file${selectedFiles.length === 1 ? "" : "s"}...`);
|
||||
const formData = new FormData();
|
||||
for (const file of selectedFiles) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
formData.append("mapping", JSON.stringify(csvMapping));
|
||||
try {
|
||||
const payload = await apiFetch<ImportBatchResult>("/api/transactions/import/batch", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (payload.error) {
|
||||
setImportStatus(payload.error?.message ?? "Import failed.");
|
||||
setImportLoading(false);
|
||||
return;
|
||||
}
|
||||
const r = payload.data;
|
||||
setImportStatus(`Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.`);
|
||||
const failed = r.failedFiles
|
||||
? ` ${r.failedFiles} file${r.failedFiles === 1 ? "" : "s"} failed.`
|
||||
: "";
|
||||
const failedNames = r.results
|
||||
.filter((result) => result.error)
|
||||
.map((result) => `${result.fileName}: ${result.error}`)
|
||||
.join(" ");
|
||||
setImportStatus(
|
||||
`Processed ${r.processedFiles}/${r.totalFiles} file${r.totalFiles === 1 ? "" : "s"}. Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.${failed}${failedNames ? ` ${failedNames}` : ""}`
|
||||
);
|
||||
setCsvFiles([]);
|
||||
setCsvPreview(null);
|
||||
await load();
|
||||
await loadSummary();
|
||||
await loadInsights();
|
||||
} catch {
|
||||
setImportStatus("Import failed. Please try again.");
|
||||
}
|
||||
@ -196,6 +344,23 @@ export default function TransactionsPage() {
|
||||
};
|
||||
};
|
||||
|
||||
const splitPayload = (mode: "none" | "equal" | "custom", mine: string, yours: string) => {
|
||||
if (mode === "custom") {
|
||||
return {
|
||||
splitMode: mode,
|
||||
splitMinePercent: Number.parseFloat(mine),
|
||||
splitYoursPercent: Number.parseFloat(yours),
|
||||
};
|
||||
}
|
||||
return { splitMode: mode };
|
||||
};
|
||||
|
||||
const splitLabel = (row: TransactionRow) => {
|
||||
if (!row.split || row.split.mode === "none") return "No split";
|
||||
if (row.split.mode === "equal") return "50/50";
|
||||
return `${row.split.minePercent}/${row.split.yoursPercent}`;
|
||||
};
|
||||
|
||||
const onManualCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const amount = Number.parseFloat(manualForm.amount);
|
||||
@ -210,19 +375,30 @@ export default function TransactionsPage() {
|
||||
amount,
|
||||
category: manualForm.category || undefined,
|
||||
note: manualForm.note || undefined,
|
||||
attribution: manualForm.attribution,
|
||||
...splitPayload(manualForm.splitMode, manualForm.splitMinePercent, manualForm.splitYoursPercent),
|
||||
}),
|
||||
});
|
||||
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
|
||||
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" }));
|
||||
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "", attribution: "mine", splitMode: "none", splitMinePercent: "50", splitYoursPercent: "50" }));
|
||||
setShowManual(false);
|
||||
setStatus("Manual transaction saved.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
await loadInsights();
|
||||
};
|
||||
|
||||
const startEdit = (row: TransactionRow) => {
|
||||
setEditingId(row.id);
|
||||
setEditForm({ category: row.category ?? "", note: row.note ?? "", hidden: Boolean(row.hidden) });
|
||||
setEditForm({
|
||||
category: row.category ?? "",
|
||||
note: row.note ?? "",
|
||||
attribution: row.attribution ?? "mine",
|
||||
splitMode: row.split?.mode ?? "none",
|
||||
splitMinePercent: String(row.split?.minePercent ?? 50),
|
||||
splitYoursPercent: String(row.split?.yoursPercent ?? 50),
|
||||
hidden: Boolean(row.hidden),
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
@ -233,6 +409,8 @@ export default function TransactionsPage() {
|
||||
body: JSON.stringify({
|
||||
userCategory: editForm.category || undefined,
|
||||
userNotes: editForm.note || undefined,
|
||||
attribution: editForm.attribution,
|
||||
...splitPayload(editForm.splitMode, editForm.splitMinePercent, editForm.splitYoursPercent),
|
||||
isHidden: editForm.hidden,
|
||||
}),
|
||||
});
|
||||
@ -241,6 +419,7 @@ export default function TransactionsPage() {
|
||||
setStatus("Transaction updated.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
await loadInsights();
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
@ -288,16 +467,20 @@ export default function TransactionsPage() {
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.name.endsWith(".csv")) onImportCsv(file);
|
||||
onPreviewCsv(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImportCsv(f); }}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length) onPreviewCsv(files);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
@ -305,9 +488,88 @@ export default function TransactionsPage() {
|
||||
{importLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Uploading...</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Drop a CSV file here or <span className="text-primary font-medium">click to browse</span></p>
|
||||
<p className="text-sm text-muted-foreground">Drop CSV files here or <span className="text-primary font-medium">click to browse</span></p>
|
||||
)}
|
||||
</div>
|
||||
{csvPreview && (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="rounded-xl border border-border bg-background/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-foreground">Column mapping</p>
|
||||
<span className="rounded-full bg-secondary px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{csvPreview.remembered ? "Remembered" : "New"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
["date", "Date"],
|
||||
["description", "Description"],
|
||||
["amount", "Amount"],
|
||||
["category", "Category"],
|
||||
["notes", "Notes"],
|
||||
].map(([key, label]) => (
|
||||
<label key={key} className={labelCls}>
|
||||
{label}
|
||||
<select
|
||||
value={(csvMapping[key as keyof CsvMapping] as string | undefined) ?? ""}
|
||||
onChange={(e) => setCsvMapping((prev) => ({ ...prev, [key]: e.target.value || undefined }))}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Not mapped</option>
|
||||
{csvPreview.headers.map((header) => (
|
||||
<option key={header} value={header}>{header}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<label className={labelCls}>
|
||||
Amount sign
|
||||
<select
|
||||
value={csvMapping.amountMultiplier ?? 1}
|
||||
onChange={(e) => setCsvMapping((prev) => ({ ...prev, amountMultiplier: e.target.value === "-1" ? -1 : 1 }))}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value={1}>Keep file values</option>
|
||||
<option value={-1}>Flip income/expense signs</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportCsv}
|
||||
disabled={importLoading}
|
||||
className="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{importLoading ? "Importing..." : `Import ${csvFiles.length} file${csvFiles.length === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">Preview: {csvPreview.fileName}</p>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-secondary/30 text-muted-foreground">
|
||||
<tr>
|
||||
{csvPreview.headers.map((header) => (
|
||||
<th key={header} className="whitespace-nowrap px-3 py-2 font-semibold">{header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.sampleRows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-border text-foreground">
|
||||
{csvPreview.headers.map((header) => (
|
||||
<td key={header} className="max-w-48 truncate px-3 py-2">{row[header] ?? ""}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{importStatus && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
|
||||
)}
|
||||
@ -348,6 +610,34 @@ export default function TransactionsPage() {
|
||||
<label className={labelCls}>Note</label>
|
||||
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Attribution</label>
|
||||
<select value={manualForm.attribution} onChange={(e) => setManualForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))} className={inputCls}>
|
||||
<option value="mine">Mine</option>
|
||||
<option value="yours">Yours</option>
|
||||
<option value="ours">Ours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Split</label>
|
||||
<select value={manualForm.splitMode} onChange={(e) => setManualForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))} className={inputCls}>
|
||||
<option value="none">No split</option>
|
||||
<option value="equal">50/50</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
{manualForm.splitMode === "custom" && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelCls}>Mine %</label>
|
||||
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitMinePercent} onChange={(e) => setManualForm((p) => ({ ...p, splitMinePercent: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Yours %</label>
|
||||
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitYoursPercent} onChange={(e) => setManualForm((p) => ({ ...p, splitYoursPercent: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="md:col-span-3 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
|
||||
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
|
||||
@ -386,6 +676,10 @@ export default function TransactionsPage() {
|
||||
<label className={labelCls}>Category</label>
|
||||
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Source</label>
|
||||
<input type="text" value={filters.source} onChange={(e) => setFilters((p) => ({ ...p, source: e.target.value }))} className={inputCls} placeholder="plaid, manual, csv..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Min amount</label>
|
||||
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
||||
@ -401,7 +695,7 @@ export default function TransactionsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button onClick={() => { load(); loadSummary(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
|
||||
<button onClick={() => { load(); loadSummary(); loadInsights(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -424,6 +718,62 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(cashflow.length > 0 || merchants.length > 0) && (
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
{cashflow.length > 0 && (
|
||||
<div className="glass-panel rounded-xl p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Cashflow</p>
|
||||
<span className="text-xs text-muted-foreground">Last 6 months</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{cashflow.map((item) => {
|
||||
const income = Number.parseFloat(item.income);
|
||||
const expense = Number.parseFloat(item.expense);
|
||||
const max = Math.max(income, expense, 1);
|
||||
return (
|
||||
<div key={item.month} className="grid grid-cols-[5rem_1fr_4.5rem] items-center gap-3 text-xs">
|
||||
<span className="font-medium text-muted-foreground">{item.month}</span>
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 rounded-full bg-secondary">
|
||||
<div className="h-2 rounded-full bg-primary" style={{ width: `${Math.min((income / max) * 100, 100)}%` }} />
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary">
|
||||
<div className="h-2 rounded-full bg-foreground/70" style={{ width: `${Math.min((expense / max) * 100, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={Number.parseFloat(item.net) >= 0 ? "text-primary font-semibold" : "text-foreground font-semibold"}>
|
||||
${Number.parseFloat(item.net).toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{merchants.length > 0 && (
|
||||
<div className="glass-panel rounded-xl p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-foreground">Top merchants</p>
|
||||
<span className="text-xs text-muted-foreground">By spend</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{merchants.map((merchant) => (
|
||||
<div key={merchant.merchant} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/40 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{merchant.merchant}</p>
|
||||
<p className="text-xs text-muted-foreground">{merchant.count} transactions</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-foreground">${Number.parseFloat(merchant.total).toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction table */}
|
||||
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
|
||||
{status && (
|
||||
@ -436,6 +786,8 @@ export default function TransactionsPage() {
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Description</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3">Attribution</th>
|
||||
<th className="px-4 py-3">Split</th>
|
||||
<th className="px-4 py-3 text-right">Amount</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
@ -444,8 +796,8 @@ export default function TransactionsPage() {
|
||||
{rows.map((row) =>
|
||||
editingId === row.id ? (
|
||||
<tr key={row.id} className="border-b border-border bg-secondary/10">
|
||||
<td className="px-4 py-3" colSpan={2}>
|
||||
<div className="flex gap-2">
|
||||
<td className="px-4 py-3" colSpan={4}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.category}
|
||||
@ -460,6 +812,48 @@ export default function TransactionsPage() {
|
||||
placeholder="Note"
|
||||
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={editForm.attribution}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="mine">Mine</option>
|
||||
<option value="yours">Yours</option>
|
||||
<option value="ours">Ours</option>
|
||||
</select>
|
||||
<select
|
||||
value={editForm.splitMode}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="none">No split</option>
|
||||
<option value="equal">50/50</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
{editForm.splitMode === "custom" && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={editForm.splitMinePercent}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, splitMinePercent: e.target.value }))}
|
||||
placeholder="Mine %"
|
||||
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={editForm.splitYoursPercent}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, splitYoursPercent: e.target.value }))}
|
||||
placeholder="Yours %"
|
||||
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className="flex items-center gap-1 text-xs text-foreground">
|
||||
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
|
||||
Hide
|
||||
@ -488,6 +882,16 @@ export default function TransactionsPage() {
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium capitalize text-foreground">
|
||||
{row.attribution ?? "mine"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
|
||||
{splitLabel(row)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-bold ${formatAmount(row.amount).tone}`}>
|
||||
{formatAmount(row.amount).display}
|
||||
</td>
|
||||
@ -499,7 +903,7 @@ export default function TransactionsPage() {
|
||||
)}
|
||||
{!rows.length && !status && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No transactions found. Try adjusting your filters or sync your accounts.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -13,6 +13,7 @@ const navItems = [
|
||||
{ href: "/rules", label: "Rules" },
|
||||
{ href: "/exports", label: "Exports" },
|
||||
{ href: "/tax", label: "Tax" },
|
||||
{ href: "/settings/households", label: "Households" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
@ -66,7 +67,7 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
.catch(() => {});
|
||||
|
||||
// Fetch subscription plan
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
apiFetch<SubscriptionData>("/api/billing/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data?.plan) {
|
||||
const p = res.data.plan;
|
||||
@ -77,12 +78,10 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
}, []);
|
||||
|
||||
const onLogout = async () => {
|
||||
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
} catch {}
|
||||
clearAuth();
|
||||
|
||||
@ -36,6 +36,9 @@ export function SiteFooter() {
|
||||
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/compare/vs-copilot">
|
||||
Vs Copilot
|
||||
</Link>
|
||||
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/compare/vs-monarch">
|
||||
Vs Monarch
|
||||
</Link>
|
||||
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/pricing">
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
@ -39,7 +39,7 @@ export const blogPosts: BlogPost[] = [
|
||||
"Most teams start with two or three accounts. As the business grows, those connections multiply, and so do the inconsistencies.",
|
||||
"A connected account strategy keeps the growth sustainable. The first step is standardizing the way accounts are labeled, synced, and categorized.",
|
||||
"LedgerOne gives teams a way to normalize incoming data without overwriting raw transactions. That means you can keep the original feed intact while building your internal logic on top.",
|
||||
"When you are ready to scale beyond the first two accounts, the upgrade is simple and predictable. Unlimited accounts open the door to consistent reporting across every source.",
|
||||
"When you are ready to scale beyond the first two accounts, the upgrade is simple and predictable. Pro covers ten active accounts, while Elite opens the door to unlimited reporting coverage across every source.",
|
||||
"The teams that succeed with scale are the ones who document every rule, every decision, and every export. That is what LedgerOne is built to deliver."
|
||||
]
|
||||
},
|
||||
|
||||
@ -12,7 +12,7 @@ export const defaultFaqs = [
|
||||
{
|
||||
question: "How much does LedgerOne costi",
|
||||
answer:
|
||||
"Your first two connected accounts are free. Unlimited accounts are $9 per month."
|
||||
"Free includes two connected accounts. Pro includes ten accounts and paid export access. Elite includes unlimited accounts."
|
||||
},
|
||||
{
|
||||
question: "What counts as a connected accounti",
|
||||
@ -32,7 +32,7 @@ export const defaultFaqs = [
|
||||
{
|
||||
question: "Can I export my data anytimei",
|
||||
answer:
|
||||
"Yes. You can export unlimited CSV and JSON files with full metadata."
|
||||
"Yes. Pro and Elite users can export unlimited CSV, JSON, XLSX, and PDF files through secure download links."
|
||||
},
|
||||
{
|
||||
question: "Does LedgerOne support audit trailsi",
|
||||
@ -47,7 +47,7 @@ export const defaultFaqs = [
|
||||
{
|
||||
question: "What is the sync frequencyi",
|
||||
answer:
|
||||
"Sync cadence depends on the connector. Unlimited accounts include priority sync intervals."
|
||||
"Sync cadence depends on the connector. Pro and Elite plans include expanded connected-account coverage."
|
||||
},
|
||||
{
|
||||
question: "Is my data securei",
|
||||
@ -82,7 +82,7 @@ export const defaultFaqs = [
|
||||
{
|
||||
question: "Is there a free trial for unlimited accountsi",
|
||||
answer:
|
||||
"We currently offer the first two accounts free. Contact us for trial options."
|
||||
"Free includes two accounts. Contact us if you need to evaluate Pro or Elite before upgrading."
|
||||
},
|
||||
{
|
||||
question: "Can I customize categories and tagsi",
|
||||
|
||||
47
lib/api.ts
47
lib/api.ts
@ -1,4 +1,4 @@
|
||||
// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
|
||||
// Client-side fetch helper with automatic HttpOnly cookie refresh.
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T;
|
||||
@ -6,13 +6,10 @@ export interface ApiResponse<T = unknown> {
|
||||
error: null | { message: string; code?: string };
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "ledgerone_token";
|
||||
const REFRESH_KEY = "ledgerone_refresh_token";
|
||||
const USER_KEY = "ledgerone_user";
|
||||
|
||||
export function getStoredToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getStoredUser<T = unknown>(): T | null {
|
||||
@ -26,51 +23,36 @@ export function getStoredUser<T = unknown>(): T | null {
|
||||
}
|
||||
|
||||
export function storeAuthTokens(data: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user: unknown;
|
||||
}): void {
|
||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
|
||||
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
document.cookie = "ledgerone_auth=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refreshToken) return null;
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuth();
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
const payload = (await res.json()) as ApiResponse<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>;
|
||||
if (payload.error || !payload.data?.accessToken) {
|
||||
const payload = (await res.json()) as ApiResponse<unknown>;
|
||||
if (payload.error) {
|
||||
clearAuth();
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
|
||||
return payload.data.accessToken;
|
||||
return true;
|
||||
} catch {
|
||||
clearAuth();
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,11 +60,9 @@ export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
if (
|
||||
options.body &&
|
||||
typeof options.body === "string" &&
|
||||
@ -95,9 +75,8 @@ export async function apiFetch<T = unknown>(
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (res.status === 401) {
|
||||
const newToken = await tryRefresh();
|
||||
if (newToken) {
|
||||
headers["Authorization"] = `Bearer ${newToken}`;
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
res = await fetch(path, { ...options, headers });
|
||||
} else {
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
38
lib/auth-cookies.ts
Normal file
38
lib/auth-cookies.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const IS_PROD = process.env.NODE_ENV === "production";
|
||||
|
||||
export const ACCESS_COOKIE = "ledgerone_access";
|
||||
export const REFRESH_COOKIE = "ledgerone_refresh";
|
||||
export const AUTH_COOKIE = "ledgerone_auth";
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: IS_PROD,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
export function setAuthCookies(res: NextResponse, accessToken: string, refreshToken: string) {
|
||||
res.cookies.set(ACCESS_COOKIE, accessToken, {
|
||||
...cookieOptions,
|
||||
maxAge: 60,
|
||||
});
|
||||
res.cookies.set(REFRESH_COOKIE, refreshToken, {
|
||||
...cookieOptions,
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
res.cookies.set(AUTH_COOKIE, "1", {
|
||||
...cookieOptions,
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookies(res: NextResponse) {
|
||||
for (const name of [ACCESS_COOKIE, REFRESH_COOKIE, AUTH_COOKIE]) {
|
||||
res.cookies.set(name, "", {
|
||||
...cookieOptions,
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
// Forwards requests to the NestJS backend, including the Bearer token.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ACCESS_COOKIE } from "./auth-cookies";
|
||||
|
||||
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
|
||||
@ -27,10 +28,16 @@ export async function proxyRequest(
|
||||
|
||||
const method = options.method ?? req.method;
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
const cookieAccessToken = req.cookies.get(ACCESS_COOKIE)?.value ?? "";
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
const userAgent = req.headers.get("user-agent") ?? "";
|
||||
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
|
||||
|
||||
const headers: Record<string, string> = { ...options.extraHeaders };
|
||||
if (auth) headers["Authorization"] = auth;
|
||||
else if (cookieAccessToken) headers["Authorization"] = `Bearer ${cookieAccessToken}`;
|
||||
if (userAgent) headers["User-Agent"] = userAgent;
|
||||
if (forwardedFor) headers["X-Forwarded-For"] = forwardedFor;
|
||||
|
||||
let body: BodyInit | null | undefined = undefined;
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ export function middleware(req: NextRequest) {
|
||||
|
||||
if (!isProtected) return NextResponse.next();
|
||||
|
||||
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
|
||||
// ledgerone_auth is an HttpOnly marker cookie set by the auth API routes.
|
||||
const authCookie = req.cookies.get("ledgerone_auth");
|
||||
if (!authCookie?.value) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user