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

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/accounts/manual`, {
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"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/manual");
}

View File

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

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) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/login`, {
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"
}
});
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
}

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) {
const body = await req.text();
const auth = req.headers.get("authorization") ?? "";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/profile`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: auth },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
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");
}

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) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/register`, {
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"
}
});
}
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" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
}

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

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) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/plaid/exchange`, {
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"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/exchange");
}

View File

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

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/rules${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
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"
}
});
}
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");
}

View File

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

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(
_req: Request,
context: { params: { id: string } }
) {
const res = await fetch(`http://localhost:3051/api/tax/returns/${context.params.id}/export`, {
method: "POST"
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
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`);
}

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/tax/returns${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
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"
}
});
}
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");
}

View File

@ -1,27 +1,9 @@
export async function PATCH(req: Request, context: { params: { id: string } }) {
try {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text();
const res = await fetch(`${baseUrl}/api/transactions/${context.params.id}/derived`, {
method: "PATCH",
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" } }
);
}
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`);
}

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/cashflow${url.search}`);
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" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/cashflow");
}

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) {
try {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text();
const res = await fetch(`${baseUrl}/api/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" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/manual");
}

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/merchants${url.search}`);
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" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/merchants");
}

View File

@ -1,23 +1,10 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions${url.search}`);
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" } }
);
}
}
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");
}

View File

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

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/sync`, {
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"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/sync");
}

View File

@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
@ -31,36 +32,14 @@ type MerchantInsight = {
count: number;
};
const recentTransactions = [
{
date: "Oct 24, 2023",
description: "Whole Foods Market",
category: "Groceries",
account: "Chase Sapphire",
amount: "-$142.30"
},
{
date: "Oct 23, 2023",
description: "Apple Subscription",
category: "Services",
account: "Apple Card",
amount: "-$14.99"
},
{
date: "Oct 22, 2023",
description: "Stripe Payout",
category: "Income",
account: "Mercury Business",
amount: "+$4,200.00"
},
{
date: "Oct 22, 2023",
description: "Shell Gasoline",
category: "Transport",
account: "Chase Sapphire",
amount: "-$52.12"
}
];
type TxRow = {
id: string;
date: string;
description: string;
category?: string | null;
accountId?: string | null;
amount: string;
};
// ---------- helpers ----------
function formatMonthLabel(yyyyMm: string) {
@ -520,27 +499,23 @@ export default function AppHomePage() {
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null);
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setLoading(false);
return;
}
const query = `?user_id=${encodeURIComponent(userId)}`;
Promise.all([
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>),
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>),
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>),
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>)
apiFetch<Summary>("/api/transactions/summary"),
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data);
if (!merchantsRes.error) setMerchants(merchantsRes.data);
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
})
.catch(() => undefined)
.finally(() => setLoading(false));
@ -743,26 +718,48 @@ export default function AppHomePage() {
<th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th>
<th className="pb-3">Category</th>
<th className="pb-3">Account</th>
<th className="pb-3 pr-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{recentTransactions.map((tx) => (
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{tx.date}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
</td>
<td className="py-3">{tx.account}</td>
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}>
{tx.amount}
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
</tr>
))
) : recentTxs.length ? (
recentTxs.map((tx) => {
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>
</tr>
))}
)}
</tbody>
</table>
</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 Link from "next/link";
import { notFound } from "next/navigation";
import { ContactSection } from "../../../components/contact-section";
import { DemoCta } from "../../../components/demo-cta";
import { FaqSection } from "../../../components/faq-section";
import { PageSchema } from "../../../components/page-schema";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { blogPosts } from "../../../data/blog";
import { defaultFaqs } from "../../../data/faq";
import { siteInfo } from "../../../data/site";
type PageProps = {
params: { slug: string };
};
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
export function generateMetadata({ params }: PageProps) {
const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) {
return { title: "Blog Post" };
}
return {
title: post.title,
description: post.excerpt,
keywords: [...siteInfo.keywords, "finance blog", "ledger insights"]
};
}
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContactSection } from "../../../components/contact-section";
import { DemoCta } from "../../../components/demo-cta";
import { FaqSection } from "../../../components/faq-section";
import { PageSchema } from "../../../components/page-schema";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { blogPosts } from "../../../data/blog";
import { defaultFaqs } from "../../../data/faq";
import { siteInfo } from "../../../data/site";
type PageProps = {
params: { slug: string };
};
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
export function generateMetadata({ params }: PageProps) {
const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) {
return { title: "Blog Post" };
}
return {
title: post.title,
description: post.excerpt,
keywords: [...siteInfo.keywords, "finance blog", "ledger insights"]
};
}
export default function BlogPostPage({ params }: PageProps) {
const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) {
notFound();
}
const schema = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
author: {
"@type": "Organization",
name: siteInfo.name
},
image: post.image,
mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}`
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
];
const post = blogPosts.find((item) => item.slug === params.slug);
if (!post) {
notFound();
}
const schema = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
author: {
"@type": "Organization",
name: siteInfo.name
},
image: post.image,
mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}`
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
];
return (
<div className="marketing min-h-screen">
<div className="relative overflow-hidden">
<div className="halo absolute inset-0" />
<div className="grid-dots absolute inset-0" />
<SiteHeader />
<main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12">
<div className="relative overflow-hidden">
<div className="halo absolute inset-0" />
<div className="grid-dots absolute inset-0" />
<SiteHeader />
<main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12">
<Link className="text-sm font-semibold text-ink" href="/blog">
&lt;- Back to blog
</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">
{post.date} - {post.readTime}
</p>
<h1 className="mt-3 text-3xl font-semibold">{post.title}</h1>
<p className="mt-4 text-sm text-muted">{post.excerpt}</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-ink/10">
<Image
src={post.image}
alt={post.title}
width={1200}
height={900}
className="h-64 w-full object-cover"
/>
</div>
<div className="mt-6 space-y-4 text-sm text-muted">
{post.content.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
</main>
</div>
<DemoCta />
<FaqSection limit={8} />
<ContactSection />
<PageSchema schema={schema} />
<SiteFooter />
</div>
);
}
<h1 className="mt-3 text-3xl font-semibold">{post.title}</h1>
<p className="mt-4 text-sm text-muted">{post.excerpt}</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-ink/10">
<Image
src={post.image}
alt={post.title}
width={1200}
height={900}
className="h-64 w-full object-cover"
/>
</div>
<div className="mt-6 space-y-4 text-sm text-muted">
{post.content.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
</main>
</div>
<DemoCta />
<FaqSection limit={8} />
<ContactSection />
<PageSchema schema={schema} />
<SiteFooter />
</div>
);
}

View File

@ -1,109 +1,109 @@
import Link from "next/link";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "Blog",
description: "Insights on financial control, audit readiness, and ledger automation.",
keywords: siteInfo.keywords
};
const posts = [
{
title: "Why 'Audit-Ready' Matters More Than Ever",
excerpt:
"As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.",
date: "Oct 24, 2023",
readTime: "5 min read",
slug: "audit-ready-matters"
},
{
title: "The Hidden Cost of Spreadsheet Chaos",
excerpt:
"Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.",
date: "Oct 12, 2023",
readTime: "4 min read",
slug: "spreadsheet-chaos"
},
{
title: "Automating the Month-End Close",
excerpt:
"How to use rules and categories to reduce your close time from days to hours.",
date: "Sep 28, 2023",
readTime: "6 min read",
slug: "automating-month-end"
}
];
export default function BlogPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Blog",
description: "Insights on financial control, audit readiness, and ledger automation.",
url: `${siteInfo.url}/blog`
},
{
"@context": "https://schema.org",
"@type": "Blog",
blogPost: posts.map((post) => ({
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
url: `${siteInfo.url}/blog/${post.slug}`
}))
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
<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="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">
Insights for modern finance teams.
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Best practices for financial control, audit readiness, and automation.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{posts.map((post) => (
<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="flex items-center gap-3 text-xs text-muted-foreground mb-4">
<time dateTime={post.date}>{post.date}</time>
<span></span>
<span>{post.readTime}</span>
</div>
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3">
{post.title}
</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{post.excerpt}
</p>
<div className="mt-6 flex items-center text-primary font-medium text-sm">
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>
</div>
</div>
</Link>
))}
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Link from "next/link";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "Blog",
description: "Insights on financial control, audit readiness, and ledger automation.",
keywords: siteInfo.keywords
};
const posts = [
{
title: "Why 'Audit-Ready' Matters More Than Ever",
excerpt:
"As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.",
date: "Oct 24, 2023",
readTime: "5 min read",
slug: "audit-ready-matters"
},
{
title: "The Hidden Cost of Spreadsheet Chaos",
excerpt:
"Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.",
date: "Oct 12, 2023",
readTime: "4 min read",
slug: "spreadsheet-chaos"
},
{
title: "Automating the Month-End Close",
excerpt:
"How to use rules and categories to reduce your close time from days to hours.",
date: "Sep 28, 2023",
readTime: "6 min read",
slug: "automating-month-end"
}
];
export default function BlogPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Blog",
description: "Insights on financial control, audit readiness, and ledger automation.",
url: `${siteInfo.url}/blog`
},
{
"@context": "https://schema.org",
"@type": "Blog",
blogPost: posts.map((post) => ({
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
url: `${siteInfo.url}/blog/${post.slug}`
}))
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
<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="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">
Insights for modern finance teams.
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Best practices for financial control, audit readiness, and automation.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{posts.map((post) => (
<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="flex items-center gap-3 text-xs text-muted-foreground mb-4">
<time dateTime={post.date}>{post.date}</time>
<span></span>
<span>{post.readTime}</span>
</div>
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3">
{post.title}
</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{post.excerpt}
</p>
<div className="mt-6 flex items-center text-primary font-medium text-sm">
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>
</div>
</div>
</Link>
))}
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

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

View File

@ -1,86 +1,86 @@
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Copilot",
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
keywords: siteInfo.keywords
};
export default function CompareCopilotPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Copilot",
description: "Comparison of LedgerOne and Copilot.",
url: `${siteInfo.url}/compare/vs-copilot`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Financial control for everyone.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Copilot</div>
</div>
{[
{ feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" },
{ feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" },
{ feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" },
{ feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" },
{ feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" },
{ feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Copilot",
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
keywords: siteInfo.keywords
};
export default function CompareCopilotPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Copilot",
description: "Comparison of LedgerOne and Copilot.",
url: `${siteInfo.url}/compare/vs-copilot`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Financial control for everyone.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Copilot</div>
</div>
{[
{ feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" },
{ feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" },
{ feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" },
{ feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" },
{ feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" },
{ feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

@ -1,86 +1,86 @@
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Quicken",
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
keywords: siteInfo.keywords
};
export default function CompareQuickenPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Quicken",
description: "Comparison of LedgerOne and Quicken.",
url: `${siteInfo.url}/compare/vs-quicken`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
The modern alternative to Quicken.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Quicken</div>
</div>
{[
{ feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" },
{ feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" },
{ feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" },
{ feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" },
{ feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" },
{ feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Quicken",
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
keywords: siteInfo.keywords
};
export default function CompareQuickenPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Quicken",
description: "Comparison of LedgerOne and Quicken.",
url: `${siteInfo.url}/compare/vs-quicken`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
The modern alternative to Quicken.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">Quicken</div>
</div>
{[
{ feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" },
{ feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" },
{ feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" },
{ feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" },
{ feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" },
{ feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

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

View File

@ -1,86 +1,86 @@
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs YNAB",
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
keywords: siteInfo.keywords
};
export default function CompareYnabPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs YNAB",
description: "Comparison of LedgerOne and YNAB.",
url: `${siteInfo.url}/compare/vs-ynab`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Beyond Budgeting.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">YNAB</div>
</div>
{[
{ feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" },
{ feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" },
{ feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" },
{ feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" },
{ feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" },
{ feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs YNAB",
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
keywords: siteInfo.keywords
};
export default function CompareYnabPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs YNAB",
description: "Comparison of LedgerOne and YNAB.",
url: `${siteInfo.url}/compare/vs-ynab`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Beyond Budgeting.
</h1>
<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.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center">YNAB</div>
</div>
{[
{ feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" },
{ feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" },
{ feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" },
{ feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" },
{ feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" },
{ feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" },
].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 className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2>
<Link
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"
>
Start your free trial
</Link>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

@ -1,19 +1,21 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
import { apiFetch } from "@/lib/api";
type 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() {
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 [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
@ -21,14 +23,18 @@ export default function ExportsPage() {
maxAmount: "",
category: "",
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) => {
setDatePreset(preset);
if (preset === "custom") {
return;
}
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
@ -43,101 +49,104 @@ export default function ExportsPage() {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const format = (value: Date) => value.toISOString().slice(0, 10);
setFilters((prev) => ({
...prev,
startDate: format(start),
endDate: format(end)
}));
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const onExport = async () => {
setStatus("Generating export...");
const userId = localStorage.getItem("ledgerone_user_id");
const buildParams = () => {
const params = new URLSearchParams();
if (userId) {
params.set("user_id", userId);
}
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");
}
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()}` : "";
try {
const res = await fetch(`/api/exports/csv${query}`);
const payload = (await res.json()) as ApiResponse<ExportData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Export failed.");
return;
}
if (payload.data.csv) {
const blob = new Blob([payload.data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus(`Export ready (${payload.data.rowCount ?? 0} rows).`);
} else {
setStatus("Export ready.");
}
} catch {
setStatus("Export failed.");
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 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 (
<AppShell
title="Exports"
subtitle="Generate CSV datasets with raw and derived fields."
>
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<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">
{/* Filters */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Start date</label>
<input
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"
>
<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>
@ -146,96 +155,114 @@ export default function ExportsPage() {
</select>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Category contains</label>
<input
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"
/>
<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="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Min amount</label>
<input
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"
/>
<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="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Max amount</label>
<input
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"
/>
<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="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Source contains</label>
<input
type="text"
value={filters.source}
onChange={(event) =>
setFilters((prev) => ({ ...prev, source: event.target.value }))
}
placeholder="plaid"
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={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="mt-4 flex flex-wrap items-center gap-4">
<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="border-t border-border" />
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onExport}
className="rounded-full bg-primary px-6 py-3 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
Generate CSV export
</button>
<button
type="button"
onClick={() =>
setFilters({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false
})
}
className="rounded-full border border-border bg-background px-6 py-3 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
>
Reset filters
</button>
{/* Export cards */}
<div className="grid gap-4 md:grid-cols-2">
{/* CSV */}
<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 */}
<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>
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
</div>
</AppShell>
);

View File

@ -1,84 +1,84 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "Cash Flow Management - LedgerOne",
description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.",
keywords: siteInfo.keywords
};
export default function CashFlowPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Cash Flow Management",
description: "Visualize your income and expenses in real-time.",
url: `${siteInfo.url}/features/cash-flow`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="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">
Cash Flow
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Know exactly where your money is going.
</h1>
<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.
</p>
<ul className="space-y-4 mb-10">
{[
"Real-time income vs expense tracking",
"Automatic categorization of transactions",
"Interactive bar and line charts",
"Forecast future cash positions"
].map((item) => (
<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">
<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>
<span className="text-foreground font-medium">{item}</span>
</li>
))}
</ul>
<Link
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"
>
Start tracking cash flow
</Link>
</div>
<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="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image
src="/images/feature-cashflow.png"
alt="Cash flow visualization"
width={800}
height={600}
className="w-full h-auto rounded-xl"
/>
</div>
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "Cash Flow Management - LedgerOne",
description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.",
keywords: siteInfo.keywords
};
export default function CashFlowPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Cash Flow Management",
description: "Visualize your income and expenses in real-time.",
url: `${siteInfo.url}/features/cash-flow`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="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">
Cash Flow
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Know exactly where your money is going.
</h1>
<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.
</p>
<ul className="space-y-4 mb-10">
{[
"Real-time income vs expense tracking",
"Automatic categorization of transactions",
"Interactive bar and line charts",
"Forecast future cash positions"
].map((item) => (
<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">
<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>
<span className="text-foreground font-medium">{item}</span>
</li>
))}
</ul>
<Link
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"
>
Start tracking cash flow
</Link>
</div>
<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="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image
src="/images/feature-cashflow.png"
alt="Cash flow visualization"
width={800}
height={600}
className="w-full h-auto rounded-xl"
/>
</div>
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

@ -1,84 +1,84 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "Custom Reporting - LedgerOne",
description: "Build custom financial reports and export audit-ready data for your accountant.",
keywords: siteInfo.keywords
};
export default function ReportsPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Custom Reporting",
description: "Build custom financial reports.",
url: `${siteInfo.url}/features/reports`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="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="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image
src="/images/feature-reports.png"
alt="Report builder interface"
width={800}
height={600}
className="w-full h-auto rounded-xl"
/>
</div>
</div>
<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">
Reporting
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Reports that make your accountant smile.
</h1>
<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.
</p>
<ul className="space-y-4 mb-10">
{[
"Drag-and-drop report builder",
"Filter by date, category, tag, or merchant",
"One-click CSV and PDF exports",
"Share read-only access with your accountant"
].map((item) => (
<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">
<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>
<span className="text-foreground font-medium">{item}</span>
</li>
))}
</ul>
<Link
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"
>
Start building reports
</Link>
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "Custom Reporting - LedgerOne",
description: "Build custom financial reports and export audit-ready data for your accountant.",
keywords: siteInfo.keywords
};
export default function ReportsPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Custom Reporting",
description: "Build custom financial reports.",
url: `${siteInfo.url}/features/reports`
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-32 pb-24">
<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="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="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
<Image
src="/images/feature-reports.png"
alt="Report builder interface"
width={800}
height={600}
className="w-full h-auto rounded-xl"
/>
</div>
</div>
<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">
Reporting
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
Reports that make your accountant smile.
</h1>
<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.
</p>
<ul className="space-y-4 mb-10">
{[
"Drag-and-drop report builder",
"Filter by date, category, tag, or merchant",
"One-click CSV and PDF exports",
"Share read-only access with your accountant"
].map((item) => (
<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">
<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>
<span className="text-foreground font-medium">{item}</span>
</li>
))}
</ul>
<Link
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"
>
Start building reports
</Link>
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</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";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = { user: { id: string; email: string }; token: string };
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<string>("");
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 }
}))
}
];
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Signing in...");
try {
const res = await fetch("/api/auth/login", {
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 ?? "Login failed.");
return;
}
setStatus(`Welcome back, ${payload.data.user.email}`);
localStorage.setItem("ledgerone_token", payload.data.token);
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
router.push("/app");
} catch {
setStatus("Login failed.");
}
};
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">
<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>
<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"
>
Sign in
</button>
</div>
</form>
{status && (
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
"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>
);
}

View File

@ -1,237 +1,237 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../components/site-footer";
import { SiteHeader } from "../components/site-header";
import { PageSchema } from "../components/page-schema";
import { GrowthSimulator } from "../components/growth-simulator";
import { siteInfo } from "../data/site";
export const metadata = {
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.",
keywords: siteInfo.keywords
};
export default function LandingPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: "LedgerOne",
url: siteInfo.url,
potentialAction: {
"@type": "SearchAction",
target: `${siteInfo.url}/search?q={search_term_string}`,
"query-input": "required name=search_term_string"
}
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: "LedgerOne",
url: siteInfo.url,
logo: `${siteInfo.url}/logo.png`,
sameAs: [
"https://twitter.com/ledgerone",
"https://linkedin.com/company/ledgerone"
]
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1">
{/* Hero Section - Monarch Style Clean Split */}
<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="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Left Column: Copy */}
<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">
<span className="flex h-2 w-2 rounded-full bg-primary"></span>
Now available for US & Canadian businesses
</div>
<h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight">
Master your money <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
with total clarity.
</span>
</h1>
<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.
</p>
<div className="flex flex-wrap items-center gap-4">
<Link
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"
>
Start your free trial
</Link>
<Link
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"
>
See how it works
</Link>
</div>
</div>
{/* Right Column: Composed Media Stack */}
<div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200">
{/* 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">
<video
poster="/images/hero_celebration_video_poster_1769386269277.png"
className="w-full h-full object-cover opacity-80"
autoPlay
muted
loop
playsInline
>
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* Overlay Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div>
</div>
{/* 2. Middle Layer: Interactive Simulator (Floating Card) */}
<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">
<GrowthSimulator />
</div>
</div>
</div>
</div>
</div>
</section>
{/* Social Proof */}
<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">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8">
Trusted by forward-thinking finance teams
</p>
<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 */}
<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">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">Horizon</div>
</div>
</div>
</section>
{/* Feature Grid */}
<section className="py-24 lg:py-32">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-20">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Everything you need to manage your wealth.
</h2>
<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.
</p>
</div>
<div className="grid gap-12 lg:grid-cols-3">
{/* Feature 1: Connect */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3>
<p className="text-muted-foreground mb-6">
Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop"
alt="Bank connections"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
{/* Feature 2: Visualize */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3>
<p className="text-muted-foreground mb-6">
See exactly where your money goes. Track income vs expenses with beautiful, interactive charts.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="/images/feature-cashflow.png"
alt="Cash flow chart"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
{/* Feature 3: Report */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3>
<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.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="/images/feature-reports.png"
alt="Report builder"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 relative overflow-hidden">
<div className="absolute inset-0 bg-primary/5 -z-10" />
<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">
Ready to take control?
</h2>
<p className="text-xl text-muted-foreground mb-10">
Join thousands of business owners who trust LedgerOne for their financial clarity.
</p>
<Link
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"
>
Start your 14-day free trial
</Link>
<p className="mt-4 text-sm text-muted-foreground">
No credit card required. Cancel anytime.
</p>
</div>
</section>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "../components/site-footer";
import { SiteHeader } from "../components/site-header";
import { PageSchema } from "../components/page-schema";
import { GrowthSimulator } from "../components/growth-simulator";
import { siteInfo } from "../data/site";
export const metadata = {
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.",
keywords: siteInfo.keywords
};
export default function LandingPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: "LedgerOne",
url: siteInfo.url,
potentialAction: {
"@type": "SearchAction",
target: `${siteInfo.url}/search?q={search_term_string}`,
"query-input": "required name=search_term_string"
}
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: "LedgerOne",
url: siteInfo.url,
logo: `${siteInfo.url}/logo.png`,
sameAs: [
"https://twitter.com/ledgerone",
"https://linkedin.com/company/ledgerone"
]
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1">
{/* Hero Section - Monarch Style Clean Split */}
<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="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Left Column: Copy */}
<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">
<span className="flex h-2 w-2 rounded-full bg-primary"></span>
Now available for US & Canadian businesses
</div>
<h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight">
Master your money <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
with total clarity.
</span>
</h1>
<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.
</p>
<div className="flex flex-wrap items-center gap-4">
<Link
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"
>
Start your free trial
</Link>
<Link
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"
>
See how it works
</Link>
</div>
</div>
{/* Right Column: Composed Media Stack */}
<div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200">
{/* 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">
<video
poster="/images/hero_celebration_video_poster_1769386269277.png"
className="w-full h-full object-cover opacity-80"
autoPlay
muted
loop
playsInline
>
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* Overlay Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div>
</div>
{/* 2. Middle Layer: Interactive Simulator (Floating Card) */}
<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">
<GrowthSimulator />
</div>
</div>
</div>
</div>
</div>
</section>
{/* Social Proof */}
<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">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8">
Trusted by forward-thinking finance teams
</p>
<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 */}
<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">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">Horizon</div>
</div>
</div>
</section>
{/* Feature Grid */}
<section className="py-24 lg:py-32">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-20">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Everything you need to manage your wealth.
</h2>
<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.
</p>
</div>
<div className="grid gap-12 lg:grid-cols-3">
{/* Feature 1: Connect */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3>
<p className="text-muted-foreground mb-6">
Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop"
alt="Bank connections"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
{/* Feature 2: Visualize */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3>
<p className="text-muted-foreground mb-6">
See exactly where your money goes. Track income vs expenses with beautiful, interactive charts.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="/images/feature-cashflow.png"
alt="Cash flow chart"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
{/* Feature 3: Report */}
<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">
<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>
<h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3>
<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.
</p>
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
<Image
src="/images/feature-reports.png"
alt="Report builder"
width={400}
height={250}
className="w-full h-48 object-cover"
/>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 relative overflow-hidden">
<div className="absolute inset-0 bg-primary/5 -z-10" />
<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">
Ready to take control?
</h2>
<p className="text-xl text-muted-foreground mb-10">
Join thousands of business owners who trust LedgerOne for their financial clarity.
</p>
<Link
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"
>
Start your 14-day free trial
</Link>
<p className="mt-4 text-sm text-muted-foreground">
No credit card required. Cancel anytime.
</p>
</div>
</section>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

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

View File

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

View File

@ -1,179 +1,168 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react";
import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = { user: { id: string; email: string }; token: string };
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<string>("");
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...");
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.");
return;
}
setStatus(`Welcome, ${payload.data.user.email}`);
localStorage.setItem("ledgerone_token", payload.data.token);
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
router.push("/login");
} catch {
setStatus("Registration failed.");
}
};
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
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>
<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 bg-accent/10 border border-accent/20 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
"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>
);
}

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",
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",
description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription"
}
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">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsItems.map((item) => (
<Link
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 = [
{
name: "Free",
price: "$0",
cadence: "forever",
highlight: "Connect up to 2 accounts",
features: [
"2 connected accounts",
"30-day transaction history",
"Basic exports",
"Email support"
]
},
{
name: "Pro Monthly",
price: "$9",
cadence: "per month",
highlight: "Unlimited connected accounts",
features: [
"Unlimited accounts",
"12-month history",
"Advanced exports + rules",
"Priority support"
]
},
{
name: "Pro Annual",
price: "$90",
cadence: "per year",
highlight: "Two months free",
features: [
"Unlimited accounts",
"12-month history",
"Advanced exports + rules",
"Priority support"
]
}
];
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() {
return (
<AppShell title="Subscription" subtitle="Choose a plan that fits your team.">
<div className="app-card p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<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>
const [sub, setSub] = useState<SubscriptionData | null>(null);
const [loading, setLoading] = useState(true);
const [actionStatus, setActionStatus] = useState("");
const [actionLoading, setActionLoading] = useState(false);
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{plans.map((plan) => (
<div key={plan.name} className="app-card p-6">
<p className="text-xs uppercase tracking-[0.3em] text-muted">{plan.name}</p>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-3xl font-semibold">{plan.price}</span>
<span className="text-xs text-muted">{plan.cadence}</span>
</div>
<p className="mt-2 text-sm text-muted">{plan.highlight}</p>
<ul className="mt-4 space-y-2 text-xs text-muted">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{feature}
</li>
))}
</ul>
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
type="button"
className="mt-6 w-full rounded-full border border-ink/10 bg-white/5 px-4 py-2 text-xs font-semibold text-ink"
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"
>
{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>
</div>
))}
)}
{actionStatus && (
<p className="text-sm text-muted-foreground">{actionStatus}</p>
)}
</div>
</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 React, { useEffect, useState } from "react";
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
import { apiFetch, clearAuth, getStoredUser } from "@/lib/api";
const navItems = [
{ href: "/app", label: "Dashboard" },
@ -12,28 +13,107 @@ const navItems = [
{ href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" },
{ 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 = {
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");
const onLogout = () => {
localStorage.removeItem("ledgerone_token");
localStorage.removeItem("ledgerone_user_id");
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">
{/* Sidebar */}
{/* 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">
@ -48,32 +128,65 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
{navItems.map((item) => {
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>
);
})}
<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">
AC
<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">Alex Chen</p>
<p className="text-xs text-muted-foreground truncate">Pro Plan</p>
<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>
@ -81,31 +194,46 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
{/* 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-6 lg:px-8">
<div>
{/* Breadcrumbs or Title */}
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
<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-4">
<div className="flex items-center gap-3 lg:gap-4">
<div className="relative hidden md:block">
<input
type="text"
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>
<CurrencyToggle />
<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
</button>
</div>
</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">
{children}
</div>

View File

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

View File

@ -1,58 +1,58 @@
"use client";
import { useEffect, useState } from "react";
export function CurrencyToggle() {
const [currency, setCurrency] = useState<"USD" | "CAD">("USD");
const toggle = () => {
setCurrency((prev) => (prev === "USD" ? "CAD" : "USD"));
// In a real app, this would update a context or store
document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'");
};
return (
<button
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"
>
<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={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span>
</button>
);
}
export function MoodToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
if (document.documentElement.classList.contains("dark")) {
setIsDark(true);
}
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
return (
<button
onClick={toggle}
className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
title="Toggle Mood Mode"
>
{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="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>
);
}
"use client";
import { useEffect, useState } from "react";
export function CurrencyToggle() {
const [currency, setCurrency] = useState<"USD" | "CAD">("USD");
const toggle = () => {
setCurrency((prev) => (prev === "USD" ? "CAD" : "USD"));
// In a real app, this would update a context or store
document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'");
};
return (
<button
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"
>
<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={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span>
</button>
);
}
export function MoodToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
if (document.documentElement.classList.contains("dark")) {
setIsDark(true);
}
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
return (
<button
onClick={toggle}
className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
title="Toggle Mood Mode"
>
{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="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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
type SchemaProps = {
schema: Record<string, unknown> | Array<Record<string, unknown>>;
};
type SchemaProps = {
schema: Record<string, unknown> | Array<Record<string, unknown>>;
};
export function PageSchema({ schema }: SchemaProps) {
const payload = Array.isArray(schema) ? schema : [schema];
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }}
/>
);
}
return (
<script
type="application/ld+json"
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",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@ -21,7 +21,10 @@
{
"name": "next"
}
]
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
@ -32,4 +35,4 @@
"exclude": [
"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");