implemented the udpated changes

This commit is contained in:
metatroncubeswdev 2026-03-14 08:51:48 -04:00
parent fe6dcfd4f6
commit cf6c4005dd
86 changed files with 7616 additions and 3692 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
*.log
.git

23
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,203 +1,203 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ContactSection } from "../../components/contact-section"; import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta"; import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section"; import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema"; import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer"; import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header"; import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq"; import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; import { siteInfo } from "../../data/site";
export const metadata = { export const metadata = {
title: "About LedgerOne", title: "About LedgerOne",
description: description:
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.", "Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
const values = [ const values = [
{ {
title: "Data ownership", title: "Data ownership",
detail: "Your data stays portable, exportable, and under your control." detail: "Your data stays portable, exportable, and under your control."
}, },
{ {
title: "Transparent automation", title: "Transparent automation",
detail: "Rules are visible, explainable, and ready for compliance review." detail: "Rules are visible, explainable, and ready for compliance review."
}, },
{ {
title: "Operational clarity", title: "Operational clarity",
detail: "Keep finance, tax, and operations aligned with one ledger truth." detail: "Keep finance, tax, and operations aligned with one ledger truth."
} }
]; ];
const milestones = [ const milestones = [
{ {
title: "Ledger-first architecture", title: "Ledger-first architecture",
detail: "Every transaction starts immutable, then layers preserve every change." detail: "Every transaction starts immutable, then layers preserve every change."
}, },
{ {
title: "Built for teams", title: "Built for teams",
detail: "We design workflows for the handoffs between finance, tax, and ops." detail: "We design workflows for the handoffs between finance, tax, and ops."
}, },
{ {
title: "US-ready exports", title: "US-ready exports",
detail: "Exports are formatted to support US tax and accounting workflows." detail: "Exports are formatted to support US tax and accounting workflows."
} }
]; ];
export default function AboutPage() { export default function AboutPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "About LedgerOne", name: "About LedgerOne",
description: description:
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.", "Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
url: `${siteInfo.url}/about` url: `${siteInfo.url}/about`
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({ mainEntity: defaultFaqs.map((item) => ({
"@type": "Question", "@type": "Question",
name: item.question, name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer } acceptedAnswer: { "@type": "Answer", text: item.answer }
})) }))
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24 relative overflow-hidden"> <main className="flex-1 pt-32 pb-24 relative overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" /> <div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<section className="grid gap-12 lg:grid-cols-[1fr_1fr] items-center"> <section className="grid gap-12 lg:grid-cols-[1fr_1fr] items-center">
<div className="space-y-8 animate-slide-up"> <div className="space-y-8 animate-slide-up">
<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 backdrop-blur-sm"> <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 backdrop-blur-sm">
Our Story Our Story
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
LedgerOne keeps every transaction ready for audits, review, and action. LedgerOne keeps every transaction ready for audits, review, and action.
</h1> </h1>
<div className="space-y-4 text-lg text-muted-foreground"> <div className="space-y-4 text-lg text-muted-foreground">
<p> <p>
We built LedgerOne for teams that manage high volumes of transactions but We built LedgerOne for teams that manage high volumes of transactions but
still need each decision documented. Our ledger-first workflow keeps the still need each decision documented. Our ledger-first workflow keeps the
raw truth intact while allowing intelligent categorization and rule-driven raw truth intact while allowing intelligent categorization and rule-driven
automation. automation.
</p> </p>
<p> <p>
The result is a single source of truth that helps US finance and tax teams The result is a single source of truth that helps US finance and tax teams
collaborate without losing evidence or context. collaborate without losing evidence or context.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<Link <Link
href="/pricing" href="/pricing"
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors shadow-glow-teal" className="px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors shadow-glow-teal"
> >
View pricing View pricing
</Link> </Link>
<Link <Link
href="/faq" href="/faq"
className="px-6 py-3 rounded-lg bg-background border border-border text-foreground font-medium hover:bg-secondary transition-colors" className="px-6 py-3 rounded-lg bg-background border border-border text-foreground font-medium hover:bg-secondary transition-colors"
> >
Explore FAQs Explore FAQs
</Link> </Link>
</div> </div>
</div> </div>
<div className="relative animate-fade-in"> <div className="relative animate-fade-in">
<div className="absolute -right-10 top-8 hidden h-64 w-64 rounded-full bg-accent/20 blur-3xl lg:block" /> <div className="absolute -right-10 top-8 hidden h-64 w-64 rounded-full bg-accent/20 blur-3xl lg:block" />
<div className="relative rounded-2xl overflow-hidden border border-border shadow-xl glass-panel"> <div className="relative rounded-2xl overflow-hidden border border-border shadow-xl glass-panel">
<Image <Image
src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1600&auto=format&fit=crop" src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1600&auto=format&fit=crop"
alt="Analyst reviewing charts on a laptop" alt="Analyst reviewing charts on a laptop"
width={1200} width={1200}
height={900} height={900}
className="h-full w-full object-cover opacity-90" className="h-full w-full object-cover opacity-90"
/> />
<div className="absolute bottom-0 left-0 right-0 bg-background/90 backdrop-blur-sm p-6 border-t border-border"> <div className="absolute bottom-0 left-0 right-0 bg-background/90 backdrop-blur-sm p-6 border-t border-border">
<p className="text-xs font-bold uppercase tracking-wider text-primary"> <p className="text-xs font-bold uppercase tracking-wider text-primary">
Built for US operators Built for US operators
</p> </p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
LedgerOne is built around US accounting workflows, audit readiness, and LedgerOne is built around US accounting workflows, audit readiness, and
tax reporting cycles. tax reporting cycles.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section className="mt-24 grid gap-8 md:grid-cols-3"> <section className="mt-24 grid gap-8 md:grid-cols-3">
{values.map((value) => ( {values.map((value) => (
<div <div
key={value.title} key={value.title}
className="glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all" className="glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all"
> >
<div className="h-10 w-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-4"> <div className="h-10 w-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-4">
<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"></path></svg> <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"></path></svg>
</div> </div>
<h3 className="text-lg font-bold text-foreground">{value.title}</h3> <h3 className="text-lg font-bold text-foreground">{value.title}</h3>
<p className="mt-2 text-muted-foreground">{value.detail}</p> <p className="mt-2 text-muted-foreground">{value.detail}</p>
</div> </div>
))} ))}
</section> </section>
<section className="mt-24 text-center"> <section className="mt-24 text-center">
<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 backdrop-blur-sm mb-6"> <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 backdrop-blur-sm mb-6">
Leadership Leadership
</div> </div>
<h2 className="text-3xl font-bold text-foreground mb-12">Built by finance experts.</h2> <h2 className="text-3xl font-bold text-foreground mb-12">Built by finance experts.</h2>
<div className="max-w-sm mx-auto glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all"> <div className="max-w-sm mx-auto glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all">
<div className="h-24 w-24 rounded-full bg-primary/10 mx-auto mb-6 flex items-center justify-center text-3xl font-bold text-primary"> <div className="h-24 w-24 rounded-full bg-primary/10 mx-auto mb-6 flex items-center justify-center text-3xl font-bold text-primary">
MM MM
</div> </div>
<h3 className="text-xl font-bold text-foreground">Manoj Mohan</h3> <h3 className="text-xl font-bold text-foreground">Manoj Mohan</h3>
<p className="text-sm font-medium text-primary mt-1">Founder & CEO</p> <p className="text-sm font-medium text-primary mt-1">Founder & CEO</p>
<p className="mt-4 text-muted-foreground text-sm leading-relaxed"> <p className="mt-4 text-muted-foreground text-sm leading-relaxed">
Leading the vision to bring audit-ready financial controls to modern businesses. Leading the vision to bring audit-ready financial controls to modern businesses.
</p> </p>
</div> </div>
</section> </section>
<section className="mt-24 rounded-3xl bg-secondary/30 border border-border p-8 sm:p-12"> <section className="mt-24 rounded-3xl bg-secondary/30 border border-border p-8 sm:p-12">
<div className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]"> <div className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-6"> <div className="space-y-6">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-background border border-border text-xs font-medium text-muted-foreground"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-background border border-border text-xs font-medium text-muted-foreground">
What we built What we built
</div> </div>
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2> <h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
<p className="text-muted-foreground text-lg"> <p className="text-muted-foreground text-lg">
Traditional tools collapse data into summaries. LedgerOne keeps each raw Traditional tools collapse data into summaries. LedgerOne keeps each raw
entry intact and layers in decisions, reviews, and approvals. entry intact and layers in decisions, reviews, and approvals.
</p> </p>
</div> </div>
<div className="grid gap-6 sm:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-3">
{milestones.map((item, index) => ( {milestones.map((item, index) => (
<div <div
key={item.title} key={item.title}
className="rounded-xl bg-background border border-border p-6 shadow-sm" className="rounded-xl bg-background border border-border p-6 shadow-sm"
> >
<p className="text-xs font-bold text-primary uppercase tracking-wider"> <p className="text-xs font-bold text-primary uppercase tracking-wider">
Focus {index + 1} Focus {index + 1}
</p> </p>
<p className="mt-3 text-sm font-bold text-foreground">{item.title}</p> <p className="mt-3 text-sm font-bold text-foreground">{item.title}</p>
<p className="mt-2 text-xs text-muted-foreground">{item.detail}</p> <p className="mt-2 text-xs text-muted-foreground">{item.detail}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -1,12 +1,6 @@
export async function POST() { import { NextRequest } from "next/server";
const res = await fetch("http://localhost:3051/api/accounts/link", { import { proxyRequest } from "@/lib/backend";
method: "POST"
}); export async function GET(req: NextRequest) {
const body = await res.text(); return proxyRequest(req, "accounts/link-token");
return new Response(body, { }
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/accounts/manual`, { export async function POST(req: NextRequest) {
method: "POST", return proxyRequest(req, "accounts/manual");
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -1,13 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051" import { proxyRequest } from "@/lib/backend";
const url = new URL(req.url);
const query = url.searchParams.toString(); export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/accounts${query ? `?${query}` : ""}`); return proxyRequest(req, "accounts");
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -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");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/login`, { export async function POST(req: NextRequest) {
method: "POST", return proxyRequest(req, "auth/login");
headers: { "Content-Type": "application/json" }, }
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -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");
}

6
app/api/auth/me/route.ts Normal file
View File

@ -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");
}

View File

@ -1,17 +1,10 @@
export async function PATCH(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const auth = req.headers.get("authorization") ?? "";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/auth/profile`, { return proxyRequest(req, "auth/me");
method: "PATCH", }
headers: { "Content-Type": "application/json", Authorization: auth },
body export async function PATCH(req: NextRequest) {
}); return proxyRequest(req, "auth/profile");
const payload = await res.text(); }
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -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");
}

View File

@ -1,26 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/register`, { export async function POST(req: NextRequest) {
method: "POST", return proxyRequest(req, "auth/register");
headers: { "Content-Type": "application/json" }, }
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
export async function GET() {
return new Response(
JSON.stringify({
ok: true,
message: "POST JSON { email, password } to /api/auth/register."
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -1,11 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const url = new URL(req.url); import { proxyRequest } from "@/lib/backend";
const res = await fetch(`http://localhost:3051/api/exports/csv${url.search}`);
const body = await res.text(); export async function GET(req: NextRequest) {
return new Response(body, { return proxyRequest(req, "exports/csv");
status: res.status, }
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/plaid/exchange`, { export async function POST(req: NextRequest) {
method: "POST", return proxyRequest(req, "plaid/exchange");
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -1,14 +1,6 @@
export async function POST() { import { NextRequest } from "next/server";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; import { proxyRequest } from "@/lib/backend";
const res = await fetch(`${baseUrl}/api/plaid/link-token`, {
method: "POST", export async function POST(req: NextRequest) {
headers: { "Content-Type": "application/json" } return proxyRequest(req, "plaid/link-token");
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const url = new URL(req.url); import { proxyRequest } from "@/lib/backend";
const res = await fetch(`http://localhost:3051/api/rules${url.search}`);
const body = await res.text(); export async function GET(req: NextRequest) {
return new Response(body, { return proxyRequest(req, "rules");
status: res.status, }
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json" export async function POST(req: NextRequest) {
} return proxyRequest(req, "rules");
}); }
}
export async function POST(req: Request) {
const body = await req.text();
const res = await fetch("http://localhost:3051/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -1,11 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const url = new URL(req.url); import { proxyRequest } from "@/lib/backend";
const res = await fetch(`http://localhost:3051/api/rules/suggestions${url.search}`);
const body = await res.text(); export async function GET(req: NextRequest) {
return new Response(body, { return proxyRequest(req, "rules/suggestions");
status: res.status, }
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -1,15 +1,9 @@
export async function POST( import { NextRequest } from "next/server";
_req: Request, import { proxyRequest } from "@/lib/backend";
context: { params: { id: string } }
) { export async function GET(
const res = await fetch(`http://localhost:3051/api/tax/returns/${context.params.id}/export`, { req: NextRequest,
method: "POST" { params }: { params: { id: string } }
}); ) {
const body = await res.text(); return proxyRequest(req, `tax/returns/${params.id}/export`);
return new Response(body, { }
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const url = new URL(req.url); import { proxyRequest } from "@/lib/backend";
const res = await fetch(`http://localhost:3051/api/tax/returns${url.search}`);
const body = await res.text(); export async function GET(req: NextRequest) {
return new Response(body, { return proxyRequest(req, "tax/returns");
status: res.status, }
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json" export async function POST(req: NextRequest) {
} return proxyRequest(req, "tax/returns");
}); }
}
export async function POST(req: Request) {
const body = await req.text();
const res = await fetch("http://localhost:3051/api/tax/returns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}

View File

@ -1,27 +1,9 @@
export async function PATCH(req: Request, context: { params: { id: string } }) { import { NextRequest } from "next/server";
try { import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text(); export async function PATCH(
const res = await fetch(`${baseUrl}/api/transactions/${context.params.id}/derived`, { req: NextRequest,
method: "PATCH", { params }: { params: { id: string } }
headers: { "Content-Type": "application/json" }, ) {
body: payload return proxyRequest(req, `transactions/${params.id}/derived`);
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
} }

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
try { import { proxyRequest } from "@/lib/backend";
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/transactions/cashflow${url.search}`); return proxyRequest(req, "transactions/cashflow");
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
} }

View 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");
}

View File

@ -1,27 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
try { import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text(); export async function POST(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/transactions/manual`, { return proxyRequest(req, "transactions/manual");
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
} }

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
try { import { proxyRequest } from "@/lib/backend";
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/transactions/merchants${url.search}`); return proxyRequest(req, "transactions/merchants");
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
} }

View File

@ -1,23 +1,10 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
try { import { proxyRequest } from "@/lib/backend";
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/transactions${url.search}`); return proxyRequest(req, "transactions");
const body = await res.text(); }
return new Response(body, {
status: res.status, export async function POST(req: NextRequest) {
headers: { return proxyRequest(req, "transactions");
"Content-Type": res.headers.get("content-type") ?? "application/json" }
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
}

View File

@ -1,13 +1,6 @@
export async function GET(req: Request) { import { NextRequest } from "next/server";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; import { proxyRequest } from "@/lib/backend";
const url = new URL(req.url);
const query = url.searchParams.toString(); export async function GET(req: NextRequest) {
const res = await fetch(`${baseUrl}/api/transactions/summary${query ? `?${query}` : ""}`); return proxyRequest(req, "transactions/summary");
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) { import { NextRequest } from "next/server";
const body = await req.text(); import { proxyRequest } from "@/lib/backend";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/sync`, { export async function POST(req: NextRequest) {
method: "POST", return proxyRequest(req, "transactions/sync");
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} }

View File

@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = { type ApiResponse<T> = {
data: T; data: T;
@ -31,36 +32,14 @@ type MerchantInsight = {
count: number; count: number;
}; };
const recentTransactions = [ type TxRow = {
{ id: string;
date: "Oct 24, 2023", date: string;
description: "Whole Foods Market", description: string;
category: "Groceries", category?: string | null;
account: "Chase Sapphire", accountId?: string | null;
amount: "-$142.30" amount: string;
}, };
{
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 ---------- // ---------- helpers ----------
function formatMonthLabel(yyyyMm: string) { function formatMonthLabel(yyyyMm: string) {
@ -520,27 +499,23 @@ export default function AppHomePage() {
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]); const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]); const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null); const [accountCount, setAccountCount] = useState<number | null>(null);
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setLoading(false);
return;
}
const query = `?user_id=${encodeURIComponent(userId)}`;
Promise.all([ Promise.all([
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>), apiFetch<Summary>("/api/transactions/summary"),
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>), apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>), apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>) apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
]) ])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => { .then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data); if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data); if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data); if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
if (!accountsRes.error) setAccountCount(accountsRes.data.length); if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
}) })
.catch(() => undefined) .catch(() => undefined)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -743,26 +718,48 @@ export default function AppHomePage() {
<th className="pb-3 pl-2">Date</th> <th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th> <th className="pb-3">Description</th>
<th className="pb-3">Category</th> <th className="pb-3">Category</th>
<th className="pb-3">Account</th>
<th className="pb-3 pr-2 text-right">Amount</th> <th className="pb-3 pr-2 text-right">Amount</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recentTransactions.map((tx) => ( {loading ? (
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors"> Array.from({ length: 4 }).map((_, i) => (
<td className="py-3 pl-2 font-medium">{tx.date}</td> <tr key={i} className="border-b border-border">
<td className="py-3 text-foreground font-medium">{tx.description}</td> <td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"> <td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground"> <td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
{tx.category} <td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
</span> </tr>
</td> ))
<td className="py-3">{tx.account}</td> ) : recentTxs.length ? (
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}> recentTxs.map((tx) => {
{tx.amount} const amt = Number.parseFloat(tx.amount ?? "0");
const fmtAmt = formatCurrency(amt);
const isIncome = amt >= 0;
return (
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
{tx.category ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
) : <span className="text-muted-foreground"></span>}
</td>
<td className={`py-3 pr-2 text-right font-bold ${isIncome ? "text-primary" : "text-foreground"}`}>
{fmtAmt}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-8 text-center text-muted-foreground">
No transactions yet. Connect a bank account to get started.
</td> </td>
</tr> </tr>
))} )}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<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">Connecting your Google account...</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">Google Connected!</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<p className="text-xs text-muted-foreground mt-2">Redirecting to Exports...</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">Connection Failed</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<button
onClick={() => router.replace("/exports")}
className="mt-4 text-xs text-primary hover:underline"
>
Back to Exports
</button>
</>
)}
</div>
</div>
);
}

View File

@ -1,106 +1,106 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ContactSection } from "../../../components/contact-section"; import { ContactSection } from "../../../components/contact-section";
import { DemoCta } from "../../../components/demo-cta"; import { DemoCta } from "../../../components/demo-cta";
import { FaqSection } from "../../../components/faq-section"; import { FaqSection } from "../../../components/faq-section";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { blogPosts } from "../../../data/blog"; import { blogPosts } from "../../../data/blog";
import { defaultFaqs } from "../../../data/faq"; import { defaultFaqs } from "../../../data/faq";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
type PageProps = { type PageProps = {
params: { slug: string }; params: { slug: string };
}; };
export function generateStaticParams() { export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug })); return blogPosts.map((post) => ({ slug: post.slug }));
} }
export function generateMetadata({ params }: PageProps) { export function generateMetadata({ params }: PageProps) {
const post = blogPosts.find((item) => item.slug === params.slug); const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) { if (!post) {
return { title: "Blog Post" }; return { title: "Blog Post" };
} }
return { return {
title: post.title, title: post.title,
description: post.excerpt, description: post.excerpt,
keywords: [...siteInfo.keywords, "finance blog", "ledger insights"] keywords: [...siteInfo.keywords, "finance blog", "ledger insights"]
}; };
} }
export default function BlogPostPage({ params }: PageProps) { export default function BlogPostPage({ params }: PageProps) {
const post = blogPosts.find((item) => item.slug === params.slug); const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) { if (!post) {
notFound(); notFound();
} }
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: post.title, headline: post.title,
description: post.excerpt, description: post.excerpt,
datePublished: post.date, datePublished: post.date,
author: { author: {
"@type": "Organization", "@type": "Organization",
name: siteInfo.name name: siteInfo.name
}, },
image: post.image, image: post.image,
mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}` mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}`
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({ mainEntity: defaultFaqs.map((item) => ({
"@type": "Question", "@type": "Question",
name: item.question, name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer } acceptedAnswer: { "@type": "Answer", text: item.answer }
})) }))
} }
]; ];
return ( return (
<div className="marketing min-h-screen"> <div className="marketing min-h-screen">
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<div className="halo absolute inset-0" /> <div className="halo absolute inset-0" />
<div className="grid-dots absolute inset-0" /> <div className="grid-dots absolute inset-0" />
<SiteHeader /> <SiteHeader />
<main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12"> <main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12">
<Link className="text-sm font-semibold text-ink" href="/blog"> <Link className="text-sm font-semibold text-ink" href="/blog">
&lt;- Back to blog &lt;- Back to blog
</Link> </Link>
<article className="mt-6 rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft"> <article className="mt-6 rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
<p className="text-xs uppercase tracking-[0.3em] text-muted"> <p className="text-xs uppercase tracking-[0.3em] text-muted">
{post.date} - {post.readTime} {post.date} - {post.readTime}
</p> </p>
<h1 className="mt-3 text-3xl font-semibold">{post.title}</h1> <h1 className="mt-3 text-3xl font-semibold">{post.title}</h1>
<p className="mt-4 text-sm text-muted">{post.excerpt}</p> <p className="mt-4 text-sm text-muted">{post.excerpt}</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-ink/10"> <div className="mt-6 overflow-hidden rounded-2xl border border-ink/10">
<Image <Image
src={post.image} src={post.image}
alt={post.title} alt={post.title}
width={1200} width={1200}
height={900} height={900}
className="h-64 w-full object-cover" className="h-64 w-full object-cover"
/> />
</div> </div>
<div className="mt-6 space-y-4 text-sm text-muted"> <div className="mt-6 space-y-4 text-sm text-muted">
{post.content.map((paragraph) => ( {post.content.map((paragraph) => (
<p key={paragraph}>{paragraph}</p> <p key={paragraph}>{paragraph}</p>
))} ))}
</div> </div>
</article> </article>
</main> </main>
</div> </div>
<DemoCta /> <DemoCta />
<FaqSection limit={8} /> <FaqSection limit={8} />
<ContactSection /> <ContactSection />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
<SiteFooter /> <SiteFooter />
</div> </div>
); );
} }

View File

@ -1,109 +1,109 @@
import Link from "next/link"; import Link from "next/link";
import { PageSchema } from "../../components/page-schema"; import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer"; import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header"; import { SiteHeader } from "../../components/site-header";
import { siteInfo } from "../../data/site"; import { siteInfo } from "../../data/site";
export const metadata = { export const metadata = {
title: "Blog", title: "Blog",
description: "Insights on financial control, audit readiness, and ledger automation.", description: "Insights on financial control, audit readiness, and ledger automation.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
const posts = [ const posts = [
{ {
title: "Why 'Audit-Ready' Matters More Than Ever", title: "Why 'Audit-Ready' Matters More Than Ever",
excerpt: excerpt:
"As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.", "As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.",
date: "Oct 24, 2023", date: "Oct 24, 2023",
readTime: "5 min read", readTime: "5 min read",
slug: "audit-ready-matters" slug: "audit-ready-matters"
}, },
{ {
title: "The Hidden Cost of Spreadsheet Chaos", title: "The Hidden Cost of Spreadsheet Chaos",
excerpt: excerpt:
"Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.", "Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.",
date: "Oct 12, 2023", date: "Oct 12, 2023",
readTime: "4 min read", readTime: "4 min read",
slug: "spreadsheet-chaos" slug: "spreadsheet-chaos"
}, },
{ {
title: "Automating the Month-End Close", title: "Automating the Month-End Close",
excerpt: excerpt:
"How to use rules and categories to reduce your close time from days to hours.", "How to use rules and categories to reduce your close time from days to hours.",
date: "Sep 28, 2023", date: "Sep 28, 2023",
readTime: "6 min read", readTime: "6 min read",
slug: "automating-month-end" slug: "automating-month-end"
} }
]; ];
export default function BlogPage() { export default function BlogPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne Blog", name: "LedgerOne Blog",
description: "Insights on financial control, audit readiness, and ledger automation.", description: "Insights on financial control, audit readiness, and ledger automation.",
url: `${siteInfo.url}/blog` url: `${siteInfo.url}/blog`
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Blog", "@type": "Blog",
blogPost: posts.map((post) => ({ blogPost: posts.map((post) => ({
"@type": "BlogPosting", "@type": "BlogPosting",
headline: post.title, headline: post.title,
description: post.excerpt, description: post.excerpt,
datePublished: post.date, datePublished: post.date,
url: `${siteInfo.url}/blog/${post.slug}` url: `${siteInfo.url}/blog/${post.slug}`
})) }))
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24 relative overflow-hidden"> <main className="flex-1 pt-32 pb-24 relative overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" /> <div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up"> <div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
Insights for modern finance teams. Insights for modern finance teams.
</h1> </h1>
<p className="mt-6 text-lg text-muted-foreground"> <p className="mt-6 text-lg text-muted-foreground">
Best practices for financial control, audit readiness, and automation. Best practices for financial control, audit readiness, and automation.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
{posts.map((post) => ( {posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="group"> <Link key={post.slug} href={`/blog/${post.slug}`} className="group">
<div className="glass-panel h-full rounded-2xl p-8 shadow-sm transition-all hover:shadow-md hover:-translate-y-1"> <div className="glass-panel h-full rounded-2xl p-8 shadow-sm transition-all hover:shadow-md hover:-translate-y-1">
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4"> <div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
<time dateTime={post.date}>{post.date}</time> <time dateTime={post.date}>{post.date}</time>
<span></span> <span></span>
<span>{post.readTime}</span> <span>{post.readTime}</span>
</div> </div>
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3"> <h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3">
{post.title} {post.title}
</h3> </h3>
<p className="text-muted-foreground text-sm leading-relaxed"> <p className="text-muted-foreground text-sm leading-relaxed">
{post.excerpt} {post.excerpt}
</p> </p>
<div className="mt-6 flex items-center text-primary font-medium text-sm"> <div className="mt-6 flex items-center text-primary font-medium text-sm">
Read article Read article
<svg className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg> <svg className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
</div> </div>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -5,132 +5,132 @@ import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema"; import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer"; import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header"; import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq"; import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; import { siteInfo } from "../../data/site";
export const metadata = { export const metadata = {
title: "Book a Demo", title: "Book a Demo",
description: description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.", "Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function BookDemoPage() { export default function BookDemoPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "Book a LedgerOne Demo", name: "Book a LedgerOne Demo",
description: description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.", "Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
url: `${siteInfo.url}/book-demo` url: `${siteInfo.url}/book-demo`
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({ mainEntity: defaultFaqs.map((item) => ({
"@type": "Question", "@type": "Question",
name: item.question, name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer } acceptedAnswer: { "@type": "Answer", text: item.answer }
})) }))
} }
]; ];
return ( return (
<div className="marketing min-h-screen"> <div className="marketing min-h-screen">
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<div className="halo absolute inset-0" /> <div className="halo absolute inset-0" />
<div className="grid-dots absolute inset-0" /> <div className="grid-dots absolute inset-0" />
<SiteHeader /> <SiteHeader />
<main className="relative mx-auto max-w-6xl px-6 pb-12 pt-12"> <main className="relative mx-auto max-w-6xl px-6 pb-12 pt-12">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]"> <section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-6"> <div className="space-y-6">
<p className="text-xs uppercase tracking-[0.4em] text-muted">Book a demo</p> <p className="text-xs uppercase tracking-[0.4em] text-muted">Book a demo</p>
<h1 className="text-4xl font-semibold leading-tight"> <h1 className="text-4xl font-semibold leading-tight">
Schedule time with the LedgerOne team. Schedule time with the LedgerOne team.
</h1> </h1>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
We will walk you through account connections, rule automation, and We will walk you through account connections, rule automation, and
audit-ready exports based on your workflow. audit-ready exports based on your workflow.
</p> </p>
<div className="rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft"> <div className="rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
<form className="grid gap-4 md:grid-cols-2"> <form className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Full name Full name
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Work email Work email
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="email" type="email"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Preferred date Preferred date
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="date" type="date"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Team size Team size
</label> </label>
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"> <select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
<option>1-5</option> <option>1-5</option>
<option>6-20</option> <option>6-20</option>
<option>21-50</option> <option>21-50</option>
<option>50+</option> <option>50+</option>
</select> </select>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
What should we focus oni What should we focus oni
</label> </label>
<textarea className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" /> <textarea className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<button <button
type="submit" type="submit"
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze" className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
> >
Request demo Request demo
</button> </button>
</div> </div>
</form> </form>
<p className="mt-4 text-xs text-muted"> <p className="mt-4 text-xs text-muted">
Prefer emaili Reach us at{" "} Prefer emaili Reach us at{" "}
<Link className="underline" href="/contact"> <Link className="underline" href="/contact">
the contact page the contact page
</Link> </Link>
. .
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-4 rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft"> <div className="space-y-4 rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
<p className="text-xs uppercase tracking-[0.3em] text-muted">What you'll see</p> <p className="text-xs uppercase tracking-[0.3em] text-muted">What you'll see</p>
<ul className="space-y-3 text-sm text-muted"> <ul className="space-y-3 text-sm text-muted">
<li>Connect accounts and review the raw ledger flow.</li> <li>Connect accounts and review the raw ledger flow.</li>
<li>Watch rule automation run and inspect the audit trail.</li> <li>Watch rule automation run and inspect the audit trail.</li>
<li>Export a complete ledger package ready for review.</li> <li>Export a complete ledger package ready for review.</li>
</ul> </ul>
</div> </div>
</section> </section>
</main> </main>
</div> </div>
<ContactSection /> <ContactSection />
<DemoCta /> <DemoCta />
@ -138,5 +138,5 @@ export default function BookDemoPage() {
<PageSchema schema={schema} /> <PageSchema schema={schema} />
<SiteFooter /> <SiteFooter />
</div> </div>
); );
} }

View File

@ -1,86 +1,86 @@
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "LedgerOne vs Copilot", title: "LedgerOne vs Copilot",
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.", description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function CompareCopilotPage() { export default function CompareCopilotPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne vs Copilot", name: "LedgerOne vs Copilot",
description: "Comparison of LedgerOne and Copilot.", description: "Comparison of LedgerOne and Copilot.",
url: `${siteInfo.url}/compare/vs-copilot` url: `${siteInfo.url}/compare/vs-copilot`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16"> <div className="text-center max-w-3xl mx-auto mb-16">
<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"> <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 Comparison
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Financial control for everyone. Financial control for everyone.
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device. Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device.
</p> </p>
</div> </div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border"> <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="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">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div> <div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Copilot</div> <div className="col-span-1 text-center">Copilot</div>
</div> </div>
{[ {[
{ feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" }, { feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" },
{ feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" }, { feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" },
{ feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" }, { feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" },
{ feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" }, { feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" },
{ feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" }, { feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" },
{ feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" }, { feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" },
].map((row, i) => ( ].map((row, i) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}> <div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 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 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"> <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"></path></svg> <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"></path></svg>
{row.l1} {row.l1}
</div> </div>
<div className="col-span-1 text-center text-muted-foreground"> <div className="col-span-1 text-center text-muted-foreground">
{row.sheet} {row.sheet}
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-16 text-center"> <div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2> <h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start your free trial Start your free trial
</Link> </Link>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,86 +1,86 @@
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "LedgerOne vs Quicken", title: "LedgerOne vs Quicken",
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.", description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function CompareQuickenPage() { export default function CompareQuickenPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne vs Quicken", name: "LedgerOne vs Quicken",
description: "Comparison of LedgerOne and Quicken.", description: "Comparison of LedgerOne and Quicken.",
url: `${siteInfo.url}/compare/vs-quicken` url: `${siteInfo.url}/compare/vs-quicken`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16"> <div className="text-center max-w-3xl mx-auto mb-16">
<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"> <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 Comparison
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
The modern alternative to Quicken. The modern alternative to Quicken.
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web. Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web.
</p> </p>
</div> </div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border"> <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="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">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div> <div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Quicken</div> <div className="col-span-1 text-center">Quicken</div>
</div> </div>
{[ {[
{ feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" }, { feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" },
{ feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" }, { feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" },
{ feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" }, { feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" },
{ feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" }, { feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" },
{ feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" }, { feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" },
{ feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" }, { feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" },
].map((row, i) => ( ].map((row, i) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}> <div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 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 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"> <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"></path></svg> <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"></path></svg>
{row.l1} {row.l1}
</div> </div>
<div className="col-span-1 text-center text-muted-foreground"> <div className="col-span-1 text-center text-muted-foreground">
{row.sheet} {row.sheet}
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-16 text-center"> <div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2> <h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start your free trial Start your free trial
</Link> </Link>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,87 +1,87 @@
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "LedgerOne vs Spreadsheets", title: "LedgerOne vs Spreadsheets",
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.", description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function CompareSpreadsheetsPage() { export default function CompareSpreadsheetsPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne vs Spreadsheets", name: "LedgerOne vs Spreadsheets",
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.", description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
url: `${siteInfo.url}/compare/vs-spreadsheets` url: `${siteInfo.url}/compare/vs-spreadsheets`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16"> <div className="text-center max-w-3xl mx-auto mb-16">
<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"> <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 Comparison
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Stop breaking your spreadsheets. Stop breaking your spreadsheets.
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs. Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs.
</p> </p>
</div> </div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border"> <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="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">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div> <div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Spreadsheets</div> <div className="col-span-1 text-center">Spreadsheets</div>
</div> </div>
{[ {[
{ feature: "Bank Connections", l1: "Automatic (11,000+ banks)", sheet: "Manual CSV imports" }, { feature: "Bank Connections", l1: "Automatic (11,000+ banks)", sheet: "Manual CSV imports" },
{ feature: "Transaction Categorization", l1: "AI-powered & Rules", sheet: "Manual entry" }, { feature: "Transaction Categorization", l1: "AI-powered & Rules", sheet: "Manual entry" },
{ feature: "Security", l1: "Bank-level encryption (SOC2)", sheet: "Password protected file" }, { feature: "Security", l1: "Bank-level encryption (SOC2)", sheet: "Password protected file" },
{ feature: "Mobile Access", l1: "Native iOS & Android apps", sheet: "Clunky mobile view" }, { feature: "Mobile Access", l1: "Native iOS & Android apps", sheet: "Clunky mobile view" },
{ feature: "Collaboration", l1: "Multi-user with permissions", sheet: "Version control nightmares" }, { feature: "Collaboration", l1: "Multi-user with permissions", sheet: "Version control nightmares" },
{ feature: "Reporting", l1: "Instant, interactive charts", sheet: "Manual chart building" }, { feature: "Reporting", l1: "Instant, interactive charts", sheet: "Manual chart building" },
{ feature: "Audit Trail", l1: "Immutable change logs", sheet: "None" }, { feature: "Audit Trail", l1: "Immutable change logs", sheet: "None" },
].map((row, i) => ( ].map((row, i) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}> <div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 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 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"> <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"></path></svg> <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"></path></svg>
{row.l1} {row.l1}
</div> </div>
<div className="col-span-1 text-center text-muted-foreground"> <div className="col-span-1 text-center text-muted-foreground">
{row.sheet} {row.sheet}
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-16 text-center"> <div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to upgrade?</h2> <h2 className="text-2xl font-bold text-foreground mb-6">Ready to upgrade?</h2>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start your free trial Start your free trial
</Link> </Link>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,86 +1,86 @@
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "LedgerOne vs YNAB", title: "LedgerOne vs YNAB",
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.", description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function CompareYnabPage() { export default function CompareYnabPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne vs YNAB", name: "LedgerOne vs YNAB",
description: "Comparison of LedgerOne and YNAB.", description: "Comparison of LedgerOne and YNAB.",
url: `${siteInfo.url}/compare/vs-ynab` url: `${siteInfo.url}/compare/vs-ynab`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16"> <div className="text-center max-w-3xl mx-auto mb-16">
<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"> <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 Comparison
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Beyond Budgeting. Beyond Budgeting.
</h1> </h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control. YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control.
</p> </p>
</div> </div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border"> <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="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">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div> <div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">YNAB</div> <div className="col-span-1 text-center">YNAB</div>
</div> </div>
{[ {[
{ feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" }, { feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" },
{ feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" }, { feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" },
{ feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" }, { feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" },
{ feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" }, { feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" },
{ feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" }, { feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" },
{ feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" }, { feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" },
].map((row, i) => ( ].map((row, i) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}> <div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 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 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"> <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"></path></svg> <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"></path></svg>
{row.l1} {row.l1}
</div> </div>
<div className="col-span-1 text-center text-muted-foreground"> <div className="col-span-1 text-center text-muted-foreground">
{row.sheet} {row.sheet}
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="mt-16 text-center"> <div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2> <h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start your free trial Start your free trial
</Link> </Link>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,19 +1,21 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type ExportData = { status: string; csv?: string; rowCount?: number }; type ExportData = { status: string; csv?: string; rowCount?: number };
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
export default function ExportsPage() { export default function ExportsPage() {
const [status, setStatus] = useState(""); const [csvStatus, setCsvStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom"); const [datePreset, setDatePreset] = useState("custom");
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
startDate: "", startDate: "",
endDate: "", endDate: "",
@ -21,14 +23,18 @@ export default function ExportsPage() {
maxAmount: "", maxAmount: "",
category: "", category: "",
source: "", source: "",
includeHidden: false includeHidden: false,
}); });
useEffect(() => {
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
});
}, []);
const applyPreset = (preset: string) => { const applyPreset = (preset: string) => {
setDatePreset(preset); setDatePreset(preset);
if (preset === "custom") { if (preset === "custom") return;
return;
}
const now = new Date(); const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end); let start = new Date(end);
@ -43,101 +49,104 @@ export default function ExportsPage() {
start = new Date(end.getFullYear() - 1, 0, 1); start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31); end.setMonth(11, 31);
} }
const format = (value: Date) => value.toISOString().slice(0, 10); const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
...prev,
startDate: format(start),
endDate: format(end)
}));
}; };
const onExport = async () => { const buildParams = () => {
setStatus("Generating export...");
const userId = localStorage.getItem("ledgerone_user_id");
const params = new URLSearchParams(); const params = new URLSearchParams();
if (userId) { if (filters.startDate) params.set("start_date", filters.startDate);
params.set("user_id", userId); if (filters.endDate) params.set("end_date", filters.endDate);
} if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.startDate) { if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
params.set("start_date", filters.startDate); if (filters.category) params.set("category", filters.category);
} if (filters.source) params.set("source", filters.source);
if (filters.endDate) { if (filters.includeHidden) params.set("include_hidden", "true");
params.set("end_date", filters.endDate); return params;
} };
if (filters.minAmount) {
params.set("min_amount", filters.minAmount); const onExportCsv = async () => {
} setCsvStatus("Generating export...");
if (filters.maxAmount) { const params = buildParams();
params.set("max_amount", filters.maxAmount);
}
if (filters.category) {
params.set("category", filters.category);
}
if (filters.source) {
params.set("source", filters.source);
}
if (filters.includeHidden) {
params.set("include_hidden", "true");
}
const query = params.toString() ? `?${params.toString()}` : ""; const query = params.toString() ? `?${params.toString()}` : "";
try { const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
const res = await fetch(`/api/exports/csv${query}`); if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
const payload = (await res.json()) as ApiResponse<ExportData>; if (res.data?.csv) {
if (!res.ok || payload.error) { const blob = new Blob([res.data.csv], { type: "text/csv" });
setStatus(payload.error?.message ?? "Export failed."); const url = URL.createObjectURL(blob);
return; const a = document.createElement("a");
} a.href = url;
if (payload.data.csv) { a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
const blob = new Blob([payload.data.csv], { type: "text/csv" }); a.click();
const url = URL.createObjectURL(blob); URL.revokeObjectURL(url);
window.open(url, "_blank", "noopener,noreferrer"); setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
setStatus(`Export ready (${payload.data.rowCount ?? 0} rows).`); } else {
} else { setCsvStatus("Export ready.");
setStatus("Export ready.");
}
} catch {
setStatus("Export failed.");
} }
}; };
const onConnectGoogle = async () => {
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
if (res.error) {
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
return;
}
if (res.data?.authUrl) {
window.location.href = res.data.authUrl;
}
};
const onDisconnectGoogle = async () => {
setDisconnecting(true);
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
setDisconnecting(false);
if (!res.error) {
setGoogleStatus({ connected: false });
setSheetsStatus("Google account disconnected.");
setSheetsUrl(null);
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
if (url) {
setSheetsUrl(url);
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return ( return (
<AppShell <AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
title="Exports" <div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
subtitle="Generate CSV datasets with raw and derived fields." {/* Filters */}
>
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div> <div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Start date</label> <label className={labelCls}>Date range</label>
<input <select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
type="date"
value={filters.startDate}
onChange={(event) =>
setFilters((prev) => ({ ...prev, startDate: event.target.value }))
}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
disabled={datePreset !== "custom"}
/>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">End date</label>
<input
type="date"
value={filters.endDate}
onChange={(event) =>
setFilters((prev) => ({ ...prev, endDate: event.target.value }))
}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
disabled={datePreset !== "custom"}
/>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Date range</label>
<select
value={datePreset}
onChange={(event) => applyPreset(event.target.value)}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
>
<option value="custom">Custom</option> <option value="custom">Custom</option>
<option value="this_month">This month</option> <option value="this_month">This month</option>
<option value="last_month">Last month</option> <option value="last_month">Last month</option>
@ -146,96 +155,114 @@ export default function ExportsPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Category contains</label> <label className={labelCls}>Start date</label>
<input <input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
type="text"
value={filters.category}
onChange={(event) =>
setFilters((prev) => ({ ...prev, category: event.target.value }))
}
placeholder="Dining, Payroll, Utilities"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Min amount</label> <label className={labelCls}>End date</label>
<input <input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
type="number"
value={filters.minAmount}
onChange={(event) =>
setFilters((prev) => ({ ...prev, minAmount: event.target.value }))
}
placeholder="0.00"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Max amount</label> <label className={labelCls}>Category contains</label>
<input <input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
type="number"
value={filters.maxAmount}
onChange={(event) =>
setFilters((prev) => ({ ...prev, maxAmount: event.target.value }))
}
placeholder="10000.00"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
</div> </div>
<div> <div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Source contains</label> <label className={labelCls}>Min amount ($)</label>
<input <input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
type="text" </div>
value={filters.source} <div>
onChange={(event) => <label className={labelCls}>Max amount ($)</label>
setFilters((prev) => ({ ...prev, source: event.target.value })) <input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
} </div>
placeholder="plaid" <div className="flex items-end pb-2">
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary" <label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
/> <input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden transactions
</label>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-4"> <div className="border-t border-border" />
<label className="flex items-center gap-2 text-xs text-muted-foreground font-medium">
<input
type="checkbox"
checked={filters.includeHidden}
onChange={(event) =>
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Include hidden transactions
</label>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3"> {/* Export cards */}
<button <div className="grid gap-4 md:grid-cols-2">
type="button" {/* CSV */}
onClick={onExport} <div className="rounded-xl border border-border bg-secondary/10 p-6">
className="rounded-full bg-primary px-6 py-3 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors" <div className="flex items-start gap-3">
> <div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
Generate CSV export <svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</button> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<button </svg>
type="button" </div>
onClick={() => <div>
setFilters({ <p className="text-sm font-bold text-foreground">Download CSV</p>
startDate: "", <p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
endDate: "", </div>
minAmount: "", </div>
maxAmount: "", <button
category: "", onClick={onExportCsv}
source: "", className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
includeHidden: false >
}) Export CSV
} </button>
className="rounded-full border border-border bg-background px-6 py-3 text-sm font-semibold text-foreground hover:bg-secondary transition-colors" {csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
> </div>
Reset filters
</button> {/* Google Sheets */}
<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-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
{googleStatus?.connected ? (
<div className="flex items-center gap-1 mt-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
)}
</div>
</div>
{googleStatus?.connected ? (
<>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
<button
onClick={onDisconnectGoogle}
disabled={disconnecting}
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
>
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
</button>
</>
) : (
<button
onClick={onConnectGoogle}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
>
Connect Google Account
</button>
)}
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div> </div>
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
</div> </div>
</AppShell> </AppShell>
); );

View File

@ -1,84 +1,84 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "Cash Flow Management - LedgerOne", title: "Cash Flow Management - LedgerOne",
description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.", description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function CashFlowPage() { export default function CashFlowPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "Cash Flow Management", name: "Cash Flow Management",
description: "Visualize your income and expenses in real-time.", description: "Visualize your income and expenses in real-time.",
url: `${siteInfo.url}/features/cash-flow` url: `${siteInfo.url}/features/cash-flow`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-16 items-center"> <div className="grid lg:grid-cols-2 gap-16 items-center">
<div className="animate-slide-up"> <div className="animate-slide-up">
<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"> <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">
Cash Flow Cash Flow
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Know exactly where your money is going. Know exactly where your money is going.
</h1> </h1>
<p className="text-lg text-muted-foreground mb-8"> <p className="text-lg text-muted-foreground mb-8">
Stop guessing. LedgerOne gives you a crystal-clear view of your income versus expenses across all your accounts. Spot trends, identify leaks, and plan for the future. Stop guessing. LedgerOne gives you a crystal-clear view of your income versus expenses across all your accounts. Spot trends, identify leaks, and plan for the future.
</p> </p>
<ul className="space-y-4 mb-10"> <ul className="space-y-4 mb-10">
{[ {[
"Real-time income vs expense tracking", "Real-time income vs expense tracking",
"Automatic categorization of transactions", "Automatic categorization of transactions",
"Interactive bar and line charts", "Interactive bar and line charts",
"Forecast future cash positions" "Forecast future cash positions"
].map((item) => ( ].map((item) => (
<li key={item} className="flex items-center gap-3"> <li key={item} className="flex items-center gap-3">
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary"> <div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
</div> </div>
<span className="text-foreground font-medium">{item}</span> <span className="text-foreground font-medium">{item}</span>
</li> </li>
))} ))}
</ul> </ul>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start tracking cash flow Start tracking cash flow
</Link> </Link>
</div> </div>
<div className="relative animate-fade-in delay-200"> <div className="relative animate-fade-in delay-200">
<div className="absolute -inset-4 bg-gradient-to-r from-primary/20 to-emerald-400/20 rounded-3xl blur-xl opacity-50"></div> <div className="absolute -inset-4 bg-gradient-to-r from-primary/20 to-emerald-400/20 rounded-3xl blur-xl opacity-50"></div>
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2"> <div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image <Image
src="/images/feature-cashflow.png" src="/images/feature-cashflow.png"
alt="Cash flow visualization" alt="Cash flow visualization"
width={800} width={800}
height={600} height={600}
className="w-full h-auto rounded-xl" className="w-full h-auto rounded-xl"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,84 +1,84 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer"; import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header"; import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema"; import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site"; import { siteInfo } from "../../../data/site";
export const metadata = { export const metadata = {
title: "Custom Reporting - LedgerOne", title: "Custom Reporting - LedgerOne",
description: "Build custom financial reports and export audit-ready data for your accountant.", description: "Build custom financial reports and export audit-ready data for your accountant.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function ReportsPage() { export default function ReportsPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "Custom Reporting", name: "Custom Reporting",
description: "Build custom financial reports.", description: "Build custom financial reports.",
url: `${siteInfo.url}/features/reports` url: `${siteInfo.url}/features/reports`
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24"> <main className="flex-1 pt-32 pb-24">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-16 items-center"> <div className="grid lg:grid-cols-2 gap-16 items-center">
<div className="order-2 lg:order-1 relative animate-fade-in delay-200"> <div className="order-2 lg:order-1 relative animate-fade-in delay-200">
<div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-3xl blur-xl opacity-50"></div> <div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-3xl blur-xl opacity-50"></div>
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2"> <div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image <Image
src="/images/feature-reports.png" src="/images/feature-reports.png"
alt="Report builder interface" alt="Report builder interface"
width={800} width={800}
height={600} height={600}
className="w-full h-auto rounded-xl" className="w-full h-auto rounded-xl"
/> />
</div> </div>
</div> </div>
<div className="order-1 lg:order-2 animate-slide-up"> <div className="order-1 lg:order-2 animate-slide-up">
<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"> <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">
Reporting Reporting
</div> </div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Reports that make your accountant smile. Reports that make your accountant smile.
</h1> </h1>
<p className="text-lg text-muted-foreground mb-8"> <p className="text-lg text-muted-foreground mb-8">
Don't scramble at tax time. LedgerOne keeps your data organized and audit-ready year-round. Build custom reports and export them in seconds. Don't scramble at tax time. LedgerOne keeps your data organized and audit-ready year-round. Build custom reports and export them in seconds.
</p> </p>
<ul className="space-y-4 mb-10"> <ul className="space-y-4 mb-10">
{[ {[
"Drag-and-drop report builder", "Drag-and-drop report builder",
"Filter by date, category, tag, or merchant", "Filter by date, category, tag, or merchant",
"One-click CSV and PDF exports", "One-click CSV and PDF exports",
"Share read-only access with your accountant" "Share read-only access with your accountant"
].map((item) => ( ].map((item) => (
<li key={item} className="flex items-center gap-3"> <li key={item} className="flex items-center gap-3">
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary"> <div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
</div> </div>
<span className="text-foreground font-medium">{item}</span> <span className="text-foreground font-medium">{item}</span>
</li> </li>
))} ))}
</ul> </ul>
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all" className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
> >
Start building reports Start building reports
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("");
const [sent, setSent] = useState(false);
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Sending reset link...");
setIsError(false);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok && payload.error) {
setStatus(payload.error.message ?? "Something went wrong.");
setIsError(true);
return;
}
setSent(true);
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
{sent ? (
<div className="text-center space-y-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm text-foreground font-medium">{status}</p>
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
Back to sign in
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<p className="mt-1 text-xs text-muted-foreground">
We&#39;ll send a reset link to this address if it has an account.
</p>
<div className="mt-2">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Send reset link
</button>
{isError && status && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
)}
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

View File

@ -1,160 +1,195 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { Suspense, useState } from "react";
import { ContactSection } from "../../components/contact-section"; import { PageSchema } from "../../components/page-schema";
import { DemoCta } from "../../components/demo-cta"; import { SiteFooter } from "../../components/site-footer";
import { FaqSection } from "../../components/faq-section"; import { SiteHeader } from "../../components/site-header";
import { PageSchema } from "../../components/page-schema"; import { defaultFaqs } from "../../data/faq";
import { SiteFooter } from "../../components/site-footer"; import { siteInfo } from "../../data/site";
import { SiteHeader } from "../../components/site-header"; import { storeAuthTokens } from "@/lib/api";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; type ApiResponse<T> = {
data: T;
type ApiResponse<T> = { meta: { timestamp: string; version: "v1" };
data: T; error: null | { message: string; code?: string };
meta: { timestamp: string; version: "v1" }; };
error: null | { message: string; code?: string };
}; type AuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
type AuthData = { user: { id: string; email: string }; token: string }; accessToken: string;
refreshToken: string;
export default function LoginPage() { requiresTwoFactor?: boolean;
const router = useRouter(); };
const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); function LoginForm() {
const [status, setStatus] = useState<string>(""); const router = useRouter();
const schema = [ const searchParams = useSearchParams();
{ const nextPath = searchParams.get("next") ?? "/app";
"@context": "https://schema.org",
"@type": "WebPage", const [email, setEmail] = useState("");
name: "LedgerOne Login", const [password, setPassword] = useState("");
description: "Sign in to LedgerOne to access your audit-ready ledger.", const [totpToken, setTotpToken] = useState("");
url: `${siteInfo.url}/login` const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
}, const [status, setStatus] = useState<string>("");
{ const [isError, setIsError] = useState(false);
"@context": "https://schema.org",
"@type": "FAQPage", const onSubmit = async (event: React.FormEvent) => {
mainEntity: defaultFaqs.map((item) => ({ event.preventDefault();
"@type": "Question", setStatus("Signing in...");
name: item.question, setIsError(false);
acceptedAnswer: { "@type": "Answer", text: item.answer } try {
})) const res = await fetch("/api/auth/login", {
} method: "POST",
]; headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
const onSubmit = async (event: React.FormEvent) => { });
event.preventDefault(); const payload = (await res.json()) as ApiResponse<AuthData>;
setStatus("Signing in..."); if (!res.ok || payload.error) {
try { setStatus(payload.error?.message ?? "Login failed.");
const res = await fetch("/api/auth/login", { setIsError(true);
method: "POST", return;
headers: { "Content-Type": "application/json" }, }
body: JSON.stringify({ email, password })
}); if (payload.data.requiresTwoFactor) {
const payload = (await res.json()) as ApiResponse<AuthData>; setRequiresTwoFactor(true);
if (!res.ok || payload.error) { setStatus("Enter the code from your authenticator app.");
setStatus(payload.error?.message ?? "Login failed."); return;
return; }
}
setStatus(`Welcome back, ${payload.data.user.email}`); storeAuthTokens({
localStorage.setItem("ledgerone_token", payload.data.token); accessToken: payload.data.accessToken,
localStorage.setItem("ledgerone_user_id", payload.data.user.id); refreshToken: payload.data.refreshToken,
router.push("/app"); user: payload.data.user,
} catch { });
setStatus("Login failed."); setStatus(`Welcome back, ${payload.data.user.email}`);
} router.push(nextPath);
}; } catch {
setStatus("Login failed. Please try again.");
return ( setIsError(true);
<div className="min-h-screen bg-background font-sans text-foreground"> }
<SiteHeader /> };
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative"> return (
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" /> <form className="space-y-6" onSubmit={onSubmit}>
<div>
<div className="sm:mx-auto sm:w-full sm:max-w-md"> <label htmlFor="email" className="block text-sm font-medium text-foreground">
<div className="flex justify-center"> Email address
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal"> </label>
L1 <div className="mt-1">
</div> <input
</div> id="email" name="email" type="email" autoComplete="email" required
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground"> value={email} onChange={(e) => setEmail(e.target.value)}
Sign in to your account className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
</h2> />
<p className="mt-2 text-center text-sm text-muted-foreground"> </div>
Or{" "} </div>
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
start your 14-day free trial <div>
</Link> <label htmlFor="password" className="block text-sm font-medium text-foreground">
</p> Password
</div> </label>
<div className="mt-1">
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <input
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10"> id="password" name="password" type="password" autoComplete="current-password" required
<form className="space-y-6" onSubmit={onSubmit}> value={password} onChange={(e) => setPassword(e.target.value)}
<div> className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
<label htmlFor="email" className="block text-sm font-medium text-foreground"> />
Email address </div>
</label> </div>
<div className="mt-1">
<input {requiresTwoFactor && (
id="email" <div>
name="email" <label htmlFor="totp" className="block text-sm font-medium text-foreground">
type="email" Authenticator Code
autoComplete="email" </label>
required <div className="mt-1">
value={email} <input
onChange={(e) => setEmail(e.target.value)} id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all" placeholder="6-digit code" autoComplete="one-time-code" required
/> value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\D/g, ""))}
</div> className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
</div> />
</div>
<div> </div>
<label htmlFor="password" className="block text-sm font-medium text-foreground"> )}
Password
</label> <div>
<div className="mt-1"> <button
<input type="submit"
id="password" className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
name="password" >
type="password" {requiresTwoFactor ? "Verify" : "Sign in"}
autoComplete="current-password" </button>
required </div>
value={password}
onChange={(e) => setPassword(e.target.value)} <div className="text-center">
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all" <Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
/> Forgot your password?
</div> </Link>
</div> </div>
<div> {status && (
<button <div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
type="submit" <p className="text-sm font-medium text-center">{status}</p>
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5" </div>
> )}
Sign in </form>
</button> );
</div> }
</form>
export default function LoginPage() {
{status && ( const schema = [
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4"> {
<div className="flex"> "@context": "https://schema.org",
<div className="ml-3"> "@type": "WebPage",
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3> name: "LedgerOne Login",
</div> description: "Sign in to LedgerOne to access your audit-ready ledger.",
</div> url: `${siteInfo.url}/login`,
</div> },
)} {
</div> "@context": "https://schema.org",
</div> "@type": "FAQPage",
</div> mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
<SiteFooter /> name: item.question,
<PageSchema schema={schema} /> acceptedAnswer: { "@type": "Answer", text: item.answer },
</div> })),
); },
} ];
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Or{" "}
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
start your 14-day free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

@ -1,237 +1,237 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SiteFooter } from "../components/site-footer"; import { SiteFooter } from "../components/site-footer";
import { SiteHeader } from "../components/site-header"; import { SiteHeader } from "../components/site-header";
import { PageSchema } from "../components/page-schema"; import { PageSchema } from "../components/page-schema";
import { GrowthSimulator } from "../components/growth-simulator"; import { GrowthSimulator } from "../components/growth-simulator";
import { siteInfo } from "../data/site"; import { siteInfo } from "../data/site";
export const metadata = { export const metadata = {
title: "LedgerOne - The Financial Control Platform for Modern Business", title: "LedgerOne - The Financial Control Platform for Modern Business",
description: "Connect all your accounts, automate your bookkeeping, and get audit-ready financials in real-time.", description: "Connect all your accounts, automate your bookkeeping, and get audit-ready financials in real-time.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
export default function LandingPage() { export default function LandingPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
name: "LedgerOne", name: "LedgerOne",
url: siteInfo.url, url: siteInfo.url,
potentialAction: { potentialAction: {
"@type": "SearchAction", "@type": "SearchAction",
target: `${siteInfo.url}/search?q={search_term_string}`, target: `${siteInfo.url}/search?q={search_term_string}`,
"query-input": "required name=search_term_string" "query-input": "required name=search_term_string"
} }
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
name: "LedgerOne", name: "LedgerOne",
url: siteInfo.url, url: siteInfo.url,
logo: `${siteInfo.url}/logo.png`, logo: `${siteInfo.url}/logo.png`,
sameAs: [ sameAs: [
"https://twitter.com/ledgerone", "https://twitter.com/ledgerone",
"https://linkedin.com/company/ledgerone" "https://linkedin.com/company/ledgerone"
] ]
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1"> <main className="flex-1">
{/* Hero Section - Monarch Style Clean Split */} {/* Hero Section - Monarch Style Clean Split */}
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden"> <section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" /> <div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-16 items-center"> <div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Left Column: Copy */} {/* Left Column: Copy */}
<div className="max-w-2xl animate-slide-up"> <div className="max-w-2xl animate-slide-up">
<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 backdrop-blur-sm mb-8"> <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 backdrop-blur-sm mb-8">
<span className="flex h-2 w-2 rounded-full bg-primary"></span> <span className="flex h-2 w-2 rounded-full bg-primary"></span>
Now available for US & Canadian businesses Now available for US & Canadian businesses
</div> </div>
<h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight"> <h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight">
Master your money <br /> Master your money <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
with total clarity. with total clarity.
</span> </span>
</h1> </h1>
<p className="mt-6 text-lg text-muted-foreground mb-10"> <p className="mt-6 text-lg text-muted-foreground mb-10">
LedgerOne connects all your financial accounts in one place. Automate bookkeeping, track cash flow, and stay audit-ready without the spreadsheet chaos. LedgerOne connects all your financial accounts in one place. Automate bookkeeping, track cash flow, and stay audit-ready without the spreadsheet chaos.
</p> </p>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<Link <Link
href="/register" href="/register"
className="rounded-full bg-primary px-8 py-4 text-base font-bold text-primary-foreground shadow-lg shadow-primary/25 hover:bg-primary/90 hover:-translate-y-1 transition-all" className="rounded-full bg-primary px-8 py-4 text-base font-bold text-primary-foreground shadow-lg shadow-primary/25 hover:bg-primary/90 hover:-translate-y-1 transition-all"
> >
Start your free trial Start your free trial
</Link> </Link>
<Link <Link
href="/demo" href="/demo"
className="rounded-full bg-background border border-border px-8 py-4 text-base font-semibold text-foreground hover:bg-secondary transition-colors" className="rounded-full bg-background border border-border px-8 py-4 text-base font-semibold text-foreground hover:bg-secondary transition-colors"
> >
See how it works See how it works
</Link> </Link>
</div> </div>
</div> </div>
{/* Right Column: Composed Media Stack */} {/* Right Column: Composed Media Stack */}
<div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200"> <div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200">
{/* 1. Base Layer: Video (Desktop View) */} {/* 1. Base Layer: Video (Desktop View) */}
<div className="absolute top-0 right-0 w-[90%] h-[80%] rounded-2xl overflow-hidden border border-border shadow-2xl bg-background/50 backdrop-blur-xl z-10"> <div className="absolute top-0 right-0 w-[90%] h-[80%] rounded-2xl overflow-hidden border border-border shadow-2xl bg-background/50 backdrop-blur-xl z-10">
<video <video
poster="/images/hero_celebration_video_poster_1769386269277.png" poster="/images/hero_celebration_video_poster_1769386269277.png"
className="w-full h-full object-cover opacity-80" className="w-full h-full object-cover opacity-80"
autoPlay autoPlay
muted muted
loop loop
playsInline playsInline
> >
<source src="/videos/hero.mp4" type="video/mp4" /> <source src="/videos/hero.mp4" type="video/mp4" />
</video> </video>
{/* Overlay Gradient */} {/* Overlay Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div> <div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div>
</div> </div>
{/* 2. Middle Layer: Interactive Simulator (Floating Card) */} {/* 2. Middle Layer: Interactive Simulator (Floating Card) */}
<div className="absolute bottom-10 right-10 w-[320px] z-20 hidden xl:block"> <div className="absolute bottom-10 right-10 w-[320px] z-20 hidden xl:block">
<div className="bg-background/80 backdrop-blur-md border border-border rounded-2xl p-1 shadow-glass transform hover:scale-105 transition-transform duration-500"> <div className="bg-background/80 backdrop-blur-md border border-border rounded-2xl p-1 shadow-glass transform hover:scale-105 transition-transform duration-500">
<GrowthSimulator /> <GrowthSimulator />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Social Proof */} {/* Social Proof */}
<section className="py-12 border-y border-border bg-secondary/20"> <section className="py-12 border-y border-border bg-secondary/20">
<div className="max-w-7xl mx-auto px-6 lg:px-8 text-center"> <div className="max-w-7xl mx-auto px-6 lg:px-8 text-center">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8"> <p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8">
Trusted by forward-thinking finance teams Trusted by forward-thinking finance teams
</p> </p>
<div className="grid grid-cols-2 gap-8 md:grid-cols-5 opacity-60 grayscale hover:grayscale-0 transition-all duration-500"> <div className="grid grid-cols-2 gap-8 md:grid-cols-5 opacity-60 grayscale hover:grayscale-0 transition-all duration-500">
{/* Placeholders for logos */} {/* Placeholders for logos */}
<div className="flex items-center justify-center font-bold text-xl">Acme Corp</div> <div className="flex items-center justify-center font-bold text-xl">Acme Corp</div>
<div className="flex items-center justify-center font-bold text-xl">GlobalTech</div> <div className="flex items-center justify-center font-bold text-xl">GlobalTech</div>
<div className="flex items-center justify-center font-bold text-xl">Nebula</div> <div className="flex items-center justify-center font-bold text-xl">Nebula</div>
<div className="flex items-center justify-center font-bold text-xl">Vertex</div> <div className="flex items-center justify-center font-bold text-xl">Vertex</div>
<div className="flex items-center justify-center font-bold text-xl">Horizon</div> <div className="flex items-center justify-center font-bold text-xl">Horizon</div>
</div> </div>
</div> </div>
</section> </section>
{/* Feature Grid */} {/* Feature Grid */}
<section className="py-24 lg:py-32"> <section className="py-24 lg:py-32">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-20"> <div className="text-center max-w-3xl mx-auto mb-20">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"> <h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Everything you need to manage your wealth. Everything you need to manage your wealth.
</h2> </h2>
<p className="mt-4 text-lg text-muted-foreground"> <p className="mt-4 text-lg text-muted-foreground">
Stop logging into ten different sites. LedgerOne brings your entire financial life into a single, secure, and beautiful view. Stop logging into ten different sites. LedgerOne brings your entire financial life into a single, secure, and beautiful view.
</p> </p>
</div> </div>
<div className="grid gap-12 lg:grid-cols-3"> <div className="grid gap-12 lg:grid-cols-3">
{/* Feature 1: Connect */} {/* Feature 1: Connect */}
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group"> <div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform"> <div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div> </div>
<h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3> <h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically. Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically.
</p> </p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm"> <div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image <Image
src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop" src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop"
alt="Bank connections" alt="Bank connections"
width={400} width={400}
height={250} height={250}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
/> />
</div> </div>
</div> </div>
{/* Feature 2: Visualize */} {/* Feature 2: Visualize */}
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group"> <div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform"> <div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
</div> </div>
<h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3> <h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
See exactly where your money goes. Track income vs expenses with beautiful, interactive charts. See exactly where your money goes. Track income vs expenses with beautiful, interactive charts.
</p> </p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm"> <div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image <Image
src="/images/feature-cashflow.png" src="/images/feature-cashflow.png"
alt="Cash flow chart" alt="Cash flow chart"
width={400} width={400}
height={250} height={250}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
/> />
</div> </div>
</div> </div>
{/* Feature 3: Report */} {/* Feature 3: Report */}
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group"> <div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform"> <div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
</div> </div>
<h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3> <h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Build the exact report you need. Filter by category, tag, or merchant and export to CSV for your accountant. Build the exact report you need. Filter by category, tag, or merchant and export to CSV for your accountant.
</p> </p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm"> <div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image <Image
src="/images/feature-reports.png" src="/images/feature-reports.png"
alt="Report builder" alt="Report builder"
width={400} width={400}
height={250} height={250}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* CTA Section */} {/* CTA Section */}
<section className="py-24 relative overflow-hidden"> <section className="py-24 relative overflow-hidden">
<div className="absolute inset-0 bg-primary/5 -z-10" /> <div className="absolute inset-0 bg-primary/5 -z-10" />
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center"> <div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
<h2 className="text-4xl font-bold tracking-tight text-foreground mb-6"> <h2 className="text-4xl font-bold tracking-tight text-foreground mb-6">
Ready to take control? Ready to take control?
</h2> </h2>
<p className="text-xl text-muted-foreground mb-10"> <p className="text-xl text-muted-foreground mb-10">
Join thousands of business owners who trust LedgerOne for their financial clarity. Join thousands of business owners who trust LedgerOne for their financial clarity.
</p> </p>
<Link <Link
href="/register" href="/register"
className="inline-block rounded-full bg-primary px-10 py-4 text-lg font-bold text-primary-foreground shadow-xl shadow-primary/30 hover:bg-primary/90 hover:-translate-y-1 transition-all" className="inline-block rounded-full bg-primary px-10 py-4 text-lg font-bold text-primary-foreground shadow-xl shadow-primary/30 hover:bg-primary/90 hover:-translate-y-1 transition-all"
> >
Start your 14-day free trial Start your 14-day free trial
</Link> </Link>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
No credit card required. Cancel anytime. No credit card required. Cancel anytime.
</p> </p>
</div> </div>
</section> </section>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -1,178 +1,178 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ContactSection } from "../../components/contact-section"; import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta"; import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section"; import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema"; import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer"; import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header"; import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq"; import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; import { siteInfo } from "../../data/site";
export const metadata = { export const metadata = {
title: "Pricing", title: "Pricing",
description: description:
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.", "LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
const plans = [ const plans = [
{ {
name: "Starter", name: "Starter",
price: "Free", price: "Free",
tagline: "Best for getting your first ledger online.", tagline: "Best for getting your first ledger online.",
badge: "2 accounts", badge: "2 accounts",
features: [ features: [
"First two connected accounts", "First two connected accounts",
"Unlimited exports", "Unlimited exports",
"Rule engine access", "Rule engine access",
"Audit logs included" "Audit logs included"
], ],
cta: "Start free", cta: "Start free",
primary: false primary: false
}, },
{ {
name: "Unlimited", name: "Unlimited",
price: "$9", price: "$9",
tagline: "Scale your ledger without limits.", tagline: "Scale your ledger without limits.",
badge: "Unlimited accounts", badge: "Unlimited accounts",
features: [ features: [
"Unlimited connected accounts", "Unlimited connected accounts",
"Priority sync cadence", "Priority sync cadence",
"Advanced rule automation", "Advanced rule automation",
"Team-ready exports" "Team-ready exports"
], ],
cta: "Choose Unlimited", cta: "Choose Unlimited",
primary: true primary: true
} }
]; ];
const comparisons = [ const comparisons = [
{ label: "Connected accounts", starter: "2", pro: "Unlimited" }, { label: "Connected accounts", starter: "2", pro: "Unlimited" },
{ label: "Exports", starter: "Unlimited", pro: "Unlimited" }, { label: "Exports", starter: "Unlimited", pro: "Unlimited" },
{ label: "Rule engine", starter: "Core rules", pro: "Advanced rules" }, { label: "Rule engine", starter: "Core rules", pro: "Advanced rules" },
{ label: "Audit logs", starter: "Included", pro: "Included" }, { label: "Audit logs", starter: "Included", pro: "Included" },
{ label: "Support", starter: "Standard", pro: "Priority" } { label: "Support", starter: "Standard", pro: "Priority" }
]; ];
export default function PricingPage() { export default function PricingPage() {
const schema = [ const schema = [
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne Pricing", name: "LedgerOne Pricing",
description: description:
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.", "LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
url: `${siteInfo.url}/pricing` url: `${siteInfo.url}/pricing`
}, },
{ {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({ mainEntity: defaultFaqs.map((item) => ({
"@type": "Question", "@type": "Question",
name: item.question, name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer } acceptedAnswer: { "@type": "Answer", text: item.answer }
})) }))
} }
]; ];
return ( return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col"> <div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader /> <SiteHeader />
<main className="flex-1 pt-32 pb-24 relative overflow-hidden"> <main className="flex-1 pt-32 pb-24 relative overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" /> <div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up"> <div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl"> <h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
Simple, transparent pricing. Simple, transparent pricing.
</h1> </h1>
<p className="mt-6 text-lg text-muted-foreground"> <p className="mt-6 text-lg text-muted-foreground">
Start with the essentials and upgrade only when you need more accounts. Start with the essentials and upgrade only when you need more accounts.
Both plans include unlimited exports and audit logs. Both plans include unlimited exports and audit logs.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto"> <div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{plans.map((plan) => ( {plans.map((plan) => (
<div <div
key={plan.name} key={plan.name}
className={`rounded-2xl p-8 border transition-all ${plan.primary className={`rounded-2xl p-8 border transition-all ${plan.primary
? "border-primary bg-background shadow-glow-teal ring-1 ring-primary" ? "border-primary bg-background shadow-glow-teal ring-1 ring-primary"
: "border-border bg-background/50 shadow-sm hover:shadow-md glass-panel" : "border-border bg-background/50 shadow-sm hover:shadow-md glass-panel"
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3> <h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
{plan.primary && ( {plan.primary && (
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary"> <span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
Most Popular Most Popular
</span> </span>
)} )}
</div> </div>
<div className="mt-4 flex items-baseline text-foreground"> <div className="mt-4 flex items-baseline text-foreground">
<span className="text-4xl font-bold tracking-tight">{plan.price}</span> <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 !== "Free" && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
</div> </div>
<p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p> <p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p>
<ul className="mt-8 space-y-4"> <ul className="mt-8 space-y-4">
{plan.features.map((feature) => ( {plan.features.map((feature) => (
<li key={feature} className="flex items-start"> <li key={feature} className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<p className="ml-3 text-sm text-muted-foreground">{feature}</p> <p className="ml-3 text-sm text-muted-foreground">{feature}</p>
</li> </li>
))} ))}
</ul> </ul>
<Link <Link
href="/register" href="/register"
className={`mt-8 block w-full rounded-lg px-4 py-3 text-center text-sm font-semibold shadow-sm transition-all hover:-translate-y-0.5 ${plan.primary className={`mt-8 block w-full rounded-lg px-4 py-3 text-center text-sm font-semibold shadow-sm transition-all hover:-translate-y-0.5 ${plan.primary
? "bg-primary text-primary-foreground hover:bg-primary/90" ? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-background text-primary ring-1 ring-inset ring-primary/20 hover:ring-primary/40" : "bg-background text-primary ring-1 ring-inset ring-primary/20 hover:ring-primary/40"
}`} }`}
> >
{plan.cta} {plan.cta}
</Link> </Link>
</div> </div>
))} ))}
</div> </div>
<div className="mt-24 max-w-4xl mx-auto glass-panel rounded-xl p-1"> <div className="mt-24 max-w-4xl mx-auto glass-panel rounded-xl p-1">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Compare plans</h2> <h2 className="text-xl font-bold text-foreground">Compare plans</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border"> <table className="min-w-full divide-y divide-border">
<thead className="bg-secondary/30"> <thead className="bg-secondary/30">
<tr> <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">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">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">Unlimited</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-background divide-y divide-border"> <tbody className="bg-background divide-y divide-border">
{comparisons.map((item) => ( {comparisons.map((item) => (
<tr key={item.label}> <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 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.starter}</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.pro}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<SiteFooter /> <SiteFooter />
<PageSchema schema={schema} /> <PageSchema schema={schema} />
</div> </div>
); );
} }

View File

@ -2,84 +2,84 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = { type ApiResponse<T> = {
data: T; data: T;
meta: { timestamp: string; version: "v1" }; meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string }; error: null | { message: string; code?: string };
}; };
type ProfileData = { type ProfileData = {
user: { user: {
id: string; id: string;
email: string; email: string;
fullName: string; fullName: string;
phone?: string | null; phone?: string | null;
companyName?: string | null; companyName?: string | null;
addressLine1i: string | null; addressLine1i: string | null;
addressLine2i: string | null; addressLine2i: string | null;
city?: string | null; city?: string | null;
state?: string | null; state?: string | null;
postalCode?: string | null; postalCode?: string | null;
country?: string | null; country?: string | null;
}; };
}; };
export default function ProfilePage() { export default function ProfilePage() {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [fullName, setFullName] = useState(""); const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [addressLine1, setAddressLine1] = useState(""); const [addressLine1, setAddressLine1] = useState("");
const [addressLine2, setAddressLine2] = useState(""); const [addressLine2, setAddressLine2] = useState("");
const [city, setCity] = useState(""); const [city, setCity] = useState("");
const [state, setState] = useState(""); const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState(""); const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState(""); const [country, setCountry] = useState("");
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem("ledgerone_token") ?? ""; const stored = localStorage.getItem("ledgerone_token") ?? "";
setToken(stored); setToken(stored);
}, []); }, []);
const onSubmit = async (event: React.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!token) { if (!token) {
setStatus("Please sign in to update your profile."); setStatus("Please sign in to update your profile.");
return; return;
} }
setStatus("Saving profile..."); setStatus("Saving profile...");
try { try {
const res = await fetch("/api/auth/profile", { const res = await fetch("/api/auth/profile", {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
fullName, fullName,
phone: phone || undefined, phone: phone || undefined,
companyName: companyName || undefined, companyName: companyName || undefined,
addressLine1: addressLine1 || undefined, addressLine1: addressLine1 || undefined,
addressLine2: addressLine2 || undefined, addressLine2: addressLine2 || undefined,
city: city || undefined, city: city || undefined,
state: state || undefined, state: state || undefined,
postalCode: postalCode || undefined, postalCode: postalCode || undefined,
country: country || undefined country: country || undefined
}) })
}); });
const payload = (await res.json()) as ApiResponse<ProfileData>; const payload = (await res.json()) as ApiResponse<ProfileData>;
if (!res.ok || payload.error) { if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Profile update failed."); setStatus(payload.error?.message ?? "Profile update failed.");
return; return;
} }
setStatus("Profile updated."); setStatus("Profile updated.");
} catch { } catch {
setStatus("Profile update failed."); setStatus("Profile update failed.");
} }
}; };
return ( return (
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports."> <AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
<div className="app-card p-10"> <div className="app-card p-10">
@ -89,106 +89,106 @@ export default function ProfilePage() {
Add the details we need to personalize your ledger workspace. Add the details we need to personalize your ledger workspace.
</p> </p>
<form className="mt-8 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}> <form className="mt-8 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Full name Full name
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={fullName} value={fullName}
onChange={(event) => setFullName(event.target.value)} onChange={(event) => setFullName(event.target.value)}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">Phone</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">Phone</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="tel" type="tel"
value={phone} value={phone}
onChange={(event) => setPhone(event.target.value)} onChange={(event) => setPhone(event.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Company Company
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={companyName} value={companyName}
onChange={(event) => setCompanyName(event.target.value)} onChange={(event) => setCompanyName(event.target.value)}
/> />
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Address line 1 Address line 1
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={addressLine1} value={addressLine1}
onChange={(event) => setAddressLine1(event.target.value)} onChange={(event) => setAddressLine1(event.target.value)}
/> />
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Address line 2 Address line 2
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={addressLine2} value={addressLine2}
onChange={(event) => setAddressLine2(event.target.value)} onChange={(event) => setAddressLine2(event.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">City</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">City</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={city} value={city}
onChange={(event) => setCity(event.target.value)} onChange={(event) => setCity(event.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">State</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">State</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={state} value={state}
onChange={(event) => setState(event.target.value)} onChange={(event) => setState(event.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Postal code Postal code
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={postalCode} value={postalCode}
onChange={(event) => setPostalCode(event.target.value)} onChange={(event) => setPostalCode(event.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">Country</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">Country</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
value={country} value={country}
onChange={(event) => setCountry(event.target.value)} onChange={(event) => setCountry(event.target.value)}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<button <button
type="submit" type="submit"
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze" className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
> >
Save profile Save profile
</button> </button>
</div> </div>
</form> </form>
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null} {status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
</div> </div>

View File

@ -1,179 +1,168 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react"; import { useState, FormEvent } from "react";
import { ContactSection } from "../../components/contact-section"; import { PageSchema } from "../../components/page-schema";
import { DemoCta } from "../../components/demo-cta"; import { SiteFooter } from "../../components/site-footer";
import { FaqSection } from "../../components/faq-section"; import { SiteHeader } from "../../components/site-header";
import { PageSchema } from "../../components/page-schema"; import { defaultFaqs } from "../../data/faq";
import { SiteFooter } from "../../components/site-footer"; import { siteInfo } from "../../data/site";
import { SiteHeader } from "../../components/site-header"; import { storeAuthTokens } from "@/lib/api";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; type ApiResponse<T> = {
data: T;
type ApiResponse<T> = { meta: { timestamp: string; version: "v1" };
data: T; error: null | { message: string; code?: string };
meta: { timestamp: string; version: "v1" }; };
error: null | { message: string; code?: string };
}; type AuthData = {
user: { id: string; email: string; fullName?: string };
type AuthData = { user: { id: string; email: string }; token: string }; accessToken: string;
refreshToken: string;
export default function RegisterPage() { message?: string;
const router = useRouter(); };
const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); export default function RegisterPage() {
const [status, setStatus] = useState<string>(""); const router = useRouter();
const schema = [ const [email, setEmail] = useState("");
{ const [password, setPassword] = useState("");
"@context": "https://schema.org", const [status, setStatus] = useState<string>("");
"@type": "WebPage", const [isError, setIsError] = useState(false);
name: "LedgerOne Create Account",
description: "Create a LedgerOne account and start with two free accounts.", const schema = [
url: `${siteInfo.url}/register` {
}, "@context": "https://schema.org",
{ "@type": "WebPage",
"@context": "https://schema.org", name: "LedgerOne Create Account",
"@type": "FAQPage", description: "Create a LedgerOne account and start with two free accounts.",
mainEntity: defaultFaqs.map((item) => ({ url: `${siteInfo.url}/register`,
"@type": "Question", },
name: item.question, {
acceptedAnswer: { "@type": "Answer", text: item.answer } "@context": "https://schema.org",
})) "@type": "FAQPage",
} mainEntity: defaultFaqs.map((item) => ({
]; "@type": "Question",
name: item.question,
const onSubmit = async (event: FormEvent) => { acceptedAnswer: { "@type": "Answer", text: item.answer },
event.preventDefault(); })),
setStatus("Creating account..."); },
try { ];
const res = await fetch("/api/auth/register", {
method: "POST", const onSubmit = async (event: FormEvent) => {
headers: { "Content-Type": "application/json" }, event.preventDefault();
body: JSON.stringify({ email, password }) setStatus("Creating account...");
}); setIsError(false);
const payload = (await res.json()) as ApiResponse<AuthData>; try {
if (!res.ok || payload.error) { const res = await fetch("/api/auth/register", {
setStatus(payload.error?.message ?? "Registration failed."); method: "POST",
return; headers: { "Content-Type": "application/json" },
} body: JSON.stringify({ email, password }),
setStatus(`Welcome, ${payload.data.user.email}`); });
localStorage.setItem("ledgerone_token", payload.data.token); const payload = (await res.json()) as ApiResponse<AuthData>;
localStorage.setItem("ledgerone_user_id", payload.data.user.id); if (!res.ok || payload.error) {
router.push("/login"); setStatus(payload.error?.message ?? "Registration failed.");
} catch { setIsError(true);
setStatus("Registration failed."); return;
} }
}; storeAuthTokens({
accessToken: payload.data.accessToken,
return ( refreshToken: payload.data.refreshToken,
<div className="min-h-screen bg-background font-sans text-foreground"> user: payload.data.user,
<SiteHeader /> });
setStatus("Account created! Please verify your email.");
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative"> router.push("/app");
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" /> } catch {
setStatus("Registration failed. Please try again.");
<div className="sm:mx-auto sm:w-full sm:max-w-md"> setIsError(true);
<div className="flex justify-center"> }
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal"> };
L1
</div> return (
</div> <div className="min-h-screen bg-background font-sans text-foreground">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground"> <SiteHeader />
Create your account <div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
</h2> <div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<p className="mt-2 text-center text-sm text-muted-foreground"> <div className="sm:mx-auto sm:w-full sm:max-w-md">
Already have an account?{" "} <div className="flex justify-center">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors"> <div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
Sign in L1
</Link> </div>
</p> </div>
</div> <h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Create your account
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> </h2>
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10"> <p className="mt-2 text-center text-sm text-muted-foreground">
<form className="space-y-6" onSubmit={onSubmit}> Already have an account?{" "}
<div> <Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
<label htmlFor="email" className="block text-sm font-medium text-foreground"> Sign in
Email address </Link>
</label> </p>
<div className="mt-1"> </div>
<input <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
id="email" <div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
name="email" <form className="space-y-6" onSubmit={onSubmit}>
type="email" <div>
autoComplete="email" <label htmlFor="email" className="block text-sm font-medium text-foreground">
required Email address
value={email} </label>
onChange={(e) => setEmail(e.target.value)} <div className="mt-1">
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all" <input
/> id="email" name="email" type="email" autoComplete="email" required
</div> value={email} onChange={(e) => setEmail(e.target.value)}
</div> className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
<div> </div>
<label htmlFor="password" className="block text-sm font-medium text-foreground"> </div>
Password <div>
</label> <label htmlFor="password" className="block text-sm font-medium text-foreground">
<div className="mt-1"> Password
<input </label>
id="password" <div className="mt-1">
name="password" <input
type="password" id="password" name="password" type="password" autoComplete="new-password" required
autoComplete="new-password" minLength={8}
required value={password} onChange={(e) => setPassword(e.target.value)}
value={password} className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
onChange={(e) => setPassword(e.target.value)} />
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all" </div>
/> <p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div> </div>
</div> <div>
<div className="flex items-center gap-2 mb-6">
<div> <input
<div className="flex items-center gap-2 mb-6"> id="terms" name="terms" type="checkbox" required
<input className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
id="terms" onChange={(e) => {
name="terms" const btn = document.getElementById("submit-btn") as HTMLButtonElement;
type="checkbox" if (btn) btn.disabled = !e.target.checked;
required }}
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary" />
onChange={(e) => { <label htmlFor="terms" className="text-sm text-muted-foreground">
const btn = document.getElementById("submit-btn") as HTMLButtonElement; I agree to the{" "}
if (btn) btn.disabled = !e.target.checked; <Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
}} and{" "}
/> <Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
<label htmlFor="terms" className="text-sm text-muted-foreground"> </label>
I agree to the <Link href="/terms" className="text-primary hover:underline">Terms of Service</Link> and <Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link> </div>
</label> <button
</div> id="submit-btn" type="submit" disabled
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
<button >
id="submit-btn" Create account
type="submit" </button>
disabled </div>
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0" </form>
> {status && (
Create account <div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
</button> <p className="text-sm font-medium text-center">{status}</p>
</div> </div>
</form> )}
</div>
{status && ( </div>
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4"> </div>
<div className="flex"> <SiteFooter />
<div className="ml-3"> <PageSchema schema={schema} />
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3> </div>
</div> );
</div> }
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

147
app/reset-password/page.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirm) {
setStatus("Passwords do not match.");
setIsError(true);
return;
}
if (password.length < 8) {
setStatus("Password must be at least 8 characters.");
setIsError(true);
return;
}
if (!token) {
setStatus("Missing reset token. Please use the link from your email.");
setIsError(true);
return;
}
setStatus("Resetting password...");
setIsError(false);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
setIsError(true);
return;
}
setStatus("Password reset successfully! Redirecting to sign in...");
setTimeout(() => router.push("/login"), 2000);
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
</div>
);
}
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
New Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
Confirm Password
</label>
<div className="mt-1">
<input
id="confirm" name="confirm" type="password" autoComplete="new-password" required
value={confirm} onChange={(e) => setConfirm(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Reset Password
</button>
{status && (
<div className={`rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

220
app/settings/2fa/page.tsx Normal file
View File

@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
type UserData = { user: { twoFactorEnabled: boolean } };
export default function TwoFAPage() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [qrCode, setQrCode] = useState<string>("");
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
const [token, setToken] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
useEffect(() => {
apiFetch<UserData["user"]>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
// me returns { user: {...} }
const data = res.data as unknown as UserData;
setEnabled(data.user?.twoFactorEnabled ?? false);
}
})
.catch(() => {});
}, []);
const handleGenerate = async () => {
setStatus("Generating QR code...");
setIsError(false);
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
setIsError(true);
return;
}
setQrCode(res.data.qrCode);
setOtpAuthUrl(res.data.otpAuthUrl);
setStep("scan");
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
};
const handleEnable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code.");
setIsError(true);
return;
}
setStatus("Verifying...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
method: "POST",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Verification failed. Try again.");
setIsError(true);
return;
}
setEnabled(true);
setStep("done");
setStatus("Two-factor authentication is now active.");
};
const handleDisable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code to confirm.");
setIsError(true);
return;
}
setStatus("Disabling 2FA...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
method: "DELETE",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
setIsError(true);
return;
}
setEnabled(false);
setToken("");
setStep("idle");
setStatus("Two-factor authentication has been disabled.");
};
return (
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
<div className="max-w-lg">
<div className="glass-panel rounded-2xl p-8">
{enabled === null ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : enabled ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA is Active</p>
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
To disable two-factor authentication, enter the current code from your authenticator app.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required
/>
</div>
<button
type="submit"
className="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"
>
Disable 2FA
</button>
</form>
</>
) : step === "idle" ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
</p>
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Enable Two-Factor Auth
</button>
</>
) : step === "scan" ? (
<>
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
<p className="text-xs text-muted-foreground mb-4">
Open your authenticator app and scan the code below, or enter the key manually.
</p>
{qrCode && (
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
</div>
)}
{otpAuthUrl && (
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
)}
<form onSubmit={handleEnable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required autoFocus
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Verify and Enable
</button>
</form>
</>
) : (
<div className="text-center py-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<svg className="h-6 w-6 text-primary" 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-bold text-foreground">2FA Enabled Successfully</p>
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
</div>
)}
{status && (
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}

View File

@ -5,19 +5,24 @@ const settingsItems = [
{ {
title: "Profile", title: "Profile",
description: "Update company details, contact info, and onboarding fields.", description: "Update company details, contact info, and onboarding fields.",
href: "/settings/profile" href: "/settings/profile",
},
{
title: "Two-Factor Auth",
description: "Add a TOTP authenticator app for extra security.",
href: "/settings/2fa",
}, },
{ {
title: "Subscription", title: "Subscription",
description: "View plan details, upgrade options, and billing cadence.", description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription" href: "/settings/subscription",
} },
]; ];
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<AppShell title="Settings" subtitle="Account preferences and plan configuration."> <AppShell title="Settings" subtitle="Account preferences and plan configuration.">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsItems.map((item) => ( {settingsItems.map((item) => (
<Link <Link
key={item.title} key={item.title}

View File

@ -1,3 +1,149 @@
import ProfilePage from "../../profile/page"; "use client";
export default ProfilePage; import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ProfileData = {
user: {
id: string;
email: string;
fullName?: string | null;
phone?: string | null;
companyName?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
};
export default function ProfilePage() {
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [companyName, setCompanyName] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [addressLine2, setAddressLine2] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
useEffect(() => {
apiFetch<ProfileData>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
const u = (res.data as unknown as ProfileData).user;
if (u) {
setFullName(u.fullName ?? "");
setPhone(u.phone ?? "");
setCompanyName(u.companyName ?? "");
setAddressLine1(u.addressLine1 ?? "");
setAddressLine2(u.addressLine2 ?? "");
setCity(u.city ?? "");
setState(u.state ?? "");
setPostalCode(u.postalCode ?? "");
setCountry(u.country ?? "");
}
}
})
.catch(() => {});
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Saving profile...");
setIsError(false);
const res = await apiFetch<ProfileData>("/api/auth/profile", {
method: "PATCH",
body: JSON.stringify({
fullName: fullName || undefined,
phone: phone || undefined,
companyName: companyName || undefined,
addressLine1: addressLine1 || undefined,
addressLine2: addressLine2 || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Profile update failed.");
setIsError(true);
return;
}
setStatus("Profile saved successfully.");
};
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
return (
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
<div className="max-w-2xl">
<div className="glass-panel p-8 rounded-2xl">
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
<p className="mt-1 text-sm text-muted-foreground">
These details appear on tax exports and CSV reports.
</p>
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
<div className="md:col-span-2">
<label className={labelCls}>Full name</label>
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
</div>
<div>
<label className={labelCls}>Phone</label>
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className={labelCls}>Company</label>
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 1</label>
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 2</label>
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</div>
<div>
<label className={labelCls}>City</label>
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
</div>
<div>
<label className={labelCls}>State</label>
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
</div>
<div>
<label className={labelCls}>Postal code</label>
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</div>
<div>
<label className={labelCls}>Country</label>
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
</div>
<div className="md:col-span-2">
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Save profile
</button>
</div>
</form>
{status && (
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}

View File

@ -1,91 +1,164 @@
import { AppShell } from "../../../components/app-shell"; "use client";
const plans = [ import { useEffect, useState } from "react";
{ import { AppShell } from "../../../components/app-shell";
name: "Free", import { apiFetch } from "@/lib/api";
price: "$0",
cadence: "forever", type ApiResponse<T> = {
highlight: "Connect up to 2 accounts", data: T;
features: [ meta: { timestamp: string; version: "v1" };
"2 connected accounts", error: null | { message: string; code?: string };
"30-day transaction history", };
"Basic exports",
"Email support" type SubscriptionData = {
] plan?: string;
}, status?: string;
{ billingCycleAnchor?: number;
name: "Pro Monthly", cancelAtPeriodEnd?: boolean;
price: "$9", };
cadence: "per month",
highlight: "Unlimited connected accounts", const PLAN_LABELS: Record<string, string> = {
features: [ free: "Free",
"Unlimited accounts", pro: "Pro",
"12-month history", elite: "Elite",
"Advanced exports + rules", };
"Priority support"
] 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.",
name: "Pro Annual", elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
price: "$90", };
cadence: "per year",
highlight: "Two months free",
features: [
"Unlimited accounts",
"12-month history",
"Advanced exports + rules",
"Priority support"
]
}
];
export default function SubscriptionPage() { export default function SubscriptionPage() {
return ( const [sub, setSub] = useState<SubscriptionData | null>(null);
<AppShell title="Subscription" subtitle="Choose a plan that fits your team."> const [loading, setLoading] = useState(true);
<div className="app-card p-6"> const [actionStatus, setActionStatus] = useState("");
<div className="flex flex-wrap items-center justify-between gap-4"> const [actionLoading, setActionLoading] = useState(false);
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted">Current plan</p>
<h2 className="mt-2 text-2xl font-semibold">Free Plan</h2>
<p className="mt-2 text-sm text-muted">
Free includes up to two connected accounts. Upgrade any time to unlock
unlimited accounts and advanced automation.
</p>
</div>
<button
type="button"
className="app-button rounded-full px-5 py-2 text-xs font-semibold"
>
Start free trial
</button>
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-3"> useEffect(() => {
{plans.map((plan) => ( apiFetch<SubscriptionData>("/api/stripe/subscription")
<div key={plan.name} className="app-card p-6"> .then((res) => {
<p className="text-xs uppercase tracking-[0.3em] text-muted">{plan.name}</p> if (!res.error) setSub(res.data);
<div className="mt-3 flex items-baseline gap-2"> })
<span className="text-3xl font-semibold">{plan.price}</span> .catch(() => {})
<span className="text-xs text-muted">{plan.cadence}</span> .finally(() => setLoading(false));
</div> }, []);
<p className="mt-2 text-sm text-muted">{plan.highlight}</p>
<ul className="mt-4 space-y-2 text-xs text-muted"> const handleUpgrade = async (plan: string) => {
{plan.features.map((feature) => ( setActionLoading(true);
<li key={feature} className="flex items-center gap-2"> setActionStatus("Redirecting to checkout...");
<span className="h-2 w-2 rounded-full bg-emerald-400" /> const appUrl = typeof window !== "undefined" ? window.location.origin : "";
{feature} const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
</li> method: "POST",
))} body: JSON.stringify({
</ul> plan,
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
cancelUrl: `${appUrl}/settings/subscription`,
}),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const handlePortal = async () => {
setActionLoading(true);
setActionStatus("Redirecting to billing portal...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
method: "POST",
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const currentPlan = sub?.plan ?? "free";
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
return (
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
<div className="max-w-2xl 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>
{loading ? (
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
{sub.status}
</span>
)}
{(sub?.status === "active" || currentPlan !== "free") && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
{sub?.cancelAtPeriodEnd && (
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
)}
</>
)}
{!loading && currentPlan !== "free" && (
<button <button
type="button" onClick={handlePortal}
className="mt-6 w-full rounded-full border border-ink/10 bg-white/5 px-4 py-2 text-xs font-semibold text-ink" disabled={actionLoading}
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
> >
{plan.name === "Free" ? "Current plan" : "Upgrade"} Manage Billing
</button>
)}
</div>
{/* Upgrade options */}
{currentPlan === "free" && (
<div className="grid gap-4 md:grid-cols-2">
{(["pro", "elite"] as const).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>
<button
onClick={() => handleUpgrade(plan)}
disabled={actionLoading}
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]}
</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> </button>
</div> </div>
))} )}
{actionStatus && (
<p className="text-sm text-muted-foreground">{actionStatus}</p>
)}
</div> </div>
</AppShell> </AppShell>
); );

File diff suppressed because it is too large Load Diff

118
app/verify-email/page.tsx Normal file
View File

@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function VerifyEmailContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("No verification token provided.");
return;
}
fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
.then((payload) => {
if (payload.error) {
setStatus("error");
setMessage(payload.error.message ?? "Verification failed.");
} else {
setStatus("success");
setMessage(payload.data?.message ?? "Email verified successfully.");
}
})
.catch(() => {
setStatus("error");
setMessage("Something went wrong. Please try again.");
});
}, [token]);
return (
<div className="text-center space-y-4">
{status === "loading" && (
<>
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p className="text-sm text-muted-foreground">Verifying your email...</p>
</>
)}
{status === "success" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" 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-lg font-semibold text-foreground">Email Verified!</p>
<p className="text-sm text-muted-foreground">{message}</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Sign in
</Link>
</>
)}
{status === "error" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
<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-lg font-semibold text-foreground">Verification Failed</p>
<p className="text-sm text-muted-foreground">{message}</p>
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Back to sign in
</Link>
</>
)}
</div>
);
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Email Verification
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { CurrencyToggle, MoodToggle } from "./currency-toggle"; import { CurrencyToggle, MoodToggle } from "./currency-toggle";
import { apiFetch, clearAuth, getStoredUser } from "@/lib/api";
const navItems = [ const navItems = [
{ href: "/app", label: "Dashboard" }, { href: "/app", label: "Dashboard" },
@ -12,28 +13,107 @@ const navItems = [
{ href: "/rules", label: "Rules" }, { href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" }, { href: "/exports", label: "Exports" },
{ href: "/tax", label: "Tax" }, { href: "/tax", label: "Tax" },
{ href: "/settings", label: "Settings" } { href: "/settings", label: "Settings" },
]; ];
type User = {
id: string;
email: string;
fullName?: string | null;
emailVerified?: boolean;
twoFactorEnabled?: boolean;
};
type SubscriptionData = {
plan?: string;
status?: string;
};
type AppShellProps = { type AppShellProps = {
title: string; title: string;
subtitle?: string; subtitle?: string;
children: React.ReactNode; children: React.ReactNode;
}; };
function initials(user: User): string {
if (user.fullName) {
const parts = user.fullName.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return parts[0].slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
}
function displayName(user: User): string {
return user.fullName?.trim() || user.email;
}
export function AppShell({ title, subtitle, children }: AppShellProps) { export function AppShell({ title, subtitle, children }: AppShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [user, setUser] = useState<User | null>(getStoredUser<User>());
const [planLabel, setPlanLabel] = useState("Free Plan");
const onLogout = () => { useEffect(() => {
localStorage.removeItem("ledgerone_token"); // Fetch latest user profile
localStorage.removeItem("ledgerone_user_id"); apiFetch<{ user: User }>("/api/auth/me")
.then((res) => {
if (!res.error && res.data?.user) {
setUser(res.data.user);
}
})
.catch(() => {});
// Fetch subscription plan
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error && res.data?.plan) {
const p = res.data.plan;
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
}
})
.catch(() => {});
}, []);
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();
router.push("/login"); router.push("/login");
}; };
const NavLinks = () => (
<>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}`}
>
<span>{item.label}</span>
</Link>
);
})}
</>
);
return ( return (
<div className="min-h-screen bg-background flex font-sans text-foreground"> <div className="min-h-screen bg-background flex font-sans text-foreground">
{/* Sidebar */} {/* Desktop Sidebar */}
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex"> <aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
<div className="h-16 flex items-center px-6 border-b border-border"> <div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -48,32 +128,65 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
<div className="px-2 py-2 mb-2"> <div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div> </div>
{navItems.map((item) => { <NavLinks />
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}`}
>
{/* Icons could be added here */}
<span>{item.label}</span>
</Link>
);
})}
</div> </div>
<div className="mt-auto p-4 border-t border-border"> <div className="mt-auto p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2"> <div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium"> <div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
AC {user ? initials(user) : "?"}
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">Alex Chen</p> <p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">Pro Plan</p> <p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile Sidebar drawer */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-1 flex-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div> </div>
</div> </div>
</div> </div>
@ -81,31 +194,46 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
{/* Main Content */} {/* Main Content */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-6 lg:px-8"> <header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
<div> <div className="flex items-center gap-3">
{/* Breadcrumbs or Title */} {/* Hamburger — mobile only */}
<h1 className="text-lg font-semibold text-foreground">{title}</h1> <button
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={() => setMobileOpen(true)}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 lg:gap-4">
<div className="relative hidden md:block"> <div className="relative hidden md:block">
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
className="w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all" className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/> />
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg> <svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div> </div>
<CurrencyToggle /> <CurrencyToggle />
<MoodToggle /> <MoodToggle />
<button onClick={onLogout} className="text-sm font-medium text-muted-foreground hover:text-foreground"> <button
onClick={onLogout}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Log out Log out
</button> </button>
</div> </div>
</header> </header>
<main className="flex-1 p-6 lg:p-8 overflow-y-auto"> <main className="flex-1 p-4 lg:p-8 overflow-y-auto">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{children} {children}
</div> </div>

View File

@ -1,80 +1,80 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
export function ContactSection() { export function ContactSection() {
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const onSubmit = (event: React.FormEvent) => { const onSubmit = (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setStatus("Thanks! We will reach out within 1 business day."); setStatus("Thanks! We will reach out within 1 business day.");
}; };
return ( return (
<section className="mx-auto mt-16 max-w-6xl px-6"> <section className="mx-auto mt-16 max-w-6xl px-6">
<div className="rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft"> <div className="rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
<p className="text-xs uppercase tracking-[0.3em] text-muted">Contact us</p> <p className="text-xs uppercase tracking-[0.3em] text-muted">Contact us</p>
<h2 className="mt-3 text-2xl font-semibold">Let's talk about your ledger.</h2> <h2 className="mt-3 text-2xl font-semibold">Let's talk about your ledger.</h2>
<p className="mt-2 text-sm text-muted"> <p className="mt-2 text-sm text-muted">
Tell us about your workflow and we will suggest the best LedgerOne setup. Tell us about your workflow and we will suggest the best LedgerOne setup.
</p> </p>
<form className="mt-6 grid gap-4 md:grid-cols-2" onSubmit={onSubmit}> <form className="mt-6 grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Full name Full name
</label> </label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">Work email</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">Work email</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="email" type="email"
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted">Company</label> <label className="text-xs uppercase tracking-[0.2em] text-muted">Company</label>
<input <input
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
type="text" type="text"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
Monthly transactions Monthly transactions
</label> </label>
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"> <select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
<option>Under 1,000</option> <option>Under 1,000</option>
<option>1,000 - 10,000</option> <option>1,000 - 10,000</option>
<option>10,000 - 50,000</option> <option>10,000 - 50,000</option>
<option>50,000+</option> <option>50,000+</option>
</select> </select>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted"> <label className="text-xs uppercase tracking-[0.2em] text-muted">
What do you want to solve? What do you want to solve?
</label> </label>
<textarea <textarea
className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
required required
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<button <button
type="submit" type="submit"
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze" className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
> >
Send message Send message
</button> </button>
</div> </div>
</form> </form>
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null} {status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
</div> </div>
</section> </section>
); );
} }

View File

@ -1,58 +1,58 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function CurrencyToggle() { export function CurrencyToggle() {
const [currency, setCurrency] = useState<"USD" | "CAD">("USD"); const [currency, setCurrency] = useState<"USD" | "CAD">("USD");
const toggle = () => { const toggle = () => {
setCurrency((prev) => (prev === "USD" ? "CAD" : "USD")); setCurrency((prev) => (prev === "USD" ? "CAD" : "USD"));
// In a real app, this would update a context or store // In a real app, this would update a context or store
document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'"); document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'");
}; };
return ( return (
<button <button
onClick={toggle} onClick={toggle}
className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-muted" className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-muted"
> >
<span className={currency === "USD" ? "text-primary font-bold" : "text-muted-foreground"}>USD</span> <span className={currency === "USD" ? "text-primary font-bold" : "text-muted-foreground"}>USD</span>
<span className="h-3 w-[1px] bg-muted-foreground/20" /> <span className="h-3 w-[1px] bg-muted-foreground/20" />
<span className={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span> <span className={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span>
</button> </button>
); );
} }
export function MoodToggle() { export function MoodToggle() {
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
useEffect(() => { useEffect(() => {
if (document.documentElement.classList.contains("dark")) { if (document.documentElement.classList.contains("dark")) {
setIsDark(true); setIsDark(true);
} }
}, []); }, []);
const toggle = () => { const toggle = () => {
const next = !isDark; const next = !isDark;
setIsDark(next); setIsDark(next);
if (next) { if (next) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
} else { } else {
document.documentElement.classList.remove("dark"); document.documentElement.classList.remove("dark");
} }
}; };
return ( return (
<button <button
onClick={toggle} onClick={toggle}
className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors" className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
title="Toggle Mood Mode" title="Toggle Mood Mode"
> >
{isDark ? ( {isDark ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
) : ( ) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
)} )}
</button> </button>
); );
} }

View File

@ -1,56 +1,56 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
type ApiResponse<T> = { type ApiResponse<T> = {
data: T; data: T;
meta: { timestamp: string; version: "v1" }; meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string }; error: null | { message: string; code?: string };
}; };
type ExportData = { status?: string; url?: string; csv?: string }; type ExportData = { status?: string; url?: string; csv?: string };
async function getJson<T>(path: string) { async function getJson<T>(path: string) {
const res = await fetch(path); const res = await fetch(path);
if (!res.ok) { if (!res.ok) {
throw new Error("Request failed"); throw new Error("Request failed");
} }
const payload = (await res.json()) as ApiResponse<T>; const payload = (await res.json()) as ApiResponse<T>;
return payload.data; return payload.data;
} }
export function ExportDownloadButton() { export function ExportDownloadButton() {
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const onDownload = async () => { const onDownload = async () => {
setStatus("Building export..."); setStatus("Building export...");
try { try {
const userId = localStorage.getItem("ledgerone_user_id"); const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
const data = await getJson<ExportData>(`/api/exports/csv${query}`); const data = await getJson<ExportData>(`/api/exports/csv${query}`);
if (data.csv) { if (data.csv) {
const blob = new Blob([data.csv], { type: "text/csv" }); const blob = new Blob([data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
setStatus("Download opened."); setStatus("Download opened.");
} else { } else {
setStatus("Export ready."); setStatus("Export ready.");
} }
} catch { } catch {
setStatus("Unable to download export."); setStatus("Unable to download export.");
} }
}; };
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<button <button
type="button" type="button"
onClick={onDownload} onClick={onDownload}
className="w-full rounded-xl bg-ink px-4 py-2 text-sm font-semibold text-haze" className="w-full rounded-xl bg-ink px-4 py-2 text-sm font-semibold text-haze"
> >
Download export Download export
</button> </button>
{status ? <p className="text-xs text-muted">{status}</p> : null} {status ? <p className="text-xs text-muted">{status}</p> : null}
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import { defaultFaqs } from "../data/faq"; import { defaultFaqs } from "../data/faq";
type FaqSectionProps = { type FaqSectionProps = {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
@ -19,17 +19,17 @@ export function FaqSection({ title, subtitle, limit }: FaqSectionProps) {
{subtitle ?? {subtitle ??
"Find pricing, account, export, and security answers for LedgerOne."} "Find pricing, account, export, and security answers for LedgerOne."}
</p> </p>
<ol className="mt-6 space-y-5 text-sm text-muted"> <ol className="mt-6 space-y-5 text-sm text-muted">
{items.map((item, index) => ( {items.map((item, index) => (
<li key={item.question} className="space-y-2"> <li key={item.question} className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-moss"> <p className="text-xs uppercase tracking-[0.2em] text-moss">
{index + 1}. {item.question} {index + 1}. {item.question}
</p> </p>
<p>{item.answer}</p> <p>{item.answer}</p>
</li> </li>
))} ))}
</ol> </ol>
</div> </div>
</section> </section>
); );
} }

View File

@ -1,111 +1,111 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const data = [ const data = [
{ year: "2026", savings: 12000, investing: 12000 }, { year: "2026", savings: 12000, investing: 12000 },
{ year: "2027", savings: 24000, investing: 25400 }, { year: "2027", savings: 24000, investing: 25400 },
{ year: "2028", savings: 36000, investing: 40100 }, { year: "2028", savings: 36000, investing: 40100 },
{ year: "2029", savings: 48000, investing: 56500 }, { year: "2029", savings: 48000, investing: 56500 },
{ year: "2030", savings: 60000, investing: 74800 }, { year: "2030", savings: 60000, investing: 74800 },
{ year: "2031", savings: 72000, investing: 95200 }, { year: "2031", savings: 72000, investing: 95200 },
{ year: "2032", savings: 84000, investing: 118000 }, { year: "2032", savings: 84000, investing: 118000 },
]; ];
export function GrowthSimulator() { export function GrowthSimulator() {
const [monthlyContribution, setMonthlyContribution] = useState(1000); const [monthlyContribution, setMonthlyContribution] = useState(1000);
const [mode, setMode] = useState<"savings" | "investing">("investing"); const [mode, setMode] = useState<"savings" | "investing">("investing");
return ( return (
<div className="glass-panel rounded-3xl p-8 shadow-glass"> <div className="glass-panel rounded-3xl p-8 shadow-glass">
<div className="flex flex-wrap items-center justify-between gap-6 mb-8"> <div className="flex flex-wrap items-center justify-between gap-6 mb-8">
<div> <div>
<h3 className="text-xl font-bold text-foreground">Projected Growth</h3> <h3 className="text-xl font-bold text-foreground">Projected Growth</h3>
<p className="text-sm text-muted-foreground">Simulate your wealth accumulation over 6 years.</p> <p className="text-sm text-muted-foreground">Simulate your wealth accumulation over 6 years.</p>
</div> </div>
<div className="flex items-center gap-2 bg-secondary rounded-full p-1"> <div className="flex items-center gap-2 bg-secondary rounded-full p-1">
<button <button
onClick={() => setMode("savings")} onClick={() => setMode("savings")}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "savings" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground" className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "savings" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Savings Savings
</button> </button>
<button <button
onClick={() => setMode("investing")} onClick={() => setMode("investing")}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "investing" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground" className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "investing" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Investing Investing
</button> </button>
</div> </div>
</div> </div>
<div className="h-[300px] w-full"> <div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}> <AreaChart data={data}>
<defs> <defs>
<linearGradient id="colorGrowth" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="colorGrowth" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.3} /> <stop offset="5%" stopColor="var(--primary)" stopOpacity={0.3} />
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0} /> <stop offset="95%" stopColor="var(--primary)" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
<XAxis <XAxis
dataKey="year" dataKey="year"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
dy={10} dy={10}
/> />
<YAxis <YAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
tickFormatter={(value) => `$${value / 1000}k`} tickFormatter={(value) => `$${value / 1000}k`}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: "var(--background)", backgroundColor: "var(--background)",
borderColor: "var(--border)", borderColor: "var(--border)",
borderRadius: "12px", borderRadius: "12px",
boxShadow: "0 4px 12px rgba(0,0,0,0.1)" boxShadow: "0 4px 12px rgba(0,0,0,0.1)"
}} }}
itemStyle={{ color: "var(--foreground)" }} itemStyle={{ color: "var(--foreground)" }}
/> />
<Area <Area
type="monotone" type="monotone"
dataKey={mode} dataKey={mode}
stroke="var(--primary)" stroke="var(--primary)"
strokeWidth={3} strokeWidth={3}
fillOpacity={1} fillOpacity={1}
fill="url(#colorGrowth)" fill="url(#colorGrowth)"
animationDuration={1500} animationDuration={1500}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Monthly Contribution</span> <span className="text-muted-foreground">Monthly Contribution</span>
<span className="font-bold text-foreground">${monthlyContribution}</span> <span className="font-bold text-foreground">${monthlyContribution}</span>
</div> </div>
<input <input
type="range" type="range"
min="100" min="100"
max="5000" max="5000"
step="100" step="100"
value={monthlyContribution} value={monthlyContribution}
onChange={(e) => setMonthlyContribution(Number(e.target.value))} onChange={(e) => setMonthlyContribution(Number(e.target.value))}
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>$100</span> <span>$100</span>
<span>$5,000</span> <span>$5,000</span>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,91 +1,91 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
type ApiResponse<T> = { type ApiResponse<T> = {
data: T; data: T;
meta: { timestamp: string; version: "v1" }; meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string }; error: null | { message: string; code?: string };
}; };
type LinkTokenData = { linkToken?: string }; type LinkTokenData = { linkToken?: string };
type ExportData = { status?: string; url?: string; csv?: string }; type ExportData = { status?: string; url?: string; csv?: string };
async function postJson<T>(path: string) { async function postJson<T>(path: string) {
const res = await fetch(path, { const res = await fetch(path, {
method: "POST" method: "POST"
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Request failed"); throw new Error("Request failed");
} }
const payload = (await res.json()) as ApiResponse<T>; const payload = (await res.json()) as ApiResponse<T>;
return payload.data; return payload.data;
} }
async function getJson<T>(path: string) { async function getJson<T>(path: string) {
const res = await fetch(path); const res = await fetch(path);
if (!res.ok) { if (!res.ok) {
throw new Error("Request failed"); throw new Error("Request failed");
} }
const payload = (await res.json()) as ApiResponse<T>; const payload = (await res.json()) as ApiResponse<T>;
return payload.data; return payload.data;
} }
export function HeroActions() { export function HeroActions() {
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const onStart = async () => { const onStart = async () => {
setStatus("Requesting a secure link token..."); setStatus("Requesting a secure link token...");
try { try {
const data = await postJson<LinkTokenData>("/api/accounts/link"); const data = await postJson<LinkTokenData>("/api/accounts/link");
if (data.linkToken) { if (data.linkToken) {
setStatus(`Link token ready: ${data.linkToken}`); setStatus(`Link token ready: ${data.linkToken}`);
} else { } else {
setStatus("Link token requested."); setStatus("Link token requested.");
} }
} catch { } catch {
setStatus("Unable to request link token."); setStatus("Unable to request link token.");
} }
}; };
const onViewExport = async () => { const onViewExport = async () => {
setStatus("Preparing export sample..."); setStatus("Preparing export sample...");
try { try {
const userId = localStorage.getItem("ledgerone_user_id"); const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
const data = await getJson<ExportData>(`/api/exports/csv${query}`); const data = await getJson<ExportData>(`/api/exports/csv${query}`);
if (data.csv) { if (data.csv) {
const blob = new Blob([data.csv], { type: "text/csv" }); const blob = new Blob([data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
setStatus("Export sample opened."); setStatus("Export sample opened.");
} else { } else {
setStatus("Export sample ready."); setStatus("Export sample ready.");
} }
} catch { } catch {
setStatus("Unable to fetch export sample."); setStatus("Unable to fetch export sample.");
} }
}; };
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<button <button
type="button" type="button"
onClick={onStart} onClick={onStart}
className="rounded-full bg-ink px-6 py-3 text-sm font-semibold text-haze shadow-glow" className="rounded-full bg-ink px-6 py-3 text-sm font-semibold text-haze shadow-glow"
> >
Start a private ledger Start a private ledger
</button> </button>
<button <button
type="button" type="button"
onClick={onViewExport} onClick={onViewExport}
className="rounded-full border border-ink/20 bg-white/70 px-6 py-3 text-sm font-semibold text-ink" className="rounded-full border border-ink/20 bg-white/70 px-6 py-3 text-sm font-semibold text-ink"
> >
View export sample View export sample
</button> </button>
</div> </div>
{status ? <p className="text-xs text-muted">{status}</p> : null} {status ? <p className="text-xs text-muted">{status}</p> : null}
</div> </div>
); );
} }

View File

@ -1,13 +1,13 @@
type SchemaProps = { type SchemaProps = {
schema: Record<string, unknown> | Array<Record<string, unknown>>; schema: Record<string, unknown> | Array<Record<string, unknown>>;
}; };
export function PageSchema({ schema }: SchemaProps) { export function PageSchema({ schema }: SchemaProps) {
const payload = Array.isArray(schema) ? schema : [schema]; const payload = Array.isArray(schema) ? schema : [schema];
return ( return (
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }}
/> />
); );
} }

115
lib/api.ts Normal file
View File

@ -0,0 +1,115 @@
// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
export interface ApiResponse<T = unknown> {
data: T;
meta: { timestamp: string; version: "v1" };
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) ?? "";
}
export function getStoredUser<T = unknown>(): T | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function storeAuthTokens(data: {
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;
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;
}
const payload = (await res.json()) as ApiResponse<{
accessToken: string;
refreshToken: string;
}>;
if (payload.error || !payload.data?.accessToken) {
clearAuth();
return null;
}
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
return payload.data.accessToken;
} catch {
clearAuth();
return null;
}
}
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" &&
!headers["Content-Type"]
) {
headers["Content-Type"] = "application/json";
}
let res = await fetch(path, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401) {
const newToken = await tryRefresh();
if (newToken) {
headers["Authorization"] = `Bearer ${newToken}`;
res = await fetch(path, { ...options, headers });
} else {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return {
data: null as T,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Session expired. Please sign in again." },
};
}
}
return res.json() as Promise<ApiResponse<T>>;
}

73
lib/backend.ts Normal file
View File

@ -0,0 +1,73 @@
// Server-side proxy helper for Next.js API routes.
// Forwards requests to the NestJS backend, including the Bearer token.
import { NextRequest, NextResponse } from "next/server";
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
export function getBackendUrl(path: string): string {
return `${BASE_URL}/api/${path.replace(/^\//, "")}`;
}
interface ProxyOptions {
method?: string;
body?: BodyInit | null;
extraHeaders?: Record<string, string>;
search?: string;
}
export async function proxyRequest(
req: NextRequest,
backendPath: string,
options: ProxyOptions = {}
): Promise<NextResponse> {
const url = new URL(req.url);
const search = options.search !== undefined ? options.search : url.search;
const targetUrl = `${getBackendUrl(backendPath)}${search}`;
const method = options.method ?? req.method;
const auth = req.headers.get("authorization") ?? "";
const contentType = req.headers.get("content-type") ?? "";
const headers: Record<string, string> = { ...options.extraHeaders };
if (auth) headers["Authorization"] = auth;
let body: BodyInit | null | undefined = undefined;
if (method !== "GET" && method !== "HEAD") {
if (options.body !== undefined) {
body = options.body;
if (contentType) headers["Content-Type"] = contentType;
} else if (contentType.includes("multipart/form-data")) {
body = await req.formData();
// Do not set Content-Type — fetch sets it with boundary automatically
} else {
body = await req.text();
if (contentType) headers["Content-Type"] = contentType;
}
}
try {
const res = await fetch(targetUrl, {
method,
headers,
body: body ?? undefined,
});
const payload = await res.text();
return new NextResponse(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json",
},
});
} catch {
return NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." },
},
{ status: 503 }
);
}
}

38
middleware.ts Normal file
View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
const PROTECTED_PREFIXES = [
"/app",
"/transactions",
"/rules",
"/exports",
"/tax",
"/settings",
"/profile",
];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
if (!isProtected) return NextResponse.next();
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
const authCookie = req.cookies.get("ledgerone_auth");
if (!authCookie?.value) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Run on all routes except Next.js internals and static files
"/((?!_next/static|_next/image|favicon\.ico|api/).*)",
],
};

1
package-lock.json generated
View File

@ -1433,6 +1433,7 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,

View File

@ -21,7 +21,10 @@
{ {
"name": "next" "name": "next"
} }
] ],
"paths": {
"@/*": ["./*"]
}
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
@ -32,4 +35,4 @@
"exclude": [ "exclude": [
"node_modules" "node_modules"
] ]
} }

240
write-frontend-1-lib.mjs Normal file
View File

@ -0,0 +1,240 @@
import { writeFileSync, mkdirSync } from "fs";
mkdirSync("lib", { recursive: true });
// ─── lib/api.ts ─────────────────────────────────────────────────────────────
writeFileSync("lib/api.ts", `// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
export interface ApiResponse<T = unknown> {
data: T;
meta: { timestamp: string; version: "v1" };
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) ?? "";
}
export function getStoredUser<T = unknown>(): T | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function storeAuthTokens(data: {
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;
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;
}
const payload = (await res.json()) as ApiResponse<{
accessToken: string;
refreshToken: string;
}>;
if (payload.error || !payload.data?.accessToken) {
clearAuth();
return null;
}
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
return payload.data.accessToken;
} catch {
clearAuth();
return null;
}
}
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" &&
!headers["Content-Type"]
) {
headers["Content-Type"] = "application/json";
}
let res = await fetch(path, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401) {
const newToken = await tryRefresh();
if (newToken) {
headers["Authorization"] = \`Bearer \${newToken}\`;
res = await fetch(path, { ...options, headers });
} else {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return {
data: null as T,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Session expired. Please sign in again." },
};
}
}
return res.json() as Promise<ApiResponse<T>>;
}
`);
// ─── lib/backend.ts ──────────────────────────────────────────────────────────
writeFileSync("lib/backend.ts", `// Server-side proxy helper for Next.js API routes.
// Forwards requests to the NestJS backend, including the Bearer token.
import { NextRequest, NextResponse } from "next/server";
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
export function getBackendUrl(path: string): string {
return \`\${BASE_URL}/api/\${path.replace(/^\\//, "")}\`;
}
interface ProxyOptions {
method?: string;
body?: BodyInit | null;
extraHeaders?: Record<string, string>;
search?: string;
}
export async function proxyRequest(
req: NextRequest,
backendPath: string,
options: ProxyOptions = {}
): Promise<NextResponse> {
const url = new URL(req.url);
const search = options.search !== undefined ? options.search : url.search;
const targetUrl = \`\${getBackendUrl(backendPath)}\${search}\`;
const method = options.method ?? req.method;
const auth = req.headers.get("authorization") ?? "";
const contentType = req.headers.get("content-type") ?? "";
const headers: Record<string, string> = { ...options.extraHeaders };
if (auth) headers["Authorization"] = auth;
let body: BodyInit | null | undefined = undefined;
if (method !== "GET" && method !== "HEAD") {
if (options.body !== undefined) {
body = options.body;
if (contentType) headers["Content-Type"] = contentType;
} else if (contentType.includes("multipart/form-data")) {
body = await req.formData();
// Do not set Content-Type — fetch sets it with boundary automatically
} else {
body = await req.text();
if (contentType) headers["Content-Type"] = contentType;
}
}
try {
const res = await fetch(targetUrl, {
method,
headers,
body: body ?? undefined,
});
const payload = await res.text();
return new NextResponse(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json",
},
});
} catch {
return NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." },
},
{ status: 503 }
);
}
}
`);
// ─── middleware.ts ───────────────────────────────────────────────────────────
writeFileSync("middleware.ts", `import { NextRequest, NextResponse } from "next/server";
const PROTECTED_PREFIXES = [
"/app",
"/transactions",
"/rules",
"/exports",
"/tax",
"/settings",
"/profile",
];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(\`\${prefix}/\`)
);
if (!isProtected) return NextResponse.next();
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
const authCookie = req.cookies.get("ledgerone_auth");
if (!authCookie?.value) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Run on all routes except Next.js internals and static files
"/((?!_next/static|_next/image|favicon\\.ico|api/).*)",
],
};
`);
console.log("✅ lib/api.ts, lib/backend.ts, middleware.ts written");

764
write-frontend-2-auth.mjs Normal file
View File

@ -0,0 +1,764 @@
import { writeFileSync, mkdirSync } from "fs";
// ─── Updated login/page.tsx ──────────────────────────────────────────────────
writeFileSync("app/login/page.tsx", `"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
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";
import { storeAuthTokens } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
accessToken: string;
refreshToken: string;
requiresTwoFactor?: boolean;
};
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") ?? "/app";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpToken, setTotpToken] = useState("");
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Signing in...");
setIsError(false);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Login failed.");
setIsError(true);
return;
}
if (payload.data.requiresTwoFactor) {
setRequiresTwoFactor(true);
setStatus("Enter the code from your authenticator app.");
return;
}
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus(\`Welcome back, \${payload.data.user.email}\`);
router.push(nextPath);
} catch {
setStatus("Login failed. Please try again.");
setIsError(true);
}
};
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
{requiresTwoFactor && (
<div>
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
Authenticator Code
</label>
<div className="mt-1">
<input
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
placeholder="6-digit code" autoComplete="one-time-code" required
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
/>
</div>
</div>
)}
<div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
{requiresTwoFactor ? "Verify" : "Sign in"}
</button>
</div>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Forgot your password?
</Link>
</div>
{status && (
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</form>
);
}
export default function LoginPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Login",
description: "Sign in to LedgerOne to access your audit-ready ledger.",
url: \`\${siteInfo.url}/login\`,
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Or{" "}
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
start your 14-day free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
`);
// ─── Updated register/page.tsx ───────────────────────────────────────────────
writeFileSync("app/register/page.tsx", `"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react";
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";
import { storeAuthTokens } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = {
user: { id: string; email: string; fullName?: string };
accessToken: string;
refreshToken: string;
message?: string;
};
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Create Account",
description: "Create a LedgerOne account and start with two free accounts.",
url: \`\${siteInfo.url}/register\`,
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("Creating account...");
setIsError(false);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Registration failed.");
setIsError(true);
return;
}
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus("Account created! Please verify your email.");
router.push("/app");
} catch {
setStatus("Registration failed. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required
minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<div className="flex items-center gap-2 mb-6">
<input
id="terms" name="terms" type="checkbox" required
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
onChange={(e) => {
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
if (btn) btn.disabled = !e.target.checked;
}}
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
and{" "}
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
</label>
</div>
<button
id="submit-btn" type="submit" disabled
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
Create account
</button>
</div>
</form>
{status && (
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
`);
// ─── New forgot-password/page.tsx ────────────────────────────────────────────
mkdirSync("app/forgot-password", { recursive: true });
writeFileSync("app/forgot-password/page.tsx", `"use client";
import Link from "next/link";
import { useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("");
const [sent, setSent] = useState(false);
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Sending reset link...");
setIsError(false);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok && payload.error) {
setStatus(payload.error.message ?? "Something went wrong.");
setIsError(true);
return;
}
setSent(true);
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
{sent ? (
<div className="text-center space-y-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm text-foreground font-medium">{status}</p>
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
Back to sign in
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<p className="mt-1 text-xs text-muted-foreground">
We&#39;ll send a reset link to this address if it has an account.
</p>
<div className="mt-2">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Send reset link
</button>
{isError && status && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
)}
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
// ─── New verify-email/page.tsx ───────────────────────────────────────────────
mkdirSync("app/verify-email", { recursive: true });
writeFileSync("app/verify-email/page.tsx", `"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function VerifyEmailContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("No verification token provided.");
return;
}
fetch(\`/api/auth/verify-email?token=\${encodeURIComponent(token)}\`)
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
.then((payload) => {
if (payload.error) {
setStatus("error");
setMessage(payload.error.message ?? "Verification failed.");
} else {
setStatus("success");
setMessage(payload.data?.message ?? "Email verified successfully.");
}
})
.catch(() => {
setStatus("error");
setMessage("Something went wrong. Please try again.");
});
}, [token]);
return (
<div className="text-center space-y-4">
{status === "loading" && (
<>
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p className="text-sm text-muted-foreground">Verifying your email...</p>
</>
)}
{status === "success" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" 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-lg font-semibold text-foreground">Email Verified!</p>
<p className="text-sm text-muted-foreground">{message}</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Sign in
</Link>
</>
)}
{status === "error" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
<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-lg font-semibold text-foreground">Verification Failed</p>
<p className="text-sm text-muted-foreground">{message}</p>
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Back to sign in
</Link>
</>
)}
</div>
);
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Email Verification
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
// ─── New reset-password/page.tsx ─────────────────────────────────────────────
mkdirSync("app/reset-password", { recursive: true });
writeFileSync("app/reset-password/page.tsx", `"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirm) {
setStatus("Passwords do not match.");
setIsError(true);
return;
}
if (password.length < 8) {
setStatus("Password must be at least 8 characters.");
setIsError(true);
return;
}
if (!token) {
setStatus("Missing reset token. Please use the link from your email.");
setIsError(true);
return;
}
setStatus("Resetting password...");
setIsError(false);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
setIsError(true);
return;
}
setStatus("Password reset successfully! Redirecting to sign in...");
setTimeout(() => router.push("/login"), 2000);
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
</div>
);
}
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
New Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
Confirm Password
</label>
<div className="mt-1">
<input
id="confirm" name="confirm" type="password" autoComplete="new-password" required
value={confirm} onChange={(e) => setConfirm(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Reset Password
</button>
{status && (
<div className={\`rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
console.log("✅ login, register, forgot-password, verify-email, reset-password pages written");

View File

@ -0,0 +1,370 @@
import { writeFileSync, mkdirSync } from "fs";
// Helper: write a simple proxy route file
function writeProxy(path, content) {
mkdirSync(path.replace(/\/[^/]+$/, ""), { recursive: true });
writeFileSync(path, content);
}
// Reusable proxy template using proxyRequest
const proxyTemplate = (methods, backendPath, opts = "") => {
const lines = [];
lines.push(`import { NextRequest } from "next/server";`);
lines.push(`import { proxyRequest } from "../../../../lib/backend";`);
lines.push(``);
for (const m of methods) {
lines.push(`export async function ${m}(req: NextRequest) {`);
lines.push(` return proxyRequest(req, "${backendPath}"${opts ? `, ${opts}` : ""});`);
lines.push(`}`);
lines.push(``);
}
return lines.join("\n");
};
// ─── Updated existing proxies ────────────────────────────────────────────────
// auth/login — public, no bearer needed
writeProxy("app/api/auth/login/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
}
`);
// auth/register — public
writeProxy("app/api/auth/register/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
}
`);
// auth/profile — needs bearer
writeProxy("app/api/auth/profile/route.ts", `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");
}
`);
// transactions/route.ts
writeProxy("app/api/transactions/route.ts", `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");
}
`);
// transactions/summary
writeProxy("app/api/transactions/summary/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/summary");
}
`);
// transactions/cashflow
writeProxy("app/api/transactions/cashflow/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/cashflow");
}
`);
// transactions/merchants
writeProxy("app/api/transactions/merchants/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/merchants");
}
`);
// transactions/sync
writeProxy("app/api/transactions/sync/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/sync");
}
`);
// transactions/manual
writeProxy("app/api/transactions/manual/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/manual");
}
`);
// transactions/[id]/derived
writeProxy("app/api/transactions/[id]/derived/route.ts", `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\`);
}
`);
// accounts/route.ts
writeProxy("app/api/accounts/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts");
}
`);
// accounts/link
writeProxy("app/api/accounts/link/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts/link-token");
}
`);
// accounts/manual
writeProxy("app/api/accounts/manual/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/manual");
}
`);
// rules/route.ts
writeProxy("app/api/rules/route.ts", `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");
}
`);
// rules/suggestions
writeProxy("app/api/rules/suggestions/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules/suggestions");
}
`);
// exports/csv
writeProxy("app/api/exports/csv/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/csv");
}
`);
// tax/returns
writeProxy("app/api/tax/returns/route.ts", `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");
}
`);
// tax/returns/[id]/export
writeProxy("app/api/tax/returns/[id]/export/route.ts", `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\`);
}
`);
// plaid/link-token
writeProxy("app/api/plaid/link-token/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/link-token");
}
`);
// plaid/exchange
writeProxy("app/api/plaid/exchange/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/exchange");
}
`);
// ─── New proxy routes ────────────────────────────────────────────────────────
// auth/refresh
mkdirSync("app/api/auth/refresh", { recursive: true });
writeFileSync("app/api/auth/refresh/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/refresh");
}
`);
// auth/logout
mkdirSync("app/api/auth/logout", { recursive: true });
writeFileSync("app/api/auth/logout/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/logout");
}
`);
// auth/me
mkdirSync("app/api/auth/me", { recursive: true });
writeFileSync("app/api/auth/me/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
`);
// auth/verify-email
mkdirSync("app/api/auth/verify-email", { recursive: true });
writeFileSync("app/api/auth/verify-email/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/verify-email");
}
`);
// auth/forgot-password
mkdirSync("app/api/auth/forgot-password", { recursive: true });
writeFileSync("app/api/auth/forgot-password/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/forgot-password");
}
`);
// auth/reset-password
mkdirSync("app/api/auth/reset-password", { recursive: true });
writeFileSync("app/api/auth/reset-password/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/reset-password");
}
`);
// 2fa/generate
mkdirSync("app/api/2fa/generate", { recursive: true });
writeFileSync("app/api/2fa/generate/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/generate");
}
`);
// 2fa/enable
mkdirSync("app/api/2fa/enable", { recursive: true });
writeFileSync("app/api/2fa/enable/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/enable");
}
`);
// 2fa/disable
mkdirSync("app/api/2fa/disable", { recursive: true });
writeFileSync("app/api/2fa/disable/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "2fa/disable");
}
`);
// stripe/subscription
mkdirSync("app/api/stripe/subscription", { recursive: true });
writeFileSync("app/api/stripe/subscription/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "stripe/subscription");
}
`);
// stripe/checkout
mkdirSync("app/api/stripe/checkout", { recursive: true });
writeFileSync("app/api/stripe/checkout/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/checkout");
}
`);
// stripe/portal
mkdirSync("app/api/stripe/portal", { recursive: true });
writeFileSync("app/api/stripe/portal/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/portal");
}
`);
// transactions/import (multipart CSV upload)
mkdirSync("app/api/transactions/import", { recursive: true });
writeFileSync("app/api/transactions/import/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import");
}
`);
// exports/sheets
mkdirSync("app/api/exports/sheets", { recursive: true });
writeFileSync("app/api/exports/sheets/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "exports/sheets");
}
`);
console.log("✅ All API proxy routes written (existing updated + new created)");

View File

@ -0,0 +1,249 @@
import { writeFileSync } from "fs";
writeFileSync("components/app-shell.tsx", `"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
import { apiFetch, clearAuth, getStoredUser } from "../lib/api";
const navItems = [
{ href: "/app", label: "Dashboard" },
{ href: "/app/connect", label: "Accounts" },
{ href: "/transactions", label: "Transactions" },
{ href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" },
{ href: "/tax", label: "Tax" },
{ href: "/settings", label: "Settings" },
];
type User = {
id: string;
email: string;
fullName?: string | null;
emailVerified?: boolean;
twoFactorEnabled?: boolean;
};
type SubscriptionData = {
plan?: string;
status?: string;
};
type AppShellProps = {
title: string;
subtitle?: string;
children: React.ReactNode;
};
function initials(user: User): string {
if (user.fullName) {
const parts = user.fullName.trim().split(/\\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return parts[0].slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
}
function displayName(user: User): string {
return user.fullName?.trim() || user.email;
}
export function AppShell({ title, subtitle, children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [user, setUser] = useState<User | null>(getStoredUser<User>());
const [planLabel, setPlanLabel] = useState("Free Plan");
useEffect(() => {
// Fetch latest user profile
apiFetch<{ user: User }>("/api/auth/me")
.then((res) => {
if (!res.error && res.data?.user) {
setUser(res.data.user);
}
})
.catch(() => {});
// Fetch subscription plan
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error && res.data?.plan) {
const p = res.data.plan;
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
}
})
.catch(() => {});
}, []);
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();
router.push("/login");
};
const NavLinks = () => (
<>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={\`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors \${
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}\`}
>
<span>{item.label}</span>
</Link>
);
})}
</>
);
return (
<div className="min-h-screen bg-background flex font-sans text-foreground">
{/* Desktop Sidebar */}
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
<div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
</div>
<div className="p-4 space-y-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="mt-auto p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile Sidebar drawer */}
<aside
className={\`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden \${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}\`}
>
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-1 flex-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
<div className="flex items-center gap-3">
{/* Hamburger — mobile only */}
<button
className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={() => setMobileOpen(true)}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-3 lg:gap-4">
<div className="relative hidden md:block">
<input
type="text"
placeholder="Search..."
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<CurrencyToggle />
<MoodToggle />
<button
onClick={onLogout}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Log out
</button>
</div>
</header>
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
</div>
</div>
);
}
`);
console.log("✅ AppShell updated with real user data and mobile nav");

View File

@ -0,0 +1,594 @@
import { writeFileSync, mkdirSync } from "fs";
// ─── Update settings/page.tsx to include 2FA link ────────────────────────────
writeFileSync("app/settings/page.tsx", `import Link from "next/link";
import { AppShell } from "../../components/app-shell";
const settingsItems = [
{
title: "Profile",
description: "Update company details, contact info, and onboarding fields.",
href: "/settings/profile",
},
{
title: "Two-Factor Auth",
description: "Add a TOTP authenticator app for extra security.",
href: "/settings/2fa",
},
{
title: "Subscription",
description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription",
},
];
export default function SettingsPage() {
return (
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsItems.map((item) => (
<Link
key={item.title}
href={item.href}
className="glass-panel p-6 rounded-2xl shadow-sm transition-all hover:-translate-y-1 hover:border-primary/50 group"
>
<p className="text-lg font-bold text-foreground group-hover:text-primary transition-colors">{item.title}</p>
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
<span className="mt-4 inline-flex rounded-full border border-border bg-secondary/50 px-3 py-1 text-xs font-medium text-muted-foreground">
Open
</span>
</Link>
))}
</div>
</AppShell>
);
}
`);
// ─── settings/2fa/page.tsx ───────────────────────────────────────────────────
mkdirSync("app/settings/2fa", { recursive: true });
writeFileSync("app/settings/2fa/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
type UserData = { user: { twoFactorEnabled: boolean } };
export default function TwoFAPage() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [qrCode, setQrCode] = useState<string>("");
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
const [token, setToken] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
useEffect(() => {
apiFetch<UserData["user"]>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
// me returns { user: {...} }
const data = res.data as unknown as UserData;
setEnabled(data.user?.twoFactorEnabled ?? false);
}
})
.catch(() => {});
}, []);
const handleGenerate = async () => {
setStatus("Generating QR code...");
setIsError(false);
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
setIsError(true);
return;
}
setQrCode(res.data.qrCode);
setOtpAuthUrl(res.data.otpAuthUrl);
setStep("scan");
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
};
const handleEnable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code.");
setIsError(true);
return;
}
setStatus("Verifying...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
method: "POST",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Verification failed. Try again.");
setIsError(true);
return;
}
setEnabled(true);
setStep("done");
setStatus("Two-factor authentication is now active.");
};
const handleDisable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code to confirm.");
setIsError(true);
return;
}
setStatus("Disabling 2FA...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
method: "DELETE",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
setIsError(true);
return;
}
setEnabled(false);
setToken("");
setStep("idle");
setStatus("Two-factor authentication has been disabled.");
};
return (
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
<div className="max-w-lg">
<div className="glass-panel rounded-2xl p-8">
{enabled === null ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : enabled ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA is Active</p>
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
To disable two-factor authentication, enter the current code from your authenticator app.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required
/>
</div>
<button
type="submit"
className="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"
>
Disable 2FA
</button>
</form>
</>
) : step === "idle" ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
</p>
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Enable Two-Factor Auth
</button>
</>
) : step === "scan" ? (
<>
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
<p className="text-xs text-muted-foreground mb-4">
Open your authenticator app and scan the code below, or enter the key manually.
</p>
{qrCode && (
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
</div>
)}
{otpAuthUrl && (
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
)}
<form onSubmit={handleEnable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required autoFocus
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Verify and Enable
</button>
</form>
</>
) : (
<div className="text-center py-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<svg className="h-6 w-6 text-primary" 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-bold text-foreground">2FA Enabled Successfully</p>
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
</div>
)}
{status && (
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}
`);
// ─── settings/subscription/page.tsx ─────────────────────────────────────────
mkdirSync("app/settings/subscription", { recursive: true });
writeFileSync("app/settings/subscription/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type SubscriptionData = {
plan?: string;
status?: string;
billingCycleAnchor?: number;
cancelAtPeriodEnd?: boolean;
};
const PLAN_LABELS: Record<string, string> = {
free: "Free",
pro: "Pro",
elite: "Elite",
};
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.",
};
export default function SubscriptionPage() {
const [sub, setSub] = useState<SubscriptionData | null>(null);
const [loading, setLoading] = useState(true);
const [actionStatus, setActionStatus] = useState("");
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error) setSub(res.data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleUpgrade = async (plan: string) => {
setActionLoading(true);
setActionStatus("Redirecting to checkout...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
method: "POST",
body: JSON.stringify({
plan,
successUrl: \`\${appUrl}/settings/subscription?upgraded=1\`,
cancelUrl: \`\${appUrl}/settings/subscription\`,
}),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const handlePortal = async () => {
setActionLoading(true);
setActionStatus("Redirecting to billing portal...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
method: "POST",
body: JSON.stringify({ returnUrl: \`\${appUrl}/settings/subscription\` }),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const currentPlan = sub?.plan ?? "free";
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
return (
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
<div className="max-w-2xl 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>
{loading ? (
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
{sub.status}
</span>
)}
{(sub?.status === "active" || currentPlan !== "free") && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
{sub?.cancelAtPeriodEnd && (
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
)}
</>
)}
{!loading && currentPlan !== "free" && (
<button
onClick={handlePortal}
disabled={actionLoading}
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
>
Manage Billing
</button>
)}
</div>
{/* Upgrade options */}
{currentPlan === "free" && (
<div className="grid gap-4 md:grid-cols-2">
{(["pro", "elite"] as const).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>
<button
onClick={() => handleUpgrade(plan)}
disabled={actionLoading}
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]}
</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>
)}
</div>
</AppShell>
);
}
`);
// ─── settings/profile/page.tsx (fix stale class names) ───────────────────────
mkdirSync("app/settings/profile", { recursive: true });
writeFileSync("app/settings/profile/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ProfileData = {
user: {
id: string;
email: string;
fullName?: string | null;
phone?: string | null;
companyName?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
};
export default function ProfilePage() {
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [companyName, setCompanyName] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [addressLine2, setAddressLine2] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
useEffect(() => {
apiFetch<ProfileData>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
const u = (res.data as unknown as ProfileData).user;
if (u) {
setFullName(u.fullName ?? "");
setPhone(u.phone ?? "");
setCompanyName(u.companyName ?? "");
setAddressLine1(u.addressLine1 ?? "");
setAddressLine2(u.addressLine2 ?? "");
setCity(u.city ?? "");
setState(u.state ?? "");
setPostalCode(u.postalCode ?? "");
setCountry(u.country ?? "");
}
}
})
.catch(() => {});
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Saving profile...");
setIsError(false);
const res = await apiFetch<ProfileData>("/api/auth/profile", {
method: "PATCH",
body: JSON.stringify({
fullName: fullName || undefined,
phone: phone || undefined,
companyName: companyName || undefined,
addressLine1: addressLine1 || undefined,
addressLine2: addressLine2 || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Profile update failed.");
setIsError(true);
return;
}
setStatus("Profile saved successfully.");
};
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
return (
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
<div className="max-w-2xl">
<div className="glass-panel p-8 rounded-2xl">
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
<p className="mt-1 text-sm text-muted-foreground">
These details appear on tax exports and CSV reports.
</p>
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
<div className="md:col-span-2">
<label className={labelCls}>Full name</label>
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
</div>
<div>
<label className={labelCls}>Phone</label>
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className={labelCls}>Company</label>
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 1</label>
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 2</label>
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</div>
<div>
<label className={labelCls}>City</label>
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
</div>
<div>
<label className={labelCls}>State</label>
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
</div>
<div>
<label className={labelCls}>Postal code</label>
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</div>
<div>
<label className={labelCls}>Country</label>
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
</div>
<div className="md:col-span-2">
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Save profile
</button>
</div>
</form>
{status && (
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ settings/page.tsx, settings/2fa/page.tsx, settings/subscription/page.tsx, settings/profile/page.tsx written");

View File

@ -0,0 +1,517 @@
import { writeFileSync } from "fs";
writeFileSync("app/transactions/page.tsx", `"use client";
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 };
};
type TransactionRow = {
id: string;
name?: string;
description?: string;
amount: string;
category?: string | null;
note?: string | null;
status?: string;
hidden?: boolean;
date: string;
accountId?: string | null;
};
type Account = {
id: string;
institutionName: string;
accountType: string;
mask?: string | null;
};
type ImportResult = {
imported: number;
skipped: number;
errors?: string[];
};
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 [datePreset, setDatePreset] = useState("this_month");
const [showFilters, setShowFilters] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
const [autoSync, setAutoSync] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [showManual, setShowManual] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importStatus, setImportStatus] = useState("");
const [importLoading, setImportLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [manualForm, setManualForm] = useState({
accountId: "",
date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
category: "",
note: "",
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false });
const [filters, setFilters] = useState({
startDate: "", endDate: "", minAmount: "", maxAmount: "",
category: "", source: "", search: "", includeHidden: false,
});
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
if (preset === "this_month") {
start = new Date(end.getFullYear(), end.getMonth(), 1);
} else if (preset === "last_month") {
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
end.setDate(0);
} else if (preset === "last_6_months") {
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
} else if (preset === "last_year") {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const buildQuery = () => {
const params = new URLSearchParams();
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.search) params.set("search", filters.search);
if (filters.includeHidden) params.set("include_hidden", "true");
return params.toString() ? \`?\${params.toString()}\` : "";
};
const load = async () => {
const query = buildQuery();
const res = await apiFetch<TransactionRow[]>(\`/api/transactions\${query}\`);
if (res.error) {
setStatus(res.error.message ?? "Unable to load transactions.");
return;
}
setRows(res.data ?? []);
setStatus((res.data ?? []).length ? "" : "No transactions yet.");
};
const loadAccounts = async () => {
const res = await apiFetch<Account[]>("/api/accounts");
if (!res.error) setAccounts(res.data ?? []);
};
const loadSummary = async () => {
const query = buildQuery();
const res = await apiFetch<{ total: string; count: number }>(\`/api/transactions/summary\${query}\`);
if (!res.error) setSummary(res.data);
};
useEffect(() => {
applyPreset("this_month");
load();
loadSummary();
loadAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!autoSync) return;
const id = setInterval(() => { onSync(); }, 5 * 60 * 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoSync, filters.startDate, filters.endDate]);
const onSync = async () => {
if (isSyncing) return;
setIsSyncing(true);
setStatus("Syncing transactions...");
const res = await apiFetch<unknown>("/api/transactions/sync", {
method: "POST",
body: JSON.stringify({ startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }),
});
if (res.error) {
setStatus(res.error.message ?? "Sync failed.");
setIsSyncing(false);
return;
}
setStatus("Sync complete.");
await load();
await loadSummary();
setIsSyncing(false);
};
const onImportCsv = async (file: File) => {
setImportLoading(true);
setImportStatus("Uploading...");
const formData = new FormData();
formData.append("file", file);
const token = getStoredToken();
try {
const res = await fetch("/api/transactions/import", {
method: "POST",
headers: token ? { Authorization: \`Bearer \${token}\` } : {},
body: formData,
});
const payload = (await res.json()) as ApiResponse<ImportResult>;
if (!res.ok || 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"}.\`);
await load();
await loadSummary();
} catch {
setImportStatus("Import failed. Please try again.");
}
setImportLoading(false);
};
const formatAmount = (value: string) => {
const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, ""));
if (Number.isNaN(numeric)) return { display: value, tone: "text-foreground" };
return {
display: numeric < 0 ? \`-\$\${Math.abs(numeric).toFixed(2)}\` : \`\$\${numeric.toFixed(2)}\`,
tone: numeric < 0 ? "text-foreground" : "text-primary font-bold",
};
};
const onManualCreate = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(manualForm.amount);
if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; }
setStatus("Saving manual transaction...");
const res = await apiFetch<unknown>("/api/transactions/manual", {
method: "POST",
body: JSON.stringify({
accountId: manualForm.accountId || undefined,
date: manualForm.date,
description: manualForm.description,
amount,
category: manualForm.category || undefined,
note: manualForm.note || undefined,
}),
});
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" }));
setShowManual(false);
setStatus("Manual transaction saved.");
await load();
await loadSummary();
};
const startEdit = (row: TransactionRow) => {
setEditingId(row.id);
setEditForm({ category: row.category ?? "", note: row.note ?? "", hidden: Boolean(row.hidden) });
};
const saveEdit = async () => {
if (!editingId) return;
setStatus("Saving edits...");
const res = await apiFetch<unknown>(\`/api/transactions/\${editingId}/derived\`, {
method: "PATCH",
body: JSON.stringify({
userCategory: editForm.category || undefined,
userNotes: editForm.note || undefined,
isHidden: editForm.hidden,
}),
});
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
setEditingId(null);
setStatus("Transaction updated.");
await load();
await loadSummary();
};
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";
const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
return (
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
{/* Action bar */}
<div className="flex flex-wrap items-center gap-2 text-sm mb-6">
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-primary" />
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
</span>
<button onClick={onSync} className="ml-auto px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors">
{isSyncing ? "Syncing..." : "Sync"}
</button>
<button onClick={() => setAutoSync((prev) => !prev)} className={\`px-3 py-2 rounded-lg border text-sm font-medium transition-colors \${autoSync ? "bg-primary/10 border-primary/30 text-primary" : "bg-background border-border text-foreground hover:bg-secondary"}\`}>
Auto {autoSync ? "On" : "Off"}
</button>
<button onClick={() => setShowManual((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showManual ? "Hide manual" : "Add manual"}
</button>
<button onClick={() => { setShowImport((prev) => !prev); setImportStatus(""); }} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showImport ? "Hide import" : "Import CSV"}
</button>
<Link href={\`/exports\${buildQuery()}\`} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
Export
</Link>
<button onClick={() => setShowFilters((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showFilters ? "Hide filters" : "Filters"}
</button>
</div>
{/* CSV Import panel */}
{showImport && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-1">Import CSV</p>
<p className="text-xs text-muted-foreground mb-4">
Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.
</p>
<div
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith(".csv")) onImportCsv(file);
}}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImportCsv(f); }}
/>
<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" />
</svg>
{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>
)}
</div>
{importStatus && (
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
)}
</div>
)}
{/* Manual transaction form */}
{showManual && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-4">Add Manual Transaction</p>
<form onSubmit={onManualCreate} className="grid gap-3 md:grid-cols-3">
<div>
<label className={labelCls}>Date</label>
<input type="date" value={manualForm.date} onChange={(e) => setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Description</label>
<input type="text" value={manualForm.description} onChange={(e) => setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Amount (negative = expense)</label>
<input type="number" step="0.01" value={manualForm.amount} onChange={(e) => setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
</div>
<div>
<label className={labelCls}>Category</label>
<input type="text" value={manualForm.category} onChange={(e) => setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Account</label>
<select value={manualForm.accountId} onChange={(e) => setManualForm((p) => ({ ...p, accountId: e.target.value }))} className={inputCls}>
<option value=""> No account </option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.institutionName} {a.mask ? \`••\${a.mask}\` : ""}</option>
))}
</select>
</div>
<div>
<label className={labelCls}>Note</label>
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: 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>
</div>
</form>
</div>
)}
{/* Filters */}
{showFilters && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Search</label>
<input type="text" value={filters.search} onChange={(e) => setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
</div>
<div>
<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}>Min amount</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden
</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>
</div>
</div>
</div>
)}
{/* Summary cards */}
{summary && (
<div className="mb-6 grid gap-3 md:grid-cols-3">
{[
{ label: "Total", value: \`\$\${Math.abs(Number.parseFloat(summary.total ?? "0")).toFixed(2)}\`, sub: \`\${summary.count} transactions\` },
{ label: "Income", value: \`+\$\${Math.abs(Number.parseFloat(summary.income ?? "0")).toFixed(2)}\`, sub: "Credits" },
{ label: "Expenses", value: \`-\$\${Math.abs(Number.parseFloat(summary.expense ?? "0")).toFixed(2)}\`, sub: "Debits" },
].map((c) => (
<div key={c.label} className="glass-panel rounded-xl p-4 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{c.label}</p>
<p className="mt-2 text-xl font-bold text-foreground">{c.value}</p>
<p className="text-xs text-muted-foreground">{c.sub}</p>
</div>
))}
</div>
)}
{/* Transaction table */}
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
{status && (
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
<tr>
<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 text-right">Amount</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{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">
<input
type="text"
value={editForm.category}
onChange={(e) => setEditForm((p) => ({ ...p, category: e.target.value }))}
placeholder="Category"
className="w-28 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<input
type="text"
value={editForm.note}
onChange={(e) => setEditForm((p) => ({ ...p, note: e.target.value }))}
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"
/>
<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
</label>
</div>
</td>
<td className="px-4 py-3" colSpan={3}>
<div className="flex gap-2 justify-end">
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
<button onClick={() => setEditingId(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
</div>
</td>
</tr>
) : (
<tr key={row.id} className={\`border-b border-border hover:bg-secondary/20 transition-colors \${row.hidden ? "opacity-50" : ""}\`}>
<td className="px-4 py-3 font-medium whitespace-nowrap">
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-4 py-3 text-foreground font-medium max-w-[200px] truncate">
{row.description ?? row.name ?? "—"}
</td>
<td className="px-4 py-3">
{row.category ? (
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">{row.category}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className={\`px-4 py-3 text-right font-bold \${formatAmount(row.amount).tone}\`}>
{formatAmount(row.amount).display}
</td>
<td className="px-4 py-3 text-right">
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
</td>
</tr>
)
)}
{!rows.length && !status && (
<tr>
<td colSpan={5} 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>
)}
</tbody>
</table>
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ transactions/page.tsx written with CSV import UI and apiFetch");

View File

@ -0,0 +1,221 @@
import { writeFileSync } from "fs";
writeFileSync("app/exports/page.tsx", `"use client";
import { useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type ExportData = { status: string; csv?: string; rowCount?: number };
type SheetsData = { spreadsheetUrl?: string; spreadsheetId?: string; rowCount?: number };
export default function ExportsPage() {
const [csvStatus, setCsvStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom");
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false,
});
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
if (preset === "this_month") {
start = new Date(end.getFullYear(), end.getMonth(), 1);
} else if (preset === "last_month") {
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
end.setDate(0);
} else if (preset === "last_6_months") {
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
} else if (preset === "last_year") {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const buildParams = () => {
const params = new URLSearchParams();
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.includeHidden) params.set("include_hidden", "true");
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 a = document.createElement("a");
a.href = url;
a.download = \`ledgerone-export-\${new Date().toISOString().slice(0, 10)}.csv\`;
a.click();
URL.revokeObjectURL(url);
setCsvStatus(\`Export ready (\${res.data.rowCount ?? 0} rows) — file downloaded.\`);
} else {
setCsvStatus("Export ready.");
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
if (res.data.spreadsheetUrl) {
setSheetsUrl(res.data.spreadsheetUrl);
setSheetsStatus(\`Sheet created with \${res.data.rowCount ?? 0} rows.\`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Date preset selector */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="custom">Custom</option>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Category contains</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</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} />
</div>
<div>
<label className={labelCls}>Max amount ($)</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden transactions
</label>
</div>
</div>
<div className="border-t border-border" />
{/* Export actions */}
<div className="grid gap-4 md:grid-cols-2">
{/* CSV export */}
<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-primary/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download CSV</p>
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
</div>
</div>
<button
onClick={onExportCsv}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Export CSV
</button>
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div>
{/* Google Sheets export */}
<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-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
<p className="mt-1 text-xs text-muted-foreground">Creates a new Google Sheet with a dated tab. Requires Google OAuth to be configured.</p>
</div>
</div>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ exports/page.tsx written with CSV download + Google Sheets export");