commit 28cefdc7e7a468e13570bdfc12e143f67deae585 Author: sharada-gif Date: Wed Mar 18 13:02:58 2026 -0700 Polish LedgerOne frontend signup, auth, and marketing UI Made-with: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df8552c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.env.local +*.log +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..584b950 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# --- DEPENDENCIES --- +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# --- NEXT.JS BUILD OUTPUT --- +.next/ +out/ +build/ + +# --- ENVIRONMENT FILES --- +.env +.env.local +.env.development +.env.development.local +.env.production +.env.production.local +.env.test +.env.test.local + +# --- PLAYWRIGHT TEST OUTPUT --- +/test-results/ +/playwright-report/ +/blob-report/ +/coverage/ + +# --- TYPESCRIPT --- +*.tsbuildinfo + +# --- LOG FILES --- +*.log +logs/ + +# --- SYSTEM FILES --- +.DS_Store +Thumbs.db + +# --- EDITOR / OS FILES --- +.vscode/ +.idea/ +*.swp + +# --- MISC --- +*.pem +*.key +*.crt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..06c7f81 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# ─── Stage 1: Build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --legacy-peer-deps + +COPY . . +RUN npm run build + +# ─── Stage 2: Production ───────────────────────────────────────────────────── +FROM node:20-alpine AS production +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +EXPOSE 3052 + +CMD ["node", "server.js"] diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..ea19af6 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,199 @@ +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"; +import { FaqSection } from "../../components/faq-section"; +import { PageSchema } from "../../components/page-schema"; +import { SiteFooter } from "../../components/site-footer"; +import { SiteHeader } from "../../components/site-header"; +import { defaultFaqs } from "../../data/faq"; +import { siteInfo } from "../../data/site"; + +export const metadata = { + title: "About LedgerOne", + description: + "Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.", + keywords: siteInfo.keywords +}; + +const values = [ + { + title: "Data ownership", + detail: "Your data stays portable, exportable, and under your control." + }, + { + title: "Transparent automation", + detail: "Rules are visible, explainable, and ready for compliance review." + }, + { + title: "Operational clarity", + detail: "Keep finance, tax, and operations aligned with one ledger truth." + } +]; + +const milestones = [ + { + title: "Ledger-first architecture", + detail: "Every transaction starts immutable, then layers preserve every change." + }, + { + title: "Built for teams", + detail: "We design workflows for the handoffs between finance, tax, and ops." + }, + { + title: "US-ready exports", + detail: "Exports are formatted to support US tax and accounting workflows." + } +]; + +export default function AboutPage() { + const schema = [ + { + "@context": "https://schema.org", + "@type": "WebPage", + name: "About LedgerOne", + description: + "Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.", + url: `${siteInfo.url}/about` + }, + { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: defaultFaqs.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { "@type": "Answer", text: item.answer } + })) + } + ]; + + return ( +
+ + +
+
+
+
+
+ Our Story +
+

+ LedgerOne keeps every transaction ready for{" "} + ready for audits, review, and action. +

+
+

+ We built LedgerOne for teams that manage high volumes of transactions but + still need each decision documented. Our ledger-first workflow keeps the + raw truth intact while allowing intelligent categorization and rule-driven + automation. +

+

+ The result is a single source of truth that helps US finance and tax teams + collaborate without losing evidence or context. +

+
+
+ + View pricing + + + Explore FAQs + +
+
+
+
+
+ Analyst reviewing charts on a laptop +
+

+ Built for US operators +

+

+ LedgerOne is built around US accounting workflows, audit readiness, and + tax reporting cycles. +

+
+
+
+
+ +
+ {values.map((value) => ( +
+
+ +
+

{value.title}

+

{value.detail}

+
+ ))} +
+ +
+
+ Leadership +
+

Built by finance experts.

+
+
+ MM +
+

Manoj Mohan

+

Founder & CEO

+

+ Leading the vision to bring audit-ready financial controls to modern businesses. +

+
+
+ +
+
+
+
+ What we built +
+

A ledger that holds the full story.

+

+ Traditional tools collapse data into summaries. LedgerOne keeps each raw + entry intact and layers in decisions, reviews, and approvals. +

+
+
+ {milestones.map((item, index) => ( +
+

+ Focus {index + 1} +

+

{item.title}

+

{item.detail}

+
+ ))} +
+
+
+
+
+ +
+ +
+ +
+ ); +} diff --git a/app/api/2fa/disable/route.ts b/app/api/2fa/disable/route.ts new file mode 100644 index 0000000..3d6ee9b --- /dev/null +++ b/app/api/2fa/disable/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function DELETE(req: NextRequest) { + return proxyRequest(req, "2fa/disable"); +} diff --git a/app/api/2fa/enable/route.ts b/app/api/2fa/enable/route.ts new file mode 100644 index 0000000..fa0bfc5 --- /dev/null +++ b/app/api/2fa/enable/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "2fa/enable"); +} diff --git a/app/api/2fa/generate/route.ts b/app/api/2fa/generate/route.ts new file mode 100644 index 0000000..5d009ec --- /dev/null +++ b/app/api/2fa/generate/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "2fa/generate"); +} diff --git a/app/api/accounts/link/route.ts b/app/api/accounts/link/route.ts new file mode 100644 index 0000000..250c02f --- /dev/null +++ b/app/api/accounts/link/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "accounts/link-token"); +} diff --git a/app/api/accounts/manual/route.ts b/app/api/accounts/manual/route.ts new file mode 100644 index 0000000..1f213ce --- /dev/null +++ b/app/api/accounts/manual/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "accounts/manual"); +} diff --git a/app/api/accounts/route.ts b/app/api/accounts/route.ts new file mode 100644 index 0000000..5b1b440 --- /dev/null +++ b/app/api/accounts/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "accounts"); +} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..61dc455 --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/forgot-password"); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..5acfb79 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/login"); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..c8009a2 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/logout"); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..f449c8b --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "auth/me"); +} diff --git a/app/api/auth/profile/route.ts b/app/api/auth/profile/route.ts new file mode 100644 index 0000000..73d44d8 --- /dev/null +++ b/app/api/auth/profile/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "auth/me"); +} + +export async function PATCH(req: NextRequest) { + return proxyRequest(req, "auth/profile"); +} diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..864d056 --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/refresh"); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..c630edd --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/register"); +} diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..bb9bf9c --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "auth/reset-password"); +} diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..4f4b5a3 --- /dev/null +++ b/app/api/auth/verify-email/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "auth/verify-email"); +} diff --git a/app/api/exports/csv/route.ts b/app/api/exports/csv/route.ts new file mode 100644 index 0000000..6b19705 --- /dev/null +++ b/app/api/exports/csv/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "exports/csv"); +} diff --git a/app/api/exports/sheets/route.ts b/app/api/exports/sheets/route.ts new file mode 100644 index 0000000..2f3b85a --- /dev/null +++ b/app/api/exports/sheets/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "exports/sheets"); +} diff --git a/app/api/google/connect/route.ts b/app/api/google/connect/route.ts new file mode 100644 index 0000000..b4af629 --- /dev/null +++ b/app/api/google/connect/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "google/connect"); +} diff --git a/app/api/google/disconnect/route.ts b/app/api/google/disconnect/route.ts new file mode 100644 index 0000000..c83a9dd --- /dev/null +++ b/app/api/google/disconnect/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function DELETE(req: NextRequest) { + return proxyRequest(req, "google/disconnect"); +} diff --git a/app/api/google/exchange/route.ts b/app/api/google/exchange/route.ts new file mode 100644 index 0000000..136ce43 --- /dev/null +++ b/app/api/google/exchange/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "google/exchange"); +} diff --git a/app/api/google/status/route.ts b/app/api/google/status/route.ts new file mode 100644 index 0000000..21867b8 --- /dev/null +++ b/app/api/google/status/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "google/status"); +} diff --git a/app/api/plaid/exchange/route.ts b/app/api/plaid/exchange/route.ts new file mode 100644 index 0000000..9578cc5 --- /dev/null +++ b/app/api/plaid/exchange/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "plaid/exchange"); +} diff --git a/app/api/plaid/link-token/route.ts b/app/api/plaid/link-token/route.ts new file mode 100644 index 0000000..109c043 --- /dev/null +++ b/app/api/plaid/link-token/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "plaid/link-token"); +} diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts new file mode 100644 index 0000000..4edf09f --- /dev/null +++ b/app/api/rules/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "rules"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "rules"); +} diff --git a/app/api/rules/suggestions/route.ts b/app/api/rules/suggestions/route.ts new file mode 100644 index 0000000..5ddfeaa --- /dev/null +++ b/app/api/rules/suggestions/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "rules/suggestions"); +} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..214296b --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "stripe/checkout"); +} diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts new file mode 100644 index 0000000..a45df15 --- /dev/null +++ b/app/api/stripe/portal/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "stripe/portal"); +} diff --git a/app/api/stripe/subscription/route.ts b/app/api/stripe/subscription/route.ts new file mode 100644 index 0000000..f561308 --- /dev/null +++ b/app/api/stripe/subscription/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "stripe/subscription"); +} diff --git a/app/api/tax/returns/[id]/export/route.ts b/app/api/tax/returns/[id]/export/route.ts new file mode 100644 index 0000000..290f99f --- /dev/null +++ b/app/api/tax/returns/[id]/export/route.ts @@ -0,0 +1,9 @@ +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}/export`); +} diff --git a/app/api/tax/returns/route.ts b/app/api/tax/returns/route.ts new file mode 100644 index 0000000..078db69 --- /dev/null +++ b/app/api/tax/returns/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "tax/returns"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "tax/returns"); +} diff --git a/app/api/transactions/[id]/derived/route.ts b/app/api/transactions/[id]/derived/route.ts new file mode 100644 index 0000000..c4447c6 --- /dev/null +++ b/app/api/transactions/[id]/derived/route.ts @@ -0,0 +1,9 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function PATCH( + req: NextRequest, + { params }: { params: { id: string } } +) { + return proxyRequest(req, `transactions/${params.id}/derived`); +} diff --git a/app/api/transactions/cashflow/route.ts b/app/api/transactions/cashflow/route.ts new file mode 100644 index 0000000..f84bc4e --- /dev/null +++ b/app/api/transactions/cashflow/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "transactions/cashflow"); +} diff --git a/app/api/transactions/import/route.ts b/app/api/transactions/import/route.ts new file mode 100644 index 0000000..d9ab8de --- /dev/null +++ b/app/api/transactions/import/route.ts @@ -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"); +} diff --git a/app/api/transactions/manual/route.ts b/app/api/transactions/manual/route.ts new file mode 100644 index 0000000..1cee988 --- /dev/null +++ b/app/api/transactions/manual/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "transactions/manual"); +} diff --git a/app/api/transactions/merchants/route.ts b/app/api/transactions/merchants/route.ts new file mode 100644 index 0000000..00ca9c4 --- /dev/null +++ b/app/api/transactions/merchants/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "transactions/merchants"); +} diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts new file mode 100644 index 0000000..79477f2 --- /dev/null +++ b/app/api/transactions/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "transactions"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "transactions"); +} diff --git a/app/api/transactions/summary/route.ts b/app/api/transactions/summary/route.ts new file mode 100644 index 0000000..51403de --- /dev/null +++ b/app/api/transactions/summary/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "transactions/summary"); +} diff --git a/app/api/transactions/sync/route.ts b/app/api/transactions/sync/route.ts new file mode 100644 index 0000000..7896454 --- /dev/null +++ b/app/api/transactions/sync/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "transactions/sync"); +} diff --git a/app/app/connect/page.tsx b/app/app/connect/page.tsx new file mode 100644 index 0000000..b337e4c --- /dev/null +++ b/app/app/connect/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { AppShell } from "../../../components/app-shell"; +import { useCallback, useEffect, useState } from "react"; +import { usePlaidLink } from "react-plaid-link"; + +type Account = { + id: string; + institutionName: string; + accountType: string; + mask?: string | null; +}; + +export default function ConnectPage() { + const [status, setStatus] = useState(""); + const [linkToken, setLinkToken] = useState(null); + 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([]); + + const createLinkToken = useCallback(async () => { + setStatus("Requesting Plaid link token..."); + try { + const res = await fetch("/api/plaid/link-token", { method: "POST" }); + const payload = await res.json(); + if (!res.ok || payload.error) { + setStatus(payload.error?.message ?? "Unable to create link token."); + return; + } + const token = payload.data?.linkToken ?? payload.data?.link_token; + if (token) { + setLinkToken(token); + setStatus("Link token ready."); + } else { + setStatus("Link token created."); + } + } catch { + setStatus("Unable to create link token."); + } + }, []); + + const loadAccounts = useCallback(async () => { + const userId = localStorage.getItem("ledgerone_user_id"); + if (!userId) { + return; + } + const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`); + if (!res.ok) { + return; + } + const payload = await res.json(); + setAccounts(payload.data ?? []); + }, []); + + useEffect(() => { + createLinkToken(); + loadAccounts(); + }, [createLinkToken, loadAccounts]); + + const onSuccess = useCallback( + async (publicToken: string) => { + const userId = localStorage.getItem("ledgerone_user_id"); + if (!userId) { + setStatus("Missing user id. Please sign in again."); + 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 }) + }); + const payload = await res.json(); + if (!res.ok || payload.error) { + setStatus(payload.error?.message ?? "Unable to exchange token."); + return; + } + setStatus("Bank account connected."); + await loadAccounts(); + } catch { + setStatus("Unable to exchange token."); + } + }, + [loadAccounts] + ); + + const { open, ready } = usePlaidLink({ + token: linkToken, + onSuccess, + onExit: () => { + setStatus("Plaid Link closed."); + } + }); + + 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) + }; + fetch("/api/accounts/manual", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }) + .then((res) => res.json()) + .then((data) => { + if (data?.error) { + setStatus(data.error?.message ?? "Unable to save manual account."); + return; + } + setStatus(`Manual account saved for ${manualBank}.`); + setManualBank(""); + setManualRouting(""); + setManualAccount(""); + setManualType("checking"); + loadAccounts(); + }) + .catch(() => { + setStatus("Unable to save manual account."); + }); + }; + + return ( + +
+

Bank connections

+

+ Securely connect your bank or card account to start syncing. +

+
+ + +
+ {status ?

{status}

: null} + {accounts.length ? ( +
+

+ Connected accounts +

+ {accounts.map((account) => ( +
+
+

{account.institutionName}

+

+ {account.accountType} {account.mask ? `- ${account.mask}` : ""} +

+
+ + Connected + +
+ ))} +
+ ) : null} + {manualMode ? ( +
+
+ + setManualBank(event.target.value)} + required + /> +
+
+ + +
+
+ + setManualRouting(event.target.value)} + required + /> +
+
+ + setManualAccount(event.target.value)} + required + /> +
+
+ +
+
+ ) : null} +

+ Your first two connections are free. Upgrade to add unlimited accounts. +

+
+
+ ); +} diff --git a/app/app/page copy 2.tsx b/app/app/page copy 2.tsx new file mode 100644 index 0000000..7c7be1a --- /dev/null +++ b/app/app/page copy 2.tsx @@ -0,0 +1,772 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { AppShell } from "../../components/app-shell"; + +type ApiResponse = { + data: T; + meta: { timestamp: string; version: "v1" }; + error: null | { message: string; code?: string }; +}; + +type Summary = { + total: string; + count: number; + income?: string; + expense?: string; + net?: string; +}; + +type CashflowPoint = { + month: string; + income: string; + expense: string; + net: string; +}; + +type MerchantInsight = { + merchant: string; + total: string; + count: number; +}; + +const recentTransactions = [ + { + date: "Oct 24, 2023", + description: "Whole Foods Market", + category: "Groceries", + account: "Chase Sapphire", + amount: "-$142.30" + }, + { + date: "Oct 23, 2023", + description: "Apple Subscription", + category: "Services", + account: "Apple Card", + amount: "-$14.99" + }, + { + date: "Oct 22, 2023", + description: "Stripe Payout", + category: "Income", + account: "Mercury Business", + amount: "+$4,200.00" + }, + { + date: "Oct 22, 2023", + description: "Shell Gasoline", + category: "Transport", + account: "Chase Sapphire", + amount: "-$52.12" + } +]; + +// ---------- helpers ---------- +function formatMonthLabel(yyyyMm: string) { + const [y, m] = yyyyMm.split("-").map((v) => Number(v)); + const d = new Date(y, (m || 1) - 1, 1); + return d.toLocaleString(undefined, { month: "short", year: "numeric" }); +} + +function formatCurrency(n: number) { + const abs = Math.abs(n); + const sign = n < 0 ? "-" : ""; + return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function parseMoney(s?: string) { + return Number.parseFloat(s ?? "0") || 0; +} + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +// ---------- tiny UI bits ---------- +function Pill({ + label, + value, + tone = "neutral" +}: { + label: string; + value: string; + tone?: "neutral" | "good" | "bad"; +}) { + const toneCls = + tone === "good" + ? "text-primary bg-primary/10" + : tone === "bad" + ? "text-red-500 bg-red-500/10" + : "text-muted-foreground bg-secondary/40"; + + return ( + + {label} + {value} + + ); +} + +function ChartLoadingSkeleton() { + // A nicer loading state than a big black rectangle: + // grid + animated wave + fake bars/line. + return ( +
+
+ + {/* shimmer wave */} + + + {/* fake bars */} +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ + {/* fake line */} + +
+ ); +} + +// ---------- chart ---------- +function CashflowChart({ + data, + loading +}: { + data: CashflowPoint[]; + loading: boolean; +}) { + const wrapRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(null); + + const parsed = useMemo(() => { + return (data ?? []).map((d) => { + const income = parseMoney(d.income); + const expense = parseMoney(d.expense); + const net = parseMoney(d.net); + return { ...d, income, expense, net, label: formatMonthLabel(d.month) }; + }); + }, [data]); + + const chart = useMemo(() => { + const W = 640; + const H = 240; + const PAD_L = 36; + const PAD_R = 18; + const PAD_T = 16; + const PAD_B = 34; + + const innerW = W - PAD_L - PAD_R; + const innerH = H - PAD_T - PAD_B; + + if (!parsed.length) { + return { + W, + H, + PAD_L, + PAD_R, + PAD_T, + PAD_B, + innerW, + innerH, + yZero: PAD_T + innerH / 2, + points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[], + lineD: "", + areaD: "", + bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[], + ticks: [] as { y: number; value: number }[] + }; + } + + const nets = parsed.map((d) => d.net); + const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v))); + const yMax = maxAbs; + const yMin = -maxAbs; + + const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1); + + const yScale = (v: number) => { + const t = (v - yMax) / (yMin - yMax); + return PAD_T + t * innerH; + }; + + const yZero = yScale(0); + + const points = parsed.map((d, i) => { + const x = PAD_L + xStep * i; + const y = yScale(d.net); + return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month }; + }); + + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + + const areaD = [ + `M ${points[0].x} ${yZero}`, + ...points.map((p) => `L ${p.x} ${p.y}`), + `L ${points[points.length - 1].x} ${yZero}`, + "Z" + ].join(" "); + + const maxBar = Math.max(1, ...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense)))); + const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.46); + + const barBand = innerW / parsed.length; + const barW = Math.max(10, Math.min(22, barBand * 0.42)); + + const bars = parsed.map((d, i) => { + const cx = PAD_L + barBand * i + barBand / 2; + const x = cx - barW / 2; + + const hIncome = barScale(d.income); + const hExpense = barScale(d.expense); + + const baseY = PAD_T + innerH; + const yIncome = baseY - hIncome; + const yExpense = baseY - hExpense; + + return { x, w: barW, yIncome, hIncome, yExpense, hExpense }; + }); + + const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) })); + + return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks }; + }, [parsed]); + + const active = activeIndex === null ? null : parsed[activeIndex]; + + function pickNearestIndex(clientX: number) { + if (!wrapRef.current || !chart.points.length) return null; + const rect = wrapRef.current.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * chart.W; + + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < chart.points.length; i++) { + const d = Math.abs(chart.points[i].x - x); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + if (loading) return ; + + if (!parsed.length) { + return ( +
+
No cash flow data available
+
+ ); + } + + return ( +
setActiveIndex(null)} + onMouseMove={(e) => setActiveIndex(pickNearestIndex(e.clientX))} + > + {/* subtle moving glow */} + + + + + + + + + + + + + + + + + + + + + {/* ticks + baseline pulse */} + {chart.ticks.map((t) => ( + + + + {formatCurrency(t.value)} + + + ))} + + + + {/* bars */} + {chart.bars.map((b, i) => ( + + + + + ))} + + {/* area */} + + + {/* net line */} + + + {/* points */} + {chart.points.map((p, i) => { + const isActive = i === activeIndex; + return ( + + {isActive ? : null} + + + ); + })} + + {/* hover crosshair */} + {activeIndex !== null && chart.points[activeIndex] ? ( + + + + ) : null} + + {/* x labels */} + {chart.points.map((p, i) => ( + + {(parsed[i]?.month ?? "").slice(2)} {/* "25-08" vibe */} + + ))} + + + {/* Tooltip */} + + {active && activeIndex !== null ? ( + +
{active.label}
+
+
+ Income + {formatCurrency(active.income)} +
+
+ Expense + {formatCurrency(active.expense)} +
+
+
+ Net + + {formatCurrency(active.net)} + +
+
+ + ) : null} + + + {/* Legend */} +
+ + + Net (line) + + + + Expense (bar) + + + + Income (bar) + +
+
+ ); +} + +// ---------- page ---------- +export default function AppHomePage() { + const [summary, setSummary] = useState(null); + const [cashflow, setCashflow] = useState([]); + const [merchants, setMerchants] = useState([]); + const [accountCount, setAccountCount] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const userId = localStorage.getItem("ledgerone_user_id"); + if (!userId) { + setLoading(false); + return; + } + const query = `?user_id=${encodeURIComponent(userId)}`; + + Promise.all([ + fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise>), + fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise>), + fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise>), + fetch(`/api/accounts${query}`).then((res) => res.json() as Promise>) + ]) + .then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => { + if (!summaryRes.error) setSummary(summaryRes.data); + if (!cashflowRes.error) setCashflow(cashflowRes.data); + if (!merchantsRes.error) setMerchants(merchantsRes.data); + if (!accountsRes.error) setAccountCount(accountsRes.data.length); + }) + .catch(() => undefined) + .finally(() => setLoading(false)); + }, []); + + const netNow = useMemo(() => parseMoney(summary?.net), [summary?.net]); + const incomeNow = useMemo(() => parseMoney(summary?.income), [summary?.income]); + const expenseNow = useMemo(() => parseMoney(summary?.expense), [summary?.expense]); + + const netTone: "good" | "bad" | "neutral" = netNow > 0 ? "good" : netNow < 0 ? "bad" : "neutral"; + + const bestWorst = useMemo(() => { + if (!cashflow.length) return null; + const parsed = cashflow.map((c) => ({ month: c.month, net: parseMoney(c.net) })); + const best = parsed.reduce((a, b) => (b.net > a.net ? b : a), parsed[0]); + const worst = parsed.reduce((a, b) => (b.net < a.net ? b : a), parsed[0]); + return { best, worst }; + }, [cashflow]); + + return ( + + {/* top filters row (slightly nicer motion) */} + + + + Last 30 days + + + + All categories + + + + Combined accounts + + +
+ + {bestWorst ? ( + + ) : null} +
+
+ + {/* KPI cards */} +
+ {[ + { + title: "Income (30d)", + value: loading ? "—" : formatCurrency(incomeNow), + sub: "Deposits and credits", + badge: "Inflow", + badgeCls: "text-primary bg-primary/10" + }, + { + title: "Expenses (30d)", + value: loading ? "—" : formatCurrency(expenseNow), + sub: "Bills and spending", + badge: "Outflow", + badgeCls: "text-red-500 bg-red-500/10" + }, + { + title: "Net cash flow", + value: loading ? "—" : formatCurrency(netNow), + sub: summary ? `${summary.count} transactions` : "No data yet", + badge: "30d", + badgeCls: netTone === "bad" ? "text-red-500 bg-red-500/10" : "text-primary bg-primary/10" + }, + { + title: "Active accounts", + value: loading ? "—" : `${accountCount ?? 0}`, + sub: "Bank + card connections", + badge: "Linked", + badgeCls: "text-primary bg-primary/10" + } + ].map((card, idx) => ( + +

{card.title}

+
+

{card.value}

+ {card.badge} +
+

{card.sub}

+
+ ))} +
+ + {/* main grid */} +
+ +
+
+

Monthly Cash-Flow

+

Net flow over last 6 months

+
+ +
+ + +
+
+ + + +
+ {(cashflow ?? []).map((item) => { + const net = parseMoney(item.net); + return ( +
+ {formatMonthLabel(item.month)} + + {formatCurrency(net)} + +
+ ); + })} +
+
+ + +
+
+

Top Merchants

+

Highest spend by merchant

+
+
+ +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : merchants.length ? ( + merchants.map((merchant, i) => ( + +
+

{merchant.merchant}

+

+ {merchant.count} transaction{merchant.count === 1 ? "" : "s"} +

+
+ ${merchant.total} +
+ )) + ) : ( +

No merchant insights yet.

+ )} +
+ +
+ + {/* table */} + +
+

Recent Transactions

+ View all records +
+ +
+ + + + + + + + + + + + {recentTransactions.map((tx) => ( + + + + + + + + ))} + +
DateDescriptionCategoryAccountAmount
{tx.date}{tx.description} + + {tx.category} + + {tx.account} + {tx.amount} +
+
+
+
+ ); +} diff --git a/app/app/page copy.tsx b/app/app/page copy.tsx new file mode 100644 index 0000000..8acc2bc --- /dev/null +++ b/app/app/page copy.tsx @@ -0,0 +1,677 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { AppShell } from "../../components/app-shell"; + +type ApiResponse = { + data: T; + meta: { timestamp: string; version: "v1" }; + error: null | { message: string; code?: string }; +}; + +type Summary = { + total: string; + count: number; + income?: string; + expense?: string; + net?: string; +}; + +type CashflowPoint = { + month: string; + income: string; + expense: string; + net: string; +}; + +type MerchantInsight = { + merchant: string; + total: string; + count: number; +}; + +type Account = { + id: string; +}; + +const recentTransactions = [ + { + date: "Oct 24, 2023", + description: "Whole Foods Market", + category: "Groceries", + account: "Chase Sapphire", + amount: "-$142.30" + }, + { + date: "Oct 23, 2023", + description: "Apple Subscription", + category: "Services", + account: "Apple Card", + amount: "-$14.99" + }, + { + date: "Oct 22, 2023", + description: "Stripe Payout", + category: "Income", + account: "Mercury Business", + amount: "+$4,200.00" + }, + { + date: "Oct 22, 2023", + description: "Shell Gasoline", + category: "Transport", + account: "Chase Sapphire", + amount: "-$52.12" + } +]; + + + + + + + + + + + + + + + + + + +import { motion, AnimatePresence } from "framer-motion"; + +function formatMonthLabel(yyyyMm: string) { + // "2026-01" -> "Jan 2026" + const [y, m] = yyyyMm.split("-").map((v) => Number(v)); + const d = new Date(y, (m || 1) - 1, 1); + return d.toLocaleString(undefined, { month: "short", year: "numeric" }); +} + +function formatCurrency(n: number) { + const abs = Math.abs(n); + const sign = n < 0 ? "-" : ""; + return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + + + +export function CashflowChart({ + data, + loading +}: { + data: CashflowPoint[]; + loading: boolean; +}) { + const wrapRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(null); + + const parsed = useMemo(() => { + return (data ?? []).map((d) => { + const income = Number.parseFloat(d.income ?? "0") || 0; + const expense = Number.parseFloat(d.expense ?? "0") || 0; + const net = Number.parseFloat(d.net ?? "0") || 0; + return { + ...d, + income, + expense, + net, + label: formatMonthLabel(d.month) + }; + }); + }, [data]); + + const chart = useMemo(() => { + const W = 600; + const H = 240; + const PAD_L = 28; + const PAD_R = 18; + const PAD_T = 18; + const PAD_B = 30; + + const innerW = W - PAD_L - PAD_R; + const innerH = H - PAD_T - PAD_B; + + if (!parsed.length) { + return { + W, + H, + PAD_L, + PAD_R, + PAD_T, + PAD_B, + innerW, + innerH, + yZero: PAD_T + innerH / 2, + points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[], + lineD: "", + areaD: "", + bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[], + ticks: [] as { y: number; value: number }[] + }; + } + + const nets = parsed.map((d) => d.net); + + // symmetric range around 0 so negatives/positives are balanced visually + const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v))); + const yMax = maxAbs; + const yMin = -maxAbs; + + const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1); + + const yScale = (v: number) => { + // map yMax -> PAD_T, yMin -> PAD_T+innerH + const t = (v - yMax) / (yMin - yMax); + return PAD_T + t * innerH; + }; + + const yZero = yScale(0); + + const points = parsed.map((d, i) => { + const x = PAD_L + xStep * i; + const y = yScale(d.net); + return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month }; + }); + + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + + // area fill to the zero baseline + const areaD = [ + `M ${points[0].x} ${yZero}`, + ...points.map((p) => `L ${p.x} ${p.y}`), + `L ${points[points.length - 1].x} ${yZero}`, + "Z" + ].join(" "); + + // income/expense bars (scaled using max of income/expense) + const maxBar = Math.max( + 1, + ...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense))) + ); + const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.42); + + const barBand = innerW / parsed.length; + const barW = Math.max(10, Math.min(22, barBand * 0.45)); + + const bars = parsed.map((d, i) => { + const cx = PAD_L + barBand * i + barBand / 2; + const x = cx - barW / 2; + + const hIncome = barScale(d.income); + const hExpense = barScale(d.expense); + + // draw both as upward bars from the bottom area + const baseY = PAD_T + innerH; // bottom + const yIncome = baseY - hIncome; + const yExpense = baseY - hExpense; + + return { x, w: barW, yIncome, hIncome, yExpense, hExpense }; + }); + + // y-axis ticks for context (3 ticks) + const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) })); + + return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks }; + }, [parsed]); + + const active = activeIndex === null ? null : parsed[activeIndex]; + + function pickNearestIndex(clientX: number) { + if (!wrapRef.current || !chart.points.length) return null; + const rect = wrapRef.current.getBoundingClientRect(); + + // convert clientX to svg X (0..W) + const x = ((clientX - rect.left) / rect.width) * chart.W; + let best = 0; + let bestDist = Infinity; + + for (let i = 0; i < chart.points.length; i++) { + const d = Math.abs(chart.points[i].x - x); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + return ( +
setActiveIndex(null)} + onMouseMove={(e) => { + const idx = pickNearestIndex(e.clientX); + setActiveIndex(idx); + }} + > + + + + + + + + + + + + + + + + + + + {loading ? ( + + ) : !chart.points.length ? ( + + No cash flow data available + + ) : ( + <> + {/* y ticks */} + {chart.ticks.map((t) => ( + + + + {formatCurrency(t.value)} + + + ))} + + {/* income/expense bars (bottom) */} + {chart.bars.map((b, i) => ( + + + + + ))} + + {/* net area */} + + + {/* net line */} + + + {/* points */} + {chart.points.map((p, i) => { + const isActive = i === activeIndex; + return ( + + + {/* subtle glow ring on active */} + {isActive ? ( + + ) : null} + + ); + })} + + {/* hover crosshair */} + {activeIndex !== null && chart.points[activeIndex] ? ( + + + + ) : null} + + {/* x labels (every point; small) */} + {chart.points.map((p, i) => ( + + {parsed[i]?.month ?? ""} + + ))} + + )} + + + {/* Tooltip */} + + {!loading && active && activeIndex !== null ? ( + +
{active.label}
+
+
+ Income + {formatCurrency(active.income)} +
+
+ Expense + {formatCurrency(active.expense)} +
+
+
+ Net + + {formatCurrency(active.net)} + +
+
+ + ) : null} + + + {/* Legend */} + {!loading && parsed.length ? ( +
+ + + Net (line) + + + + Expense (bar) + + + + Income (bar) + +
+ ) : null} +
+ ); +} + + + + + + + +export default function AppHomePage() { + const [summary, setSummary] = useState(null); + const [cashflow, setCashflow] = useState([]); + const [merchants, setMerchants] = useState([]); + const [accountCount, setAccountCount] = useState(null); + + const [loading, setLoading] = useState(true); + + useEffect(() => { + const userId = localStorage.getItem("ledgerone_user_id"); + if (!userId) { + setLoading(false); + return; + } + const query = `?user_id=${encodeURIComponent(userId)}`; + + Promise.all([ + fetch(`/api/transactions/summary${query}`).then((res) => res.json()), + fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json()), + fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json()), + fetch(`/api/accounts${query}`).then((res) => res.json()) + ]) + .then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => { + if (!summaryRes.error) setSummary(summaryRes.data); + if (!cashflowRes.error) setCashflow(cashflowRes.data); + if (!merchantsRes.error) setMerchants(merchantsRes.data); + if (!accountsRes.error) setAccountCount(accountsRes.data.length); + }) + .catch(() => undefined) + .finally(() => setLoading(false)); + }, []); + + const cashflowPath = useMemo(() => { + if (!cashflow.length) { + return ""; + } + const values = cashflow.map((item) => Number.parseFloat(item.net)); + const max = Math.max(...values.map((v) => Math.abs(v)), 1); + const stepX = 460 / Math.max(values.length - 1, 1); + return values + .map((value, index) => { + const x = 10 + stepX * index; + const y = 170 - (value / max) * 120; + return `${index === 0 ? "M" : "L"} ${x} ${y}`; + }) + .join(" "); + }, [cashflow]); + + return ( + +
+ + + Last 30 days + + + + All categories + + + + Combined accounts + + Auto sync enabled +
+ +
+
+

Income (30d)

+
+

+ {summary?.income ? `$${summary.income}` : "$0.00"} +

+ Inflow +
+

Deposits and credits

+
+
+

Expenses (30d)

+
+

+ {summary?.expense ? `$${summary.expense}` : "$0.00"} +

+ Outflow +
+

Bills and spending

+
+
+

Net cash flow

+
+

+ {summary?.net ? `$${summary.net}` : "$0.00"} +

+ 30d +
+

+ {summary ? `${summary.count} transactions` : "No data yet"} +

+
+
+

Active accounts

+
+

+ {accountCount !== null ? accountCount : 0} +

+ Linked +
+

Bank + card connections

+
+
+ +
+
+
+
+

Monthly Cash-Flow

+

Net flow over last 6 months

+
+
Income vs expense
+
+ + +
+ {cashflow.map((item) => ( +
+ {item.month} + ${item.net} +
+ ))} +
+
+ +
+
+
+

Top Merchants

+

Highest spend by merchant

+
+
+
+ {merchants.length ? ( + merchants.map((merchant) => ( +
+
+

{merchant.merchant}

+

+ {merchant.count} transaction{merchant.count === 1 ? "" : "s"} +

+
+ ${merchant.total} +
+ )) + ) : ( +

No merchant insights yet.

+ )} +
+
+
+ +
+
+

Recent Transactions

+ View all records +
+
+ + + + + + + + + + + + {recentTransactions.map((tx) => ( + + + + + + + + ))} + +
DateDescriptionCategoryAccountAmount
{tx.date}{tx.description} + + {tx.category} + + {tx.account} + {tx.amount} +
+
+
+
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..2701ba4 --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,769 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { AppShell } from "../../components/app-shell"; +import { apiFetch } from "@/lib/api"; + +type ApiResponse = { + data: T; + meta: { timestamp: string; version: "v1" }; + error: null | { message: string; code?: string }; +}; + +type Summary = { + total: string; + count: number; + income?: string; + expense?: string; + net?: string; +}; + +type CashflowPoint = { + month: string; + income: string; + expense: string; + net: string; +}; + +type MerchantInsight = { + merchant: string; + total: string; + count: number; +}; + +type TxRow = { + id: string; + date: string; + description: string; + category?: string | null; + accountId?: string | null; + amount: string; +}; + +// ---------- helpers ---------- +function formatMonthLabel(yyyyMm: string) { + const [y, m] = yyyyMm.split("-").map((v) => Number(v)); + const d = new Date(y, (m || 1) - 1, 1); + return d.toLocaleString(undefined, { month: "short", year: "numeric" }); +} + +function formatCurrency(n: number) { + const abs = Math.abs(n); + const sign = n < 0 ? "-" : ""; + return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function parseMoney(s?: string) { + return Number.parseFloat(s ?? "0") || 0; +} + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +// ---------- tiny UI bits ---------- +function Pill({ + label, + value, + tone = "neutral" +}: { + label: string; + value: string; + tone?: "neutral" | "good" | "bad"; +}) { + const toneCls = + tone === "good" + ? "text-primary bg-primary/10" + : tone === "bad" + ? "text-red-500 bg-red-500/10" + : "text-muted-foreground bg-secondary/40"; + + return ( + + {label} + {value} + + ); +} + +function ChartLoadingSkeleton() { + // A nicer loading state than a big black rectangle: + // grid + animated wave + fake bars/line. + return ( +
+
+ + {/* shimmer wave */} + + + {/* fake bars */} +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ + {/* fake line */} + +
+ ); +} + +// ---------- chart ---------- +function CashflowChart({ + data, + loading +}: { + data: CashflowPoint[]; + loading: boolean; +}) { + const wrapRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(null); + + const parsed = useMemo(() => { + return (data ?? []).map((d) => { + const income = parseMoney(d.income); + const expense = parseMoney(d.expense); + const net = parseMoney(d.net); + return { ...d, income, expense, net, label: formatMonthLabel(d.month) }; + }); + }, [data]); + + const chart = useMemo(() => { + const W = 640; + const H = 240; + const PAD_L = 36; + const PAD_R = 18; + const PAD_T = 16; + const PAD_B = 34; + + const innerW = W - PAD_L - PAD_R; + const innerH = H - PAD_T - PAD_B; + + if (!parsed.length) { + return { + W, + H, + PAD_L, + PAD_R, + PAD_T, + PAD_B, + innerW, + innerH, + yZero: PAD_T + innerH / 2, + points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[], + lineD: "", + areaD: "", + bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[], + ticks: [] as { y: number; value: number }[] + }; + } + + const nets = parsed.map((d) => d.net); + const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v))); + const yMax = maxAbs; + const yMin = -maxAbs; + + const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1); + + const yScale = (v: number) => { + const t = (v - yMax) / (yMin - yMax); + return PAD_T + t * innerH; + }; + + const yZero = yScale(0); + + const points = parsed.map((d, i) => { + const x = PAD_L + xStep * i; + const y = yScale(d.net); + return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month }; + }); + + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + + const areaD = [ + `M ${points[0].x} ${yZero}`, + ...points.map((p) => `L ${p.x} ${p.y}`), + `L ${points[points.length - 1].x} ${yZero}`, + "Z" + ].join(" "); + + const maxBar = Math.max(1, ...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense)))); + const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.46); + + const barBand = innerW / parsed.length; + const barW = Math.max(10, Math.min(22, barBand * 0.42)); + + const bars = parsed.map((d, i) => { + const cx = PAD_L + barBand * i + barBand / 2; + const x = cx - barW / 2; + + const hIncome = barScale(d.income); + const hExpense = barScale(d.expense); + + const baseY = PAD_T + innerH; + const yIncome = baseY - hIncome; + const yExpense = baseY - hExpense; + + return { x, w: barW, yIncome, hIncome, yExpense, hExpense }; + }); + + const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) })); + + return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks }; + }, [parsed]); + + const active = activeIndex === null ? null : parsed[activeIndex]; + + function pickNearestIndex(clientX: number) { + if (!wrapRef.current || !chart.points.length) return null; + const rect = wrapRef.current.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * chart.W; + + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < chart.points.length; i++) { + const d = Math.abs(chart.points[i].x - x); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + if (loading) return ; + + if (!parsed.length) { + return ( +
+
No cash flow data available
+
+ ); + } + + return ( +
setActiveIndex(null)} + onMouseMove={(e) => setActiveIndex(pickNearestIndex(e.clientX))} + > + {/* subtle moving glow */} + + + + + + + + + + + + + + + + + + + + + {/* ticks + baseline pulse */} + {chart.ticks.map((t) => ( + + + + {formatCurrency(t.value)} + + + ))} + + + + {/* bars */} + {chart.bars.map((b, i) => ( + + + + + ))} + + {/* area */} + + + {/* net line */} + + + {/* points */} + {chart.points.map((p, i) => { + const isActive = i === activeIndex; + return ( + + {isActive ? : null} + + + ); + })} + + {/* hover crosshair */} + {activeIndex !== null && chart.points[activeIndex] ? ( + + + + ) : null} + + {/* x labels */} + {chart.points.map((p, i) => ( + + {(parsed[i]?.month ?? "").slice(2)} {/* "25-08" vibe */} + + ))} + + + {/* Tooltip */} + + {active && activeIndex !== null ? ( + +
{active.label}
+
+
+ Income + {formatCurrency(active.income)} +
+
+ Expense + {formatCurrency(active.expense)} +
+
+
+ Net + + {formatCurrency(active.net)} + +
+
+ + ) : null} + + + {/* Legend */} +
+ + + Net (line) + + + + Expense (bar) + + + + Income (bar) + +
+
+ ); +} + +// ---------- page ---------- +export default function AppHomePage() { + const [summary, setSummary] = useState(null); + const [cashflow, setCashflow] = useState([]); + const [merchants, setMerchants] = useState([]); + const [accountCount, setAccountCount] = useState(null); + const [recentTxs, setRecentTxs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + apiFetch("/api/transactions/summary"), + apiFetch("/api/transactions/cashflow?months=6"), + apiFetch("/api/transactions/merchants?limit=5"), + apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"), + apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"), + ]) + .then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => { + if (!summaryRes.error) setSummary(summaryRes.data); + if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []); + if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []); + if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0); + if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []); + }) + .catch(() => undefined) + .finally(() => setLoading(false)); + }, []); + + const netNow = useMemo(() => parseMoney(summary?.net), [summary?.net]); + const incomeNow = useMemo(() => parseMoney(summary?.income), [summary?.income]); + const expenseNow = useMemo(() => parseMoney(summary?.expense), [summary?.expense]); + + const netTone: "good" | "bad" | "neutral" = netNow > 0 ? "good" : netNow < 0 ? "bad" : "neutral"; + + const bestWorst = useMemo(() => { + if (!cashflow.length) return null; + const parsed = cashflow.map((c) => ({ month: c.month, net: parseMoney(c.net) })); + const best = parsed.reduce((a, b) => (b.net > a.net ? b : a), parsed[0]); + const worst = parsed.reduce((a, b) => (b.net < a.net ? b : a), parsed[0]); + return { best, worst }; + }, [cashflow]); + + return ( + + {/* top filters row (slightly nicer motion) */} + + + + Last 30 days + + + + All categories + + + + Combined accounts + + +
+ + {bestWorst ? ( + + ) : null} +
+
+ + {/* KPI cards */} +
+ {[ + { + title: "Income (30d)", + value: loading ? "—" : formatCurrency(incomeNow), + sub: "Deposits and credits", + badge: "Inflow", + badgeCls: "text-primary bg-primary/10" + }, + { + title: "Expenses (30d)", + value: loading ? "—" : formatCurrency(expenseNow), + sub: "Bills and spending", + badge: "Outflow", + badgeCls: "text-red-500 bg-red-500/10" + }, + { + title: "Net cash flow", + value: loading ? "—" : formatCurrency(netNow), + sub: summary ? `${summary.count} transactions` : "No data yet", + badge: "30d", + badgeCls: netTone === "bad" ? "text-red-500 bg-red-500/10" : "text-primary bg-primary/10" + }, + { + title: "Active accounts", + value: loading ? "—" : `${accountCount ?? 0}`, + sub: "Bank + card connections", + badge: "Linked", + badgeCls: "text-primary bg-primary/10" + } + ].map((card, idx) => ( + +

{card.title}

+
+

{card.value}

+ {card.badge} +
+

{card.sub}

+
+ ))} +
+ + {/* main grid */} +
+ +
+
+

Monthly Cash-Flow

+

Net flow over last 6 months

+
+ +
+ + +
+
+ + + +
+ {(cashflow ?? []).map((item) => { + const net = parseMoney(item.net); + return ( +
+ {formatMonthLabel(item.month)} + + {formatCurrency(net)} + +
+ ); + })} +
+
+ + +
+
+

Top Merchants

+

Highest spend by merchant

+
+
+ +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : merchants.length ? ( + merchants.map((merchant, i) => ( + +
+

{merchant.merchant}

+

+ {merchant.count} transaction{merchant.count === 1 ? "" : "s"} +

+
+ ${merchant.total} +
+ )) + ) : ( +

No merchant insights yet.

+ )} +
+ +
+ + {/* table */} + +
+

Recent Transactions

+ View all records +
+ +
+ + + + + + + + + + + {loading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + + + + + + )) + ) : recentTxs.length ? ( + recentTxs.map((tx) => { + const amt = Number.parseFloat(tx.amount ?? "0"); + const fmtAmt = formatCurrency(amt); + const isIncome = amt >= 0; + return ( + + + + + + + ); + }) + ) : ( + + + + )} + +
DateDescriptionCategoryAmount
{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}{tx.description} + {tx.category ? ( + + {tx.category} + + ) : } + + {fmtAmt} +
+ No transactions yet. Connect a bank account to get started. +
+
+
+
+ ); +} diff --git a/app/auth/google/callback/page.tsx b/app/auth/google/callback/page.tsx new file mode 100644 index 0000000..5a864ce --- /dev/null +++ b/app/auth/google/callback/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { apiFetch } from "@/lib/api"; + +export default function GoogleCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + if (error) { + setStatus("error"); + setMessage(error === "access_denied" ? "You declined Google access." : `Google returned an error: ${error}`); + return; + } + + if (!code) { + setStatus("error"); + setMessage("No authorization code received from Google."); + return; + } + + apiFetch<{ connected: boolean; googleEmail: string }>("/api/google/exchange", { + method: "POST", + body: JSON.stringify({ code }), + }).then((res) => { + if (res.error) { + setStatus("error"); + setMessage(res.error.message ?? "Failed to connect Google account."); + } else { + setStatus("success"); + setMessage(`Connected as ${res.data?.googleEmail ?? "your Google account"}.`); + setTimeout(() => router.replace("/exports"), 2000); + } + }); + }, [searchParams, router]); + + return ( +
+
+ {status === "loading" && ( + <> +
+

Connecting your Google account...

+ + )} + {status === "success" && ( + <> +
+ + + +
+

Google Connected!

+

{message}

+

Redirecting to Exports...

+ + )} + {status === "error" && ( + <> +
+ + + +
+

Connection Failed

+

{message}

+ + + )} +
+
+ ); +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..db0bd37 --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -0,0 +1,106 @@ +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ContactSection } from "../../../components/contact-section"; +import { DemoCta } from "../../../components/demo-cta"; +import { FaqSection } from "../../../components/faq-section"; +import { PageSchema } from "../../../components/page-schema"; +import { SiteFooter } from "../../../components/site-footer"; +import { SiteHeader } from "../../../components/site-header"; +import { blogPosts } from "../../../data/blog"; +import { defaultFaqs } from "../../../data/faq"; +import { siteInfo } from "../../../data/site"; + +type PageProps = { + params: { slug: string }; +}; + +export function generateStaticParams() { + return blogPosts.map((post) => ({ slug: post.slug })); +} + +export function generateMetadata({ params }: PageProps) { + const post = blogPosts.find((item) => item.slug === params.slug); + if (!post) { + return { title: "Blog Post" }; + } + return { + title: post.title, + description: post.excerpt, + keywords: [...siteInfo.keywords, "finance blog", "ledger insights"] + }; +} + +export default function BlogPostPage({ params }: PageProps) { + const post = blogPosts.find((item) => item.slug === params.slug); + if (!post) { + notFound(); + } + + const schema = [ + { + "@context": "https://schema.org", + "@type": "Article", + headline: post.title, + description: post.excerpt, + datePublished: post.date, + author: { + "@type": "Organization", + name: siteInfo.name + }, + image: post.image, + mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}` + }, + { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: defaultFaqs.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { "@type": "Answer", text: item.answer } + })) + } + ]; + + return ( +
+
+
+
+ + +
+ + <- Back to blog + +
+

+ {post.date} - {post.readTime} +

+

{post.title}

+

{post.excerpt}

+
+ {post.title} +
+
+ {post.content.map((paragraph) => ( +

{paragraph}

+ ))} +
+
+
+
+ + + + + +
+ ); +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..5f74571 --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { PageSchema } from "../../components/page-schema"; +import { SiteFooter } from "../../components/site-footer"; +import { SiteHeader } from "../../components/site-header"; +import { siteInfo } from "../../data/site"; + +export const metadata = { + title: "Blog", + description: "Insights on financial control, audit readiness, and ledger automation.", + keywords: siteInfo.keywords +}; + +const posts = [ + { + title: "Why 'Audit-Ready' Matters More Than Ever", + excerpt: + "As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.", + date: "Oct 24, 2023", + readTime: "5 min read", + slug: "audit-ready-matters" + }, + { + title: "The Hidden Cost of Spreadsheet Chaos", + excerpt: + "Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.", + date: "Oct 12, 2023", + readTime: "4 min read", + slug: "spreadsheet-chaos" + }, + { + title: "Automating the Month-End Close", + excerpt: + "How to use rules and categories to reduce your close time from days to hours.", + date: "Sep 28, 2023", + readTime: "6 min read", + slug: "automating-month-end" + } +]; + +export default function BlogPage() { + const schema = [ + { + "@context": "https://schema.org", + "@type": "WebPage", + name: "LedgerOne Blog", + description: "Insights on financial control, audit readiness, and ledger automation.", + url: `${siteInfo.url}/blog` + }, + { + "@context": "https://schema.org", + "@type": "Blog", + blogPost: posts.map((post) => ({ + "@type": "BlogPosting", + headline: post.title, + description: post.excerpt, + datePublished: post.date, + url: `${siteInfo.url}/blog/${post.slug}` + })) + } + ]; + + return ( +
+ + +
+
+ +
+
+

+ Insights for modern finance teams. +

+

+ Best practices for financial control, audit readiness, and automation. +

+
+ +
+ {posts.map((post) => ( + +
+
+ + + {post.readTime} +
+

+ {post.title} +

+

+ {post.excerpt} +

+
+ Read article + +
+
+ + ))} +
+
+
+ + + +
+ ); +} diff --git a/app/book-demo/page.tsx b/app/book-demo/page.tsx new file mode 100644 index 0000000..ee19e27 --- /dev/null +++ b/app/book-demo/page.tsx @@ -0,0 +1,159 @@ +import Link from "next/link"; +import { Background } from "../../components/background"; +import { ContactSection } from "../../components/contact-section"; +import { DemoCta } from "../../components/demo-cta"; +import { FaqSection } from "../../components/faq-section"; +import { PageSchema } from "../../components/page-schema"; +import { SiteFooter } from "../../components/site-footer"; +import { SiteHeader } from "../../components/site-header"; +import { defaultFaqs } from "../../data/faq"; +import { siteInfo } from "../../data/site"; + +const inputClass = + "block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2.5 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"; + +export const metadata = { + title: "Book a Demo", + description: + "Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.", + keywords: siteInfo.keywords +}; + +export default function BookDemoPage() { + const schema = [ + { + "@context": "https://schema.org", + "@type": "WebPage", + name: "Book a LedgerOne Demo", + description: + "Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.", + url: `${siteInfo.url}/book-demo` + }, + { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: defaultFaqs.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { "@type": "Answer", text: item.answer } + })) + } + ]; + + return ( +
+ + +
+
+
+
+
+ Book a demo +
+

+ Schedule time with the LedgerOne team. +

+

+ We will walk you through account connections, rule automation, and + audit-ready exports based on your workflow. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +