implemented the udpated changes
This commit is contained in:
parent
fe6dcfd4f6
commit
cf6c4005dd
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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"]
|
||||||
@ -1,203 +1,203 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ContactSection } from "../../components/contact-section";
|
import { ContactSection } from "../../components/contact-section";
|
||||||
import { DemoCta } from "../../components/demo-cta";
|
import { DemoCta } from "../../components/demo-cta";
|
||||||
import { FaqSection } from "../../components/faq-section";
|
import { FaqSection } from "../../components/faq-section";
|
||||||
import { PageSchema } from "../../components/page-schema";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { defaultFaqs } from "../../data/faq";
|
import { defaultFaqs } from "../../data/faq";
|
||||||
import { siteInfo } from "../../data/site";
|
import { siteInfo } from "../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "About LedgerOne",
|
title: "About LedgerOne",
|
||||||
description:
|
description:
|
||||||
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
{
|
{
|
||||||
title: "Data ownership",
|
title: "Data ownership",
|
||||||
detail: "Your data stays portable, exportable, and under your control."
|
detail: "Your data stays portable, exportable, and under your control."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Transparent automation",
|
title: "Transparent automation",
|
||||||
detail: "Rules are visible, explainable, and ready for compliance review."
|
detail: "Rules are visible, explainable, and ready for compliance review."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Operational clarity",
|
title: "Operational clarity",
|
||||||
detail: "Keep finance, tax, and operations aligned with one ledger truth."
|
detail: "Keep finance, tax, and operations aligned with one ledger truth."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const milestones = [
|
const milestones = [
|
||||||
{
|
{
|
||||||
title: "Ledger-first architecture",
|
title: "Ledger-first architecture",
|
||||||
detail: "Every transaction starts immutable, then layers preserve every change."
|
detail: "Every transaction starts immutable, then layers preserve every change."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Built for teams",
|
title: "Built for teams",
|
||||||
detail: "We design workflows for the handoffs between finance, tax, and ops."
|
detail: "We design workflows for the handoffs between finance, tax, and ops."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "US-ready exports",
|
title: "US-ready exports",
|
||||||
detail: "Exports are formatted to support US tax and accounting workflows."
|
detail: "Exports are formatted to support US tax and accounting workflows."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "About LedgerOne",
|
name: "About LedgerOne",
|
||||||
description:
|
description:
|
||||||
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
|
||||||
url: `${siteInfo.url}/about`
|
url: `${siteInfo.url}/about`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "FAQPage",
|
"@type": "FAQPage",
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
"@type": "Question",
|
"@type": "Question",
|
||||||
name: item.question,
|
name: item.question,
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<section className="grid gap-12 lg:grid-cols-[1fr_1fr] items-center">
|
<section className="grid gap-12 lg:grid-cols-[1fr_1fr] items-center">
|
||||||
<div className="space-y-8 animate-slide-up">
|
<div className="space-y-8 animate-slide-up">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm">
|
||||||
Our Story
|
Our Story
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
||||||
LedgerOne keeps every transaction ready for audits, review, and action.
|
LedgerOne keeps every transaction ready for audits, review, and action.
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-4 text-lg text-muted-foreground">
|
<div className="space-y-4 text-lg text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
We built LedgerOne for teams that manage high volumes of transactions but
|
We built LedgerOne for teams that manage high volumes of transactions but
|
||||||
still need each decision documented. Our ledger-first workflow keeps the
|
still need each decision documented. Our ledger-first workflow keeps the
|
||||||
raw truth intact while allowing intelligent categorization and rule-driven
|
raw truth intact while allowing intelligent categorization and rule-driven
|
||||||
automation.
|
automation.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The result is a single source of truth that helps US finance and tax teams
|
The result is a single source of truth that helps US finance and tax teams
|
||||||
collaborate without losing evidence or context.
|
collaborate without losing evidence or context.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors shadow-glow-teal"
|
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors shadow-glow-teal"
|
||||||
>
|
>
|
||||||
View pricing
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/faq"
|
href="/faq"
|
||||||
className="px-6 py-3 rounded-lg bg-background border border-border text-foreground font-medium hover:bg-secondary transition-colors"
|
className="px-6 py-3 rounded-lg bg-background border border-border text-foreground font-medium hover:bg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Explore FAQs
|
Explore FAQs
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative animate-fade-in">
|
<div className="relative animate-fade-in">
|
||||||
<div className="absolute -right-10 top-8 hidden h-64 w-64 rounded-full bg-accent/20 blur-3xl lg:block" />
|
<div className="absolute -right-10 top-8 hidden h-64 w-64 rounded-full bg-accent/20 blur-3xl lg:block" />
|
||||||
<div className="relative rounded-2xl overflow-hidden border border-border shadow-xl glass-panel">
|
<div className="relative rounded-2xl overflow-hidden border border-border shadow-xl glass-panel">
|
||||||
<Image
|
<Image
|
||||||
src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1600&auto=format&fit=crop"
|
src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=1600&auto=format&fit=crop"
|
||||||
alt="Analyst reviewing charts on a laptop"
|
alt="Analyst reviewing charts on a laptop"
|
||||||
width={1200}
|
width={1200}
|
||||||
height={900}
|
height={900}
|
||||||
className="h-full w-full object-cover opacity-90"
|
className="h-full w-full object-cover opacity-90"
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-background/90 backdrop-blur-sm p-6 border-t border-border">
|
<div className="absolute bottom-0 left-0 right-0 bg-background/90 backdrop-blur-sm p-6 border-t border-border">
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-primary">
|
<p className="text-xs font-bold uppercase tracking-wider text-primary">
|
||||||
Built for US operators
|
Built for US operators
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
LedgerOne is built around US accounting workflows, audit readiness, and
|
LedgerOne is built around US accounting workflows, audit readiness, and
|
||||||
tax reporting cycles.
|
tax reporting cycles.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-24 grid gap-8 md:grid-cols-3">
|
<section className="mt-24 grid gap-8 md:grid-cols-3">
|
||||||
{values.map((value) => (
|
{values.map((value) => (
|
||||||
<div
|
<div
|
||||||
key={value.title}
|
key={value.title}
|
||||||
className="glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all"
|
className="glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all"
|
||||||
>
|
>
|
||||||
<div className="h-10 w-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-4">
|
<div className="h-10 w-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary mb-4">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-foreground">{value.title}</h3>
|
<h3 className="text-lg font-bold text-foreground">{value.title}</h3>
|
||||||
<p className="mt-2 text-muted-foreground">{value.detail}</p>
|
<p className="mt-2 text-muted-foreground">{value.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-24 text-center">
|
<section className="mt-24 text-center">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm mb-6">
|
||||||
Leadership
|
Leadership
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-12">Built by finance experts.</h2>
|
<h2 className="text-3xl font-bold text-foreground mb-12">Built by finance experts.</h2>
|
||||||
<div className="max-w-sm mx-auto glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all">
|
<div className="max-w-sm mx-auto glass-panel rounded-2xl p-8 shadow-sm hover:shadow-md transition-all">
|
||||||
<div className="h-24 w-24 rounded-full bg-primary/10 mx-auto mb-6 flex items-center justify-center text-3xl font-bold text-primary">
|
<div className="h-24 w-24 rounded-full bg-primary/10 mx-auto mb-6 flex items-center justify-center text-3xl font-bold text-primary">
|
||||||
MM
|
MM
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground">Manoj Mohan</h3>
|
<h3 className="text-xl font-bold text-foreground">Manoj Mohan</h3>
|
||||||
<p className="text-sm font-medium text-primary mt-1">Founder & CEO</p>
|
<p className="text-sm font-medium text-primary mt-1">Founder & CEO</p>
|
||||||
<p className="mt-4 text-muted-foreground text-sm leading-relaxed">
|
<p className="mt-4 text-muted-foreground text-sm leading-relaxed">
|
||||||
Leading the vision to bring audit-ready financial controls to modern businesses.
|
Leading the vision to bring audit-ready financial controls to modern businesses.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-24 rounded-3xl bg-secondary/30 border border-border p-8 sm:p-12">
|
<section className="mt-24 rounded-3xl bg-secondary/30 border border-border p-8 sm:p-12">
|
||||||
<div className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]">
|
<div className="grid gap-12 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-background border border-border text-xs font-medium text-muted-foreground">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-background border border-border text-xs font-medium text-muted-foreground">
|
||||||
What we built
|
What we built
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
|
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
|
||||||
<p className="text-muted-foreground text-lg">
|
<p className="text-muted-foreground text-lg">
|
||||||
Traditional tools collapse data into summaries. LedgerOne keeps each raw
|
Traditional tools collapse data into summaries. LedgerOne keeps each raw
|
||||||
entry intact and layers in decisions, reviews, and approvals.
|
entry intact and layers in decisions, reviews, and approvals.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 sm:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-3">
|
||||||
{milestones.map((item, index) => (
|
{milestones.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.title}
|
key={item.title}
|
||||||
className="rounded-xl bg-background border border-border p-6 shadow-sm"
|
className="rounded-xl bg-background border border-border p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
<p className="text-xs font-bold text-primary uppercase tracking-wider">
|
<p className="text-xs font-bold text-primary uppercase tracking-wider">
|
||||||
Focus {index + 1}
|
Focus {index + 1}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm font-bold text-foreground">{item.title}</p>
|
<p className="mt-3 text-sm font-bold text-foreground">{item.title}</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{item.detail}</p>
|
<p className="mt-2 text-xs text-muted-foreground">{item.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/api/2fa/disable/route.ts
Normal file
6
app/api/2fa/disable/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/2fa/enable/route.ts
Normal file
6
app/api/2fa/enable/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "2fa/enable");
|
||||||
|
}
|
||||||
6
app/api/2fa/generate/route.ts
Normal file
6
app/api/2fa/generate/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "2fa/generate");
|
||||||
|
}
|
||||||
@ -1,12 +1,6 @@
|
|||||||
export async function POST() {
|
import { NextRequest } from "next/server";
|
||||||
const res = await fetch("http://localhost:3051/api/accounts/link", {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
method: "POST"
|
|
||||||
});
|
export async function GET(req: NextRequest) {
|
||||||
const body = await res.text();
|
return proxyRequest(req, "accounts/link-token");
|
||||||
return new Response(body, {
|
}
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const res = await fetch(`${baseUrl}/api/accounts/manual`, {
|
export async function POST(req: NextRequest) {
|
||||||
method: "POST",
|
return proxyRequest(req, "accounts/manual");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const url = new URL(req.url);
|
|
||||||
const query = url.searchParams.toString();
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/accounts${query ? `?${query}` : ""}`);
|
return proxyRequest(req, "accounts");
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/api/auth/forgot-password/route.ts
Normal file
6
app/api/auth/forgot-password/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/forgot-password");
|
||||||
|
}
|
||||||
@ -1,16 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
export async function POST(req: NextRequest) {
|
||||||
method: "POST",
|
return proxyRequest(req, "auth/login");
|
||||||
headers: { "Content-Type": "application/json" },
|
}
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
6
app/api/auth/logout/route.ts
Normal file
6
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/logout");
|
||||||
|
}
|
||||||
6
app/api/auth/me/route.ts
Normal file
6
app/api/auth/me/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/me");
|
||||||
|
}
|
||||||
@ -1,17 +1,10 @@
|
|||||||
export async function PATCH(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const auth = req.headers.get("authorization") ?? "";
|
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/auth/profile`, {
|
return proxyRequest(req, "auth/me");
|
||||||
method: "PATCH",
|
}
|
||||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
||||||
body
|
export async function PATCH(req: NextRequest) {
|
||||||
});
|
return proxyRequest(req, "auth/profile");
|
||||||
const payload = await res.text();
|
}
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
6
app/api/auth/refresh/route.ts
Normal file
6
app/api/auth/refresh/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/refresh");
|
||||||
|
}
|
||||||
@ -1,26 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const res = await fetch(`${baseUrl}/api/auth/register`, {
|
export async function POST(req: NextRequest) {
|
||||||
method: "POST",
|
return proxyRequest(req, "auth/register");
|
||||||
headers: { "Content-Type": "application/json" },
|
}
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
message: "POST JSON { email, password } to /api/auth/register."
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
6
app/api/auth/reset-password/route.ts
Normal file
6
app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/reset-password");
|
||||||
|
}
|
||||||
6
app/api/auth/verify-email/route.ts
Normal file
6
app/api/auth/verify-email/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/verify-email");
|
||||||
|
}
|
||||||
@ -1,11 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const url = new URL(req.url);
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const res = await fetch(`http://localhost:3051/api/exports/csv${url.search}`);
|
|
||||||
const body = await res.text();
|
export async function GET(req: NextRequest) {
|
||||||
return new Response(body, {
|
return proxyRequest(req, "exports/csv");
|
||||||
status: res.status,
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
6
app/api/exports/sheets/route.ts
Normal file
6
app/api/exports/sheets/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "exports/sheets");
|
||||||
|
}
|
||||||
6
app/api/google/connect/route.ts
Normal file
6
app/api/google/connect/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "google/connect");
|
||||||
|
}
|
||||||
6
app/api/google/disconnect/route.ts
Normal file
6
app/api/google/disconnect/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/google/exchange/route.ts
Normal file
6
app/api/google/exchange/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "google/exchange");
|
||||||
|
}
|
||||||
6
app/api/google/status/route.ts
Normal file
6
app/api/google/status/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "google/status");
|
||||||
|
}
|
||||||
@ -1,16 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const res = await fetch(`${baseUrl}/api/plaid/exchange`, {
|
export async function POST(req: NextRequest) {
|
||||||
method: "POST",
|
return proxyRequest(req, "plaid/exchange");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,6 @@
|
|||||||
export async function POST() {
|
import { NextRequest } from "next/server";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const res = await fetch(`${baseUrl}/api/plaid/link-token`, {
|
|
||||||
method: "POST",
|
export async function POST(req: NextRequest) {
|
||||||
headers: { "Content-Type": "application/json" }
|
return proxyRequest(req, "plaid/link-token");
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,10 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const url = new URL(req.url);
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const res = await fetch(`http://localhost:3051/api/rules${url.search}`);
|
|
||||||
const body = await res.text();
|
export async function GET(req: NextRequest) {
|
||||||
return new Response(body, {
|
return proxyRequest(req, "rules");
|
||||||
status: res.status,
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
export async function POST(req: NextRequest) {
|
||||||
}
|
return proxyRequest(req, "rules");
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const body = await req.text();
|
|
||||||
const res = await fetch("http://localhost:3051/api/rules", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const url = new URL(req.url);
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const res = await fetch(`http://localhost:3051/api/rules/suggestions${url.search}`);
|
|
||||||
const body = await res.text();
|
export async function GET(req: NextRequest) {
|
||||||
return new Response(body, {
|
return proxyRequest(req, "rules/suggestions");
|
||||||
status: res.status,
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
6
app/api/stripe/checkout/route.ts
Normal file
6
app/api/stripe/checkout/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "stripe/checkout");
|
||||||
|
}
|
||||||
6
app/api/stripe/portal/route.ts
Normal file
6
app/api/stripe/portal/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "stripe/portal");
|
||||||
|
}
|
||||||
6
app/api/stripe/subscription/route.ts
Normal file
6
app/api/stripe/subscription/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "stripe/subscription");
|
||||||
|
}
|
||||||
@ -1,15 +1,9 @@
|
|||||||
export async function POST(
|
import { NextRequest } from "next/server";
|
||||||
_req: Request,
|
import { proxyRequest } from "@/lib/backend";
|
||||||
context: { params: { id: string } }
|
|
||||||
) {
|
export async function GET(
|
||||||
const res = await fetch(`http://localhost:3051/api/tax/returns/${context.params.id}/export`, {
|
req: NextRequest,
|
||||||
method: "POST"
|
{ params }: { params: { id: string } }
|
||||||
});
|
) {
|
||||||
const body = await res.text();
|
return proxyRequest(req, `tax/returns/${params.id}/export`);
|
||||||
return new Response(body, {
|
}
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,27 +1,10 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const url = new URL(req.url);
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const res = await fetch(`http://localhost:3051/api/tax/returns${url.search}`);
|
|
||||||
const body = await res.text();
|
export async function GET(req: NextRequest) {
|
||||||
return new Response(body, {
|
return proxyRequest(req, "tax/returns");
|
||||||
status: res.status,
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
export async function POST(req: NextRequest) {
|
||||||
}
|
return proxyRequest(req, "tax/returns");
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const body = await req.text();
|
|
||||||
const res = await fetch("http://localhost:3051/api/tax/returns", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,27 +1,9 @@
|
|||||||
export async function PATCH(req: Request, context: { params: { id: string } }) {
|
import { NextRequest } from "next/server";
|
||||||
try {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const payload = await req.text();
|
export async function PATCH(
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/${context.params.id}/derived`, {
|
req: NextRequest,
|
||||||
method: "PATCH",
|
{ params }: { params: { id: string } }
|
||||||
headers: { "Content-Type": "application/json" },
|
) {
|
||||||
body: payload
|
return proxyRequest(req, `transactions/${params.id}/derived`);
|
||||||
});
|
|
||||||
const body = await res.text();
|
|
||||||
return new Response(body, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: null,
|
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
||||||
error: { message: "Backend unavailable." }
|
|
||||||
}),
|
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
try {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const url = new URL(req.url);
|
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/cashflow${url.search}`);
|
return proxyRequest(req, "transactions/cashflow");
|
||||||
const body = await res.text();
|
|
||||||
return new Response(body, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: [],
|
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
||||||
error: { message: "Backend unavailable." }
|
|
||||||
}),
|
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/api/transactions/import/route.ts
Normal file
6
app/api/transactions/import/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "transactions/import");
|
||||||
|
}
|
||||||
@ -1,27 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
try {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const payload = await req.text();
|
export async function POST(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/manual`, {
|
return proxyRequest(req, "transactions/manual");
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
const body = await res.text();
|
|
||||||
return new Response(body, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: null,
|
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
||||||
error: { message: "Backend unavailable." }
|
|
||||||
}),
|
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
try {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const url = new URL(req.url);
|
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/merchants${url.search}`);
|
return proxyRequest(req, "transactions/merchants");
|
||||||
const body = await res.text();
|
|
||||||
return new Response(body, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: [],
|
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
||||||
error: { message: "Backend unavailable." }
|
|
||||||
}),
|
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,10 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
try {
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const url = new URL(req.url);
|
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/transactions${url.search}`);
|
return proxyRequest(req, "transactions");
|
||||||
const body = await res.text();
|
}
|
||||||
return new Response(body, {
|
|
||||||
status: res.status,
|
export async function POST(req: NextRequest) {
|
||||||
headers: {
|
return proxyRequest(req, "transactions");
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: [],
|
|
||||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
|
||||||
error: { message: "Backend unavailable." }
|
|
||||||
}),
|
|
||||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
export async function GET(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const url = new URL(req.url);
|
|
||||||
const query = url.searchParams.toString();
|
export async function GET(req: NextRequest) {
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/summary${query ? `?${query}` : ""}`);
|
return proxyRequest(req, "transactions/summary");
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
export async function POST(req: Request) {
|
import { NextRequest } from "next/server";
|
||||||
const body = await req.text();
|
import { proxyRequest } from "@/lib/backend";
|
||||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
|
||||||
const res = await fetch(`${baseUrl}/api/transactions/sync`, {
|
export async function POST(req: NextRequest) {
|
||||||
method: "POST",
|
return proxyRequest(req, "transactions/sync");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body
|
|
||||||
});
|
|
||||||
const payload = await res.text();
|
|
||||||
return new Response(payload, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
115
app/app/page.tsx
115
app/app/page.tsx
@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { AppShell } from "../../components/app-shell";
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
@ -31,36 +32,14 @@ type MerchantInsight = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const recentTransactions = [
|
type TxRow = {
|
||||||
{
|
id: string;
|
||||||
date: "Oct 24, 2023",
|
date: string;
|
||||||
description: "Whole Foods Market",
|
description: string;
|
||||||
category: "Groceries",
|
category?: string | null;
|
||||||
account: "Chase Sapphire",
|
accountId?: string | null;
|
||||||
amount: "-$142.30"
|
amount: string;
|
||||||
},
|
};
|
||||||
{
|
|
||||||
date: "Oct 23, 2023",
|
|
||||||
description: "Apple Subscription",
|
|
||||||
category: "Services",
|
|
||||||
account: "Apple Card",
|
|
||||||
amount: "-$14.99"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "Oct 22, 2023",
|
|
||||||
description: "Stripe Payout",
|
|
||||||
category: "Income",
|
|
||||||
account: "Mercury Business",
|
|
||||||
amount: "+$4,200.00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: "Oct 22, 2023",
|
|
||||||
description: "Shell Gasoline",
|
|
||||||
category: "Transport",
|
|
||||||
account: "Chase Sapphire",
|
|
||||||
amount: "-$52.12"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
function formatMonthLabel(yyyyMm: string) {
|
function formatMonthLabel(yyyyMm: string) {
|
||||||
@ -520,27 +499,23 @@ export default function AppHomePage() {
|
|||||||
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
|
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
|
||||||
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
|
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
|
||||||
const [accountCount, setAccountCount] = useState<number | null>(null);
|
const [accountCount, setAccountCount] = useState<number | null>(null);
|
||||||
|
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = `?user_id=${encodeURIComponent(userId)}`;
|
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>),
|
apiFetch<Summary>("/api/transactions/summary"),
|
||||||
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>),
|
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
||||||
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>),
|
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
||||||
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>)
|
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
|
||||||
|
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
|
||||||
])
|
])
|
||||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
|
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
||||||
if (!summaryRes.error) setSummary(summaryRes.data);
|
if (!summaryRes.error) setSummary(summaryRes.data);
|
||||||
if (!cashflowRes.error) setCashflow(cashflowRes.data);
|
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||||
if (!merchantsRes.error) setMerchants(merchantsRes.data);
|
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
||||||
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
|
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
|
||||||
|
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@ -743,26 +718,48 @@ export default function AppHomePage() {
|
|||||||
<th className="pb-3 pl-2">Date</th>
|
<th className="pb-3 pl-2">Date</th>
|
||||||
<th className="pb-3">Description</th>
|
<th className="pb-3">Description</th>
|
||||||
<th className="pb-3">Category</th>
|
<th className="pb-3">Category</th>
|
||||||
<th className="pb-3">Account</th>
|
|
||||||
<th className="pb-3 pr-2 text-right">Amount</th>
|
<th className="pb-3 pr-2 text-right">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentTransactions.map((tx) => (
|
{loading ? (
|
||||||
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
<td className="py-3 pl-2 font-medium">{tx.date}</td>
|
<tr key={i} className="border-b border-border">
|
||||||
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
<td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
|
||||||
<td className="py-3">
|
<td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
|
||||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
|
<td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
|
||||||
{tx.category}
|
<td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
|
||||||
</span>
|
</tr>
|
||||||
</td>
|
))
|
||||||
<td className="py-3">{tx.account}</td>
|
) : recentTxs.length ? (
|
||||||
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}>
|
recentTxs.map((tx) => {
|
||||||
{tx.amount}
|
const amt = Number.parseFloat(tx.amount ?? "0");
|
||||||
|
const fmtAmt = formatCurrency(amt);
|
||||||
|
const isIncome = amt >= 0;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||||
|
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
|
||||||
|
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
{tx.category ? (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
|
||||||
|
{tx.category}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-muted-foreground">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className={`py-3 pr-2 text-right font-bold ${isIncome ? "text-primary" : "text-foreground"}`}>
|
||||||
|
{fmtAmt}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||||
|
No transactions yet. Connect a bank account to get started.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
app/auth/google/callback/page.tsx
Normal file
85
app/auth/google/callback/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,106 +1,106 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ContactSection } from "../../../components/contact-section";
|
import { ContactSection } from "../../../components/contact-section";
|
||||||
import { DemoCta } from "../../../components/demo-cta";
|
import { DemoCta } from "../../../components/demo-cta";
|
||||||
import { FaqSection } from "../../../components/faq-section";
|
import { FaqSection } from "../../../components/faq-section";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { blogPosts } from "../../../data/blog";
|
import { blogPosts } from "../../../data/blog";
|
||||||
import { defaultFaqs } from "../../../data/faq";
|
import { defaultFaqs } from "../../../data/faq";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { slug: string };
|
params: { slug: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return blogPosts.map((post) => ({ slug: post.slug }));
|
return blogPosts.map((post) => ({ slug: post.slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateMetadata({ params }: PageProps) {
|
export function generateMetadata({ params }: PageProps) {
|
||||||
const post = blogPosts.find((item) => item.slug === params.slug);
|
const post = blogPosts.find((item) => item.slug === params.slug);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return { title: "Blog Post" };
|
return { title: "Blog Post" };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
description: post.excerpt,
|
description: post.excerpt,
|
||||||
keywords: [...siteInfo.keywords, "finance blog", "ledger insights"]
|
keywords: [...siteInfo.keywords, "finance blog", "ledger insights"]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogPostPage({ params }: PageProps) {
|
export default function BlogPostPage({ params }: PageProps) {
|
||||||
const post = blogPosts.find((item) => item.slug === params.slug);
|
const post = blogPosts.find((item) => item.slug === params.slug);
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: post.title,
|
headline: post.title,
|
||||||
description: post.excerpt,
|
description: post.excerpt,
|
||||||
datePublished: post.date,
|
datePublished: post.date,
|
||||||
author: {
|
author: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: siteInfo.name
|
name: siteInfo.name
|
||||||
},
|
},
|
||||||
image: post.image,
|
image: post.image,
|
||||||
mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}`
|
mainEntityOfPage: `${siteInfo.url}/blog/${post.slug}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "FAQPage",
|
"@type": "FAQPage",
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
"@type": "Question",
|
"@type": "Question",
|
||||||
name: item.question,
|
name: item.question,
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="marketing min-h-screen">
|
<div className="marketing min-h-screen">
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<div className="halo absolute inset-0" />
|
<div className="halo absolute inset-0" />
|
||||||
<div className="grid-dots absolute inset-0" />
|
<div className="grid-dots absolute inset-0" />
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12">
|
<main className="relative mx-auto max-w-4xl px-6 pb-20 pt-12">
|
||||||
<Link className="text-sm font-semibold text-ink" href="/blog">
|
<Link className="text-sm font-semibold text-ink" href="/blog">
|
||||||
<- Back to blog
|
<- Back to blog
|
||||||
</Link>
|
</Link>
|
||||||
<article className="mt-6 rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
|
<article className="mt-6 rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">
|
<p className="text-xs uppercase tracking-[0.3em] text-muted">
|
||||||
{post.date} - {post.readTime}
|
{post.date} - {post.readTime}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-3 text-3xl font-semibold">{post.title}</h1>
|
<h1 className="mt-3 text-3xl font-semibold">{post.title}</h1>
|
||||||
<p className="mt-4 text-sm text-muted">{post.excerpt}</p>
|
<p className="mt-4 text-sm text-muted">{post.excerpt}</p>
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border border-ink/10">
|
<div className="mt-6 overflow-hidden rounded-2xl border border-ink/10">
|
||||||
<Image
|
<Image
|
||||||
src={post.image}
|
src={post.image}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
width={1200}
|
width={1200}
|
||||||
height={900}
|
height={900}
|
||||||
className="h-64 w-full object-cover"
|
className="h-64 w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 space-y-4 text-sm text-muted">
|
<div className="mt-6 space-y-4 text-sm text-muted">
|
||||||
{post.content.map((paragraph) => (
|
{post.content.map((paragraph) => (
|
||||||
<p key={paragraph}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<DemoCta />
|
<DemoCta />
|
||||||
<FaqSection limit={8} />
|
<FaqSection limit={8} />
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,109 +1,109 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageSchema } from "../../components/page-schema";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { siteInfo } from "../../data/site";
|
import { siteInfo } from "../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Blog",
|
title: "Blog",
|
||||||
description: "Insights on financial control, audit readiness, and ledger automation.",
|
description: "Insights on financial control, audit readiness, and ledger automation.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
const posts = [
|
const posts = [
|
||||||
{
|
{
|
||||||
title: "Why 'Audit-Ready' Matters More Than Ever",
|
title: "Why 'Audit-Ready' Matters More Than Ever",
|
||||||
excerpt:
|
excerpt:
|
||||||
"As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.",
|
"As regulatory scrutiny increases, the ability to produce a clean, traceable ledger is becoming a competitive advantage.",
|
||||||
date: "Oct 24, 2023",
|
date: "Oct 24, 2023",
|
||||||
readTime: "5 min read",
|
readTime: "5 min read",
|
||||||
slug: "audit-ready-matters"
|
slug: "audit-ready-matters"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "The Hidden Cost of Spreadsheet Chaos",
|
title: "The Hidden Cost of Spreadsheet Chaos",
|
||||||
excerpt:
|
excerpt:
|
||||||
"Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.",
|
"Manual reconciliation isn't just slow—it's a liability. Here's how to move to a system of record.",
|
||||||
date: "Oct 12, 2023",
|
date: "Oct 12, 2023",
|
||||||
readTime: "4 min read",
|
readTime: "4 min read",
|
||||||
slug: "spreadsheet-chaos"
|
slug: "spreadsheet-chaos"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Automating the Month-End Close",
|
title: "Automating the Month-End Close",
|
||||||
excerpt:
|
excerpt:
|
||||||
"How to use rules and categories to reduce your close time from days to hours.",
|
"How to use rules and categories to reduce your close time from days to hours.",
|
||||||
date: "Sep 28, 2023",
|
date: "Sep 28, 2023",
|
||||||
readTime: "6 min read",
|
readTime: "6 min read",
|
||||||
slug: "automating-month-end"
|
slug: "automating-month-end"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne Blog",
|
name: "LedgerOne Blog",
|
||||||
description: "Insights on financial control, audit readiness, and ledger automation.",
|
description: "Insights on financial control, audit readiness, and ledger automation.",
|
||||||
url: `${siteInfo.url}/blog`
|
url: `${siteInfo.url}/blog`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Blog",
|
"@type": "Blog",
|
||||||
blogPost: posts.map((post) => ({
|
blogPost: posts.map((post) => ({
|
||||||
"@type": "BlogPosting",
|
"@type": "BlogPosting",
|
||||||
headline: post.title,
|
headline: post.title,
|
||||||
description: post.excerpt,
|
description: post.excerpt,
|
||||||
datePublished: post.date,
|
datePublished: post.date,
|
||||||
url: `${siteInfo.url}/blog/${post.slug}`
|
url: `${siteInfo.url}/blog/${post.slug}`
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
|
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
|
||||||
Insights for modern finance teams.
|
Insights for modern finance teams.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-lg text-muted-foreground">
|
<p className="mt-6 text-lg text-muted-foreground">
|
||||||
Best practices for financial control, audit readiness, and automation.
|
Best practices for financial control, audit readiness, and automation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link key={post.slug} href={`/blog/${post.slug}`} className="group">
|
<Link key={post.slug} href={`/blog/${post.slug}`} className="group">
|
||||||
<div className="glass-panel h-full rounded-2xl p-8 shadow-sm transition-all hover:shadow-md hover:-translate-y-1">
|
<div className="glass-panel h-full rounded-2xl p-8 shadow-sm transition-all hover:shadow-md hover:-translate-y-1">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
|
||||||
<time dateTime={post.date}>{post.date}</time>
|
<time dateTime={post.date}>{post.date}</time>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{post.readTime}</span>
|
<span>{post.readTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3">
|
<h3 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors mb-3">
|
||||||
{post.title}
|
{post.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
{post.excerpt}
|
{post.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex items-center text-primary font-medium text-sm">
|
<div className="mt-6 flex items-center text-primary font-medium text-sm">
|
||||||
Read article
|
Read article
|
||||||
<svg className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
|
<svg className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,132 +5,132 @@ import { FaqSection } from "../../components/faq-section";
|
|||||||
import { PageSchema } from "../../components/page-schema";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { defaultFaqs } from "../../data/faq";
|
import { defaultFaqs } from "../../data/faq";
|
||||||
import { siteInfo } from "../../data/site";
|
import { siteInfo } from "../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Book a Demo",
|
title: "Book a Demo",
|
||||||
description:
|
description:
|
||||||
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BookDemoPage() {
|
export default function BookDemoPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "Book a LedgerOne Demo",
|
name: "Book a LedgerOne Demo",
|
||||||
description:
|
description:
|
||||||
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
|
||||||
url: `${siteInfo.url}/book-demo`
|
url: `${siteInfo.url}/book-demo`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "FAQPage",
|
"@type": "FAQPage",
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
"@type": "Question",
|
"@type": "Question",
|
||||||
name: item.question,
|
name: item.question,
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="marketing min-h-screen">
|
<div className="marketing min-h-screen">
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<div className="halo absolute inset-0" />
|
<div className="halo absolute inset-0" />
|
||||||
<div className="grid-dots absolute inset-0" />
|
<div className="grid-dots absolute inset-0" />
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="relative mx-auto max-w-6xl px-6 pb-12 pt-12">
|
<main className="relative mx-auto max-w-6xl px-6 pb-12 pt-12">
|
||||||
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-xs uppercase tracking-[0.4em] text-muted">Book a demo</p>
|
<p className="text-xs uppercase tracking-[0.4em] text-muted">Book a demo</p>
|
||||||
<h1 className="text-4xl font-semibold leading-tight">
|
<h1 className="text-4xl font-semibold leading-tight">
|
||||||
Schedule time with the LedgerOne team.
|
Schedule time with the LedgerOne team.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
We will walk you through account connections, rule automation, and
|
We will walk you through account connections, rule automation, and
|
||||||
audit-ready exports based on your workflow.
|
audit-ready exports based on your workflow.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
|
<div className="rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
|
||||||
<form className="grid gap-4 md:grid-cols-2">
|
<form className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Full name
|
Full name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Work email
|
Work email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Preferred date
|
Preferred date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Team size
|
Team size
|
||||||
</label>
|
</label>
|
||||||
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
|
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
|
||||||
<option>1-5</option>
|
<option>1-5</option>
|
||||||
<option>6-20</option>
|
<option>6-20</option>
|
||||||
<option>21-50</option>
|
<option>21-50</option>
|
||||||
<option>50+</option>
|
<option>50+</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
What should we focus oni
|
What should we focus oni
|
||||||
</label>
|
</label>
|
||||||
<textarea className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" />
|
<textarea className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
||||||
>
|
>
|
||||||
Request demo
|
Request demo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-xs text-muted">
|
<p className="mt-4 text-xs text-muted">
|
||||||
Prefer emaili Reach us at{" "}
|
Prefer emaili Reach us at{" "}
|
||||||
<Link className="underline" href="/contact">
|
<Link className="underline" href="/contact">
|
||||||
the contact page
|
the contact page
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
|
<div className="space-y-4 rounded-3xl border border-ink/10 bg-white/80 p-6 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">What you'll see</p>
|
<p className="text-xs uppercase tracking-[0.3em] text-muted">What you'll see</p>
|
||||||
<ul className="space-y-3 text-sm text-muted">
|
<ul className="space-y-3 text-sm text-muted">
|
||||||
<li>Connect accounts and review the raw ledger flow.</li>
|
<li>Connect accounts and review the raw ledger flow.</li>
|
||||||
<li>Watch rule automation run and inspect the audit trail.</li>
|
<li>Watch rule automation run and inspect the audit trail.</li>
|
||||||
<li>Export a complete ledger package ready for review.</li>
|
<li>Export a complete ledger package ready for review.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
<DemoCta />
|
<DemoCta />
|
||||||
@ -138,5 +138,5 @@ export default function BookDemoPage() {
|
|||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,86 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Copilot",
|
title: "LedgerOne vs Copilot",
|
||||||
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
|
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompareCopilotPage() {
|
export default function CompareCopilotPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Copilot",
|
name: "LedgerOne vs Copilot",
|
||||||
description: "Comparison of LedgerOne and Copilot.",
|
description: "Comparison of LedgerOne and Copilot.",
|
||||||
url: `${siteInfo.url}/compare/vs-copilot`
|
url: `${siteInfo.url}/compare/vs-copilot`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Comparison
|
Comparison
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
||||||
Financial control for everyone.
|
Financial control for everyone.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device.
|
Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
<div className="col-span-1">Feature</div>
|
<div className="col-span-1">Feature</div>
|
||||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||||
<div className="col-span-1 text-center">Copilot</div>
|
<div className="col-span-1 text-center">Copilot</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" },
|
{ feature: "Accessibility", l1: "Web, iOS, Android", sheet: "Mac & iOS Only" },
|
||||||
{ feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" },
|
{ feature: "Target Audience", l1: "Business & Prosumer", sheet: "Personal Finance" },
|
||||||
{ feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" },
|
{ feature: "Reporting", l1: "Custom Report Builder", sheet: "Standard Views" },
|
||||||
{ feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" },
|
{ feature: "Exports", l1: "Audit-Ready CSV/PDF", sheet: "Basic CSV" },
|
||||||
{ feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" },
|
{ feature: "Team Access", l1: "Multi-User Permissions", sheet: "Single User" },
|
||||||
{ feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" },
|
{ feature: "AI Intelligence", l1: "Business Insights", sheet: "Spending Categorization" },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
||||||
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
||||||
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
{row.l1}
|
{row.l1}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 text-center text-muted-foreground">
|
<div className="col-span-1 text-center text-muted-foreground">
|
||||||
{row.sheet}
|
{row.sheet}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-center">
|
<div className="mt-16 text-center">
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2>
|
<h2 className="text-2xl font-bold text-foreground mb-6">Work from anywhere.</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start your free trial
|
Start your free trial
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,86 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Quicken",
|
title: "LedgerOne vs Quicken",
|
||||||
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
|
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompareQuickenPage() {
|
export default function CompareQuickenPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Quicken",
|
name: "LedgerOne vs Quicken",
|
||||||
description: "Comparison of LedgerOne and Quicken.",
|
description: "Comparison of LedgerOne and Quicken.",
|
||||||
url: `${siteInfo.url}/compare/vs-quicken`
|
url: `${siteInfo.url}/compare/vs-quicken`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Comparison
|
Comparison
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
||||||
The modern alternative to Quicken.
|
The modern alternative to Quicken.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web.
|
Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
<div className="col-span-1">Feature</div>
|
<div className="col-span-1">Feature</div>
|
||||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||||
<div className="col-span-1 text-center">Quicken</div>
|
<div className="col-span-1 text-center">Quicken</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" },
|
{ feature: "Platform", l1: "Cloud-Native (Web & Mobile)", sheet: "Desktop-First" },
|
||||||
{ feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" },
|
{ feature: "Bank Sync", l1: "Real-time API (Plaid)", sheet: "Direct Connect / Web Connect" },
|
||||||
{ feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" },
|
{ feature: "Interface", l1: "Modern, Fast, Clean", sheet: "Legacy / Cluttered" },
|
||||||
{ feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" },
|
{ feature: "Collaboration", l1: "Real-time Multi-user", sheet: "File Sharing" },
|
||||||
{ feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" },
|
{ feature: "Updates", l1: "Instant & Automatic", sheet: "Annual Versions" },
|
||||||
{ feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" },
|
{ feature: "Support", l1: "In-app Chat", sheet: "Phone / Email" },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
||||||
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
||||||
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
{row.l1}
|
{row.l1}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 text-center text-muted-foreground">
|
<div className="col-span-1 text-center text-muted-foreground">
|
||||||
{row.sheet}
|
{row.sheet}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-center">
|
<div className="mt-16 text-center">
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2>
|
<h2 className="text-2xl font-bold text-foreground mb-6">Upgrade your finance stack.</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start your free trial
|
Start your free trial
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +1,87 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Spreadsheets",
|
title: "LedgerOne vs Spreadsheets",
|
||||||
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
|
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompareSpreadsheetsPage() {
|
export default function CompareSpreadsheetsPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Spreadsheets",
|
name: "LedgerOne vs Spreadsheets",
|
||||||
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
|
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
|
||||||
url: `${siteInfo.url}/compare/vs-spreadsheets`
|
url: `${siteInfo.url}/compare/vs-spreadsheets`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Comparison
|
Comparison
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
||||||
Stop breaking your spreadsheets.
|
Stop breaking your spreadsheets.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs.
|
Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
<div className="col-span-1">Feature</div>
|
<div className="col-span-1">Feature</div>
|
||||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||||
<div className="col-span-1 text-center">Spreadsheets</div>
|
<div className="col-span-1 text-center">Spreadsheets</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ feature: "Bank Connections", l1: "Automatic (11,000+ banks)", sheet: "Manual CSV imports" },
|
{ feature: "Bank Connections", l1: "Automatic (11,000+ banks)", sheet: "Manual CSV imports" },
|
||||||
{ feature: "Transaction Categorization", l1: "AI-powered & Rules", sheet: "Manual entry" },
|
{ feature: "Transaction Categorization", l1: "AI-powered & Rules", sheet: "Manual entry" },
|
||||||
{ feature: "Security", l1: "Bank-level encryption (SOC2)", sheet: "Password protected file" },
|
{ feature: "Security", l1: "Bank-level encryption (SOC2)", sheet: "Password protected file" },
|
||||||
{ feature: "Mobile Access", l1: "Native iOS & Android apps", sheet: "Clunky mobile view" },
|
{ feature: "Mobile Access", l1: "Native iOS & Android apps", sheet: "Clunky mobile view" },
|
||||||
{ feature: "Collaboration", l1: "Multi-user with permissions", sheet: "Version control nightmares" },
|
{ feature: "Collaboration", l1: "Multi-user with permissions", sheet: "Version control nightmares" },
|
||||||
{ feature: "Reporting", l1: "Instant, interactive charts", sheet: "Manual chart building" },
|
{ feature: "Reporting", l1: "Instant, interactive charts", sheet: "Manual chart building" },
|
||||||
{ feature: "Audit Trail", l1: "Immutable change logs", sheet: "None" },
|
{ feature: "Audit Trail", l1: "Immutable change logs", sheet: "None" },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
||||||
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
||||||
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
{row.l1}
|
{row.l1}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 text-center text-muted-foreground">
|
<div className="col-span-1 text-center text-muted-foreground">
|
||||||
{row.sheet}
|
{row.sheet}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-center">
|
<div className="mt-16 text-center">
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to upgrade?</h2>
|
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to upgrade?</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start your free trial
|
Start your free trial
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,86 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs YNAB",
|
title: "LedgerOne vs YNAB",
|
||||||
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CompareYnabPage() {
|
export default function CompareYnabPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs YNAB",
|
name: "LedgerOne vs YNAB",
|
||||||
description: "Comparison of LedgerOne and YNAB.",
|
description: "Comparison of LedgerOne and YNAB.",
|
||||||
url: `${siteInfo.url}/compare/vs-ynab`
|
url: `${siteInfo.url}/compare/vs-ynab`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Comparison
|
Comparison
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
|
||||||
Beyond Budgeting.
|
Beyond Budgeting.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control.
|
YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
||||||
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
<div className="col-span-1">Feature</div>
|
<div className="col-span-1">Feature</div>
|
||||||
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
|
||||||
<div className="col-span-1 text-center">YNAB</div>
|
<div className="col-span-1 text-center">YNAB</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" },
|
{ feature: "Primary Focus", l1: "Business & Wealth Growth", sheet: "Zero-Based Budgeting" },
|
||||||
{ feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" },
|
{ feature: "Audit Trail", l1: "Immutable Logs", sheet: "None" },
|
||||||
{ feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" },
|
{ feature: "Forecasting", l1: "Cash Flow Projections", sheet: "Current Cash Only" },
|
||||||
{ feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" },
|
{ feature: "Collaboration", l1: "Accountant & Team Access", sheet: "Partner Sharing" },
|
||||||
{ feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" },
|
{ feature: "Reporting", l1: "P&L, Tax-Ready Exports", sheet: "Spending Reports" },
|
||||||
{ feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" },
|
{ feature: "Asset Tracking", l1: "Real Estate, Crypto, Equity", sheet: "Manual Accounts" },
|
||||||
].map((row, i) => (
|
].map((row, i) => (
|
||||||
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${i % 2 === 0 ? 'bg-background/50' : 'bg-secondary/10'}`}>
|
||||||
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
|
||||||
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
{row.l1}
|
{row.l1}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 text-center text-muted-foreground">
|
<div className="col-span-1 text-center text-muted-foreground">
|
||||||
{row.sheet}
|
{row.sheet}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 text-center">
|
<div className="mt-16 text-center">
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2>
|
<h2 className="text-2xl font-bold text-foreground mb-6">Ready to scale?</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start your free trial
|
Start your free trial
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "../../components/app-shell";
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
type ApiResponse<T> = {
|
|
||||||
data: T;
|
|
||||||
meta: { timestamp: string; version: "v1" };
|
|
||||||
error: null | { message: string; code?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExportData = { status: string; csv?: string; rowCount?: number };
|
type ExportData = { status: string; csv?: string; rowCount?: number };
|
||||||
|
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
|
||||||
|
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
|
||||||
|
|
||||||
export default function ExportsPage() {
|
export default function ExportsPage() {
|
||||||
const [status, setStatus] = useState("");
|
const [csvStatus, setCsvStatus] = useState("");
|
||||||
|
const [sheetsStatus, setSheetsStatus] = useState("");
|
||||||
|
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
|
||||||
|
const [sheetsLoading, setSheetsLoading] = useState(false);
|
||||||
const [datePreset, setDatePreset] = useState("custom");
|
const [datePreset, setDatePreset] = useState("custom");
|
||||||
|
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
|
||||||
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
startDate: "",
|
startDate: "",
|
||||||
endDate: "",
|
endDate: "",
|
||||||
@ -21,14 +23,18 @@ export default function ExportsPage() {
|
|||||||
maxAmount: "",
|
maxAmount: "",
|
||||||
category: "",
|
category: "",
|
||||||
source: "",
|
source: "",
|
||||||
includeHidden: false
|
includeHidden: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
|
||||||
|
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyPreset = (preset: string) => {
|
const applyPreset = (preset: string) => {
|
||||||
setDatePreset(preset);
|
setDatePreset(preset);
|
||||||
if (preset === "custom") {
|
if (preset === "custom") return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
let start = new Date(end);
|
let start = new Date(end);
|
||||||
@ -43,101 +49,104 @@ export default function ExportsPage() {
|
|||||||
start = new Date(end.getFullYear() - 1, 0, 1);
|
start = new Date(end.getFullYear() - 1, 0, 1);
|
||||||
end.setMonth(11, 31);
|
end.setMonth(11, 31);
|
||||||
}
|
}
|
||||||
const format = (value: Date) => value.toISOString().slice(0, 10);
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
|
||||||
...prev,
|
|
||||||
startDate: format(start),
|
|
||||||
endDate: format(end)
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onExport = async () => {
|
const buildParams = () => {
|
||||||
setStatus("Generating export...");
|
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (userId) {
|
if (filters.startDate) params.set("start_date", filters.startDate);
|
||||||
params.set("user_id", userId);
|
if (filters.endDate) params.set("end_date", filters.endDate);
|
||||||
}
|
if (filters.minAmount) params.set("min_amount", filters.minAmount);
|
||||||
if (filters.startDate) {
|
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
|
||||||
params.set("start_date", filters.startDate);
|
if (filters.category) params.set("category", filters.category);
|
||||||
}
|
if (filters.source) params.set("source", filters.source);
|
||||||
if (filters.endDate) {
|
if (filters.includeHidden) params.set("include_hidden", "true");
|
||||||
params.set("end_date", filters.endDate);
|
return params;
|
||||||
}
|
};
|
||||||
if (filters.minAmount) {
|
|
||||||
params.set("min_amount", filters.minAmount);
|
const onExportCsv = async () => {
|
||||||
}
|
setCsvStatus("Generating export...");
|
||||||
if (filters.maxAmount) {
|
const params = buildParams();
|
||||||
params.set("max_amount", filters.maxAmount);
|
|
||||||
}
|
|
||||||
if (filters.category) {
|
|
||||||
params.set("category", filters.category);
|
|
||||||
}
|
|
||||||
if (filters.source) {
|
|
||||||
params.set("source", filters.source);
|
|
||||||
}
|
|
||||||
if (filters.includeHidden) {
|
|
||||||
params.set("include_hidden", "true");
|
|
||||||
}
|
|
||||||
const query = params.toString() ? `?${params.toString()}` : "";
|
const query = params.toString() ? `?${params.toString()}` : "";
|
||||||
try {
|
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
|
||||||
const res = await fetch(`/api/exports/csv${query}`);
|
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
|
||||||
const payload = (await res.json()) as ApiResponse<ExportData>;
|
if (res.data?.csv) {
|
||||||
if (!res.ok || payload.error) {
|
const blob = new Blob([res.data.csv], { type: "text/csv" });
|
||||||
setStatus(payload.error?.message ?? "Export failed.");
|
const url = URL.createObjectURL(blob);
|
||||||
return;
|
const a = document.createElement("a");
|
||||||
}
|
a.href = url;
|
||||||
if (payload.data.csv) {
|
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
const blob = new Blob([payload.data.csv], { type: "text/csv" });
|
a.click();
|
||||||
const url = URL.createObjectURL(blob);
|
URL.revokeObjectURL(url);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
|
||||||
setStatus(`Export ready (${payload.data.rowCount ?? 0} rows).`);
|
} else {
|
||||||
} else {
|
setCsvStatus("Export ready.");
|
||||||
setStatus("Export ready.");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setStatus("Export failed.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onConnectGoogle = async () => {
|
||||||
|
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
|
||||||
|
if (res.error) {
|
||||||
|
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.data?.authUrl) {
|
||||||
|
window.location.href = res.data.authUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisconnectGoogle = async () => {
|
||||||
|
setDisconnecting(true);
|
||||||
|
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
|
||||||
|
setDisconnecting(false);
|
||||||
|
if (!res.error) {
|
||||||
|
setGoogleStatus({ connected: false });
|
||||||
|
setSheetsStatus("Google account disconnected.");
|
||||||
|
setSheetsUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExportSheets = async () => {
|
||||||
|
setSheetsLoading(true);
|
||||||
|
setSheetsStatus("Creating Google Sheet...");
|
||||||
|
setSheetsUrl(null);
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (filters.startDate) body.startDate = filters.startDate;
|
||||||
|
if (filters.endDate) body.endDate = filters.endDate;
|
||||||
|
if (filters.minAmount) body.minAmount = filters.minAmount;
|
||||||
|
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
|
||||||
|
if (filters.category) body.category = filters.category;
|
||||||
|
if (filters.includeHidden) body.includeHidden = true;
|
||||||
|
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setSheetsLoading(false);
|
||||||
|
if (res.error) {
|
||||||
|
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
|
||||||
|
if (url) {
|
||||||
|
setSheetsUrl(url);
|
||||||
|
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
|
||||||
|
} else {
|
||||||
|
setSheetsStatus("Sheet created.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||||
|
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
|
||||||
title="Exports"
|
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
|
||||||
subtitle="Generate CSV datasets with raw and derived fields."
|
{/* Filters */}
|
||||||
>
|
|
||||||
<div className="glass-panel p-8 rounded-2xl shadow-sm">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Start date</label>
|
<label className={labelCls}>Date range</label>
|
||||||
<input
|
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
|
||||||
type="date"
|
|
||||||
value={filters.startDate}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, startDate: event.target.value }))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
disabled={datePreset !== "custom"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">End date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.endDate}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, endDate: event.target.value }))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
disabled={datePreset !== "custom"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Date range</label>
|
|
||||||
<select
|
|
||||||
value={datePreset}
|
|
||||||
onChange={(event) => applyPreset(event.target.value)}
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
<option value="this_month">This month</option>
|
<option value="this_month">This month</option>
|
||||||
<option value="last_month">Last month</option>
|
<option value="last_month">Last month</option>
|
||||||
@ -146,96 +155,114 @@ export default function ExportsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Category contains</label>
|
<label className={labelCls}>Start date</label>
|
||||||
<input
|
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||||
type="text"
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, category: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Dining, Payroll, Utilities"
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Min amount</label>
|
<label className={labelCls}>End date</label>
|
||||||
<input
|
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||||
type="number"
|
|
||||||
value={filters.minAmount}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, minAmount: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="0.00"
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Max amount</label>
|
<label className={labelCls}>Category contains</label>
|
||||||
<input
|
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||||
type="number"
|
|
||||||
value={filters.maxAmount}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, maxAmount: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="10000.00"
|
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Source contains</label>
|
<label className={labelCls}>Min amount ($)</label>
|
||||||
<input
|
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
||||||
type="text"
|
</div>
|
||||||
value={filters.source}
|
<div>
|
||||||
onChange={(event) =>
|
<label className={labelCls}>Max amount ($)</label>
|
||||||
setFilters((prev) => ({ ...prev, source: event.target.value }))
|
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
|
||||||
}
|
</div>
|
||||||
placeholder="plaid"
|
<div className="flex items-end pb-2">
|
||||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||||
/>
|
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||||
|
Include hidden transactions
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
<div className="border-t border-border" />
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground font-medium">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.includeHidden}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
|
|
||||||
}
|
|
||||||
className="rounded border-border text-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
Include hidden transactions
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
{/* Export cards */}
|
||||||
<button
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
type="button"
|
{/* CSV */}
|
||||||
onClick={onExport}
|
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||||
className="rounded-full bg-primary px-6 py-3 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
<div className="flex items-start gap-3">
|
||||||
>
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
Generate CSV export
|
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</button>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
<button
|
</svg>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() =>
|
<div>
|
||||||
setFilters({
|
<p className="text-sm font-bold text-foreground">Download CSV</p>
|
||||||
startDate: "",
|
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
|
||||||
endDate: "",
|
</div>
|
||||||
minAmount: "",
|
</div>
|
||||||
maxAmount: "",
|
<button
|
||||||
category: "",
|
onClick={onExportCsv}
|
||||||
source: "",
|
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||||
includeHidden: false
|
>
|
||||||
})
|
Export CSV
|
||||||
}
|
</button>
|
||||||
className="rounded-full border border-border bg-background px-6 py-3 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
|
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
|
||||||
>
|
</div>
|
||||||
Reset filters
|
|
||||||
</button>
|
{/* Google Sheets */}
|
||||||
|
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
|
||||||
|
{googleStatus?.connected ? (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{googleStatus?.connected ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onExportSheets}
|
||||||
|
disabled={sheetsLoading}
|
||||||
|
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDisconnectGoogle}
|
||||||
|
disabled={disconnecting}
|
||||||
|
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
|
||||||
|
>
|
||||||
|
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onConnectGoogle}
|
||||||
|
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Connect Google Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
|
||||||
|
{sheetsUrl && (
|
||||||
|
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
|
||||||
|
Open Sheet →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,84 +1,84 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Cash Flow Management - LedgerOne",
|
title: "Cash Flow Management - LedgerOne",
|
||||||
description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.",
|
description: "Visualize your income and expenses in real-time. Forecast future cash flow and make smarter business decisions.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CashFlowPage() {
|
export default function CashFlowPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "Cash Flow Management",
|
name: "Cash Flow Management",
|
||||||
description: "Visualize your income and expenses in real-time.",
|
description: "Visualize your income and expenses in real-time.",
|
||||||
url: `${siteInfo.url}/features/cash-flow`
|
url: `${siteInfo.url}/features/cash-flow`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
<div className="animate-slide-up">
|
<div className="animate-slide-up">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Cash Flow
|
Cash Flow
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
|
||||||
Know exactly where your money is going.
|
Know exactly where your money is going.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground mb-8">
|
<p className="text-lg text-muted-foreground mb-8">
|
||||||
Stop guessing. LedgerOne gives you a crystal-clear view of your income versus expenses across all your accounts. Spot trends, identify leaks, and plan for the future.
|
Stop guessing. LedgerOne gives you a crystal-clear view of your income versus expenses across all your accounts. Spot trends, identify leaks, and plan for the future.
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-4 mb-10">
|
<ul className="space-y-4 mb-10">
|
||||||
{[
|
{[
|
||||||
"Real-time income vs expense tracking",
|
"Real-time income vs expense tracking",
|
||||||
"Automatic categorization of transactions",
|
"Automatic categorization of transactions",
|
||||||
"Interactive bar and line charts",
|
"Interactive bar and line charts",
|
||||||
"Forecast future cash positions"
|
"Forecast future cash positions"
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<li key={item} className="flex items-center gap-3">
|
<li key={item} className="flex items-center gap-3">
|
||||||
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-medium">{item}</span>
|
<span className="text-foreground font-medium">{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start tracking cash flow
|
Start tracking cash flow
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative animate-fade-in delay-200">
|
<div className="relative animate-fade-in delay-200">
|
||||||
<div className="absolute -inset-4 bg-gradient-to-r from-primary/20 to-emerald-400/20 rounded-3xl blur-xl opacity-50"></div>
|
<div className="absolute -inset-4 bg-gradient-to-r from-primary/20 to-emerald-400/20 rounded-3xl blur-xl opacity-50"></div>
|
||||||
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
|
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
|
||||||
<Image
|
<Image
|
||||||
src="/images/feature-cashflow.png"
|
src="/images/feature-cashflow.png"
|
||||||
alt="Cash flow visualization"
|
alt="Cash flow visualization"
|
||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
className="w-full h-auto rounded-xl"
|
className="w-full h-auto rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,84 +1,84 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../../../components/site-footer";
|
import { SiteFooter } from "../../../components/site-footer";
|
||||||
import { SiteHeader } from "../../../components/site-header";
|
import { SiteHeader } from "../../../components/site-header";
|
||||||
import { PageSchema } from "../../../components/page-schema";
|
import { PageSchema } from "../../../components/page-schema";
|
||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Custom Reporting - LedgerOne",
|
title: "Custom Reporting - LedgerOne",
|
||||||
description: "Build custom financial reports and export audit-ready data for your accountant.",
|
description: "Build custom financial reports and export audit-ready data for your accountant.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "Custom Reporting",
|
name: "Custom Reporting",
|
||||||
description: "Build custom financial reports.",
|
description: "Build custom financial reports.",
|
||||||
url: `${siteInfo.url}/features/reports`
|
url: `${siteInfo.url}/features/reports`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24">
|
<main className="flex-1 pt-32 pb-24">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
<div className="order-2 lg:order-1 relative animate-fade-in delay-200">
|
<div className="order-2 lg:order-1 relative animate-fade-in delay-200">
|
||||||
<div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-3xl blur-xl opacity-50"></div>
|
<div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-3xl blur-xl opacity-50"></div>
|
||||||
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
|
<div className="relative rounded-2xl border border-border bg-background/50 backdrop-blur-xl shadow-2xl overflow-hidden p-2">
|
||||||
<Image
|
<Image
|
||||||
src="/images/feature-reports.png"
|
src="/images/feature-reports.png"
|
||||||
alt="Report builder interface"
|
alt="Report builder interface"
|
||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
className="w-full h-auto rounded-xl"
|
className="w-full h-auto rounded-xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="order-1 lg:order-2 animate-slide-up">
|
<div className="order-1 lg:order-2 animate-slide-up">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
|
||||||
Reporting
|
Reporting
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6 leading-tight">
|
||||||
Reports that make your accountant smile.
|
Reports that make your accountant smile.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground mb-8">
|
<p className="text-lg text-muted-foreground mb-8">
|
||||||
Don't scramble at tax time. LedgerOne keeps your data organized and audit-ready year-round. Build custom reports and export them in seconds.
|
Don't scramble at tax time. LedgerOne keeps your data organized and audit-ready year-round. Build custom reports and export them in seconds.
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-4 mb-10">
|
<ul className="space-y-4 mb-10">
|
||||||
{[
|
{[
|
||||||
"Drag-and-drop report builder",
|
"Drag-and-drop report builder",
|
||||||
"Filter by date, category, tag, or merchant",
|
"Filter by date, category, tag, or merchant",
|
||||||
"One-click CSV and PDF exports",
|
"One-click CSV and PDF exports",
|
||||||
"Share read-only access with your accountant"
|
"Share read-only access with your accountant"
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<li key={item} className="flex items-center gap-3">
|
<li key={item} className="flex items-center gap-3">
|
||||||
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
<div className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-medium">{item}</span>
|
<span className="text-foreground font-medium">{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground shadow-lg hover:bg-primary/90 transition-all"
|
||||||
>
|
>
|
||||||
Start building reports
|
Start building reports
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
115
app/forgot-password/page.tsx
Normal file
115
app/forgot-password/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,160 +1,195 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { ContactSection } from "../../components/contact-section";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { DemoCta } from "../../components/demo-cta";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { FaqSection } from "../../components/faq-section";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { PageSchema } from "../../components/page-schema";
|
import { defaultFaqs } from "../../data/faq";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { siteInfo } from "../../data/site";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { storeAuthTokens } from "@/lib/api";
|
||||||
import { defaultFaqs } from "../../data/faq";
|
|
||||||
import { siteInfo } from "../../data/site";
|
type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
type ApiResponse<T> = {
|
meta: { timestamp: string; version: "v1" };
|
||||||
data: T;
|
error: null | { message: string; code?: string };
|
||||||
meta: { timestamp: string; version: "v1" };
|
};
|
||||||
error: null | { message: string; code?: string };
|
|
||||||
};
|
type AuthData = {
|
||||||
|
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
|
||||||
type AuthData = { user: { id: string; email: string }; token: string };
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
export default function LoginPage() {
|
requiresTwoFactor?: boolean;
|
||||||
const router = useRouter();
|
};
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
function LoginForm() {
|
||||||
const [status, setStatus] = useState<string>("");
|
const router = useRouter();
|
||||||
const schema = [
|
const searchParams = useSearchParams();
|
||||||
{
|
const nextPath = searchParams.get("next") ?? "/app";
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebPage",
|
const [email, setEmail] = useState("");
|
||||||
name: "LedgerOne Login",
|
const [password, setPassword] = useState("");
|
||||||
description: "Sign in to LedgerOne to access your audit-ready ledger.",
|
const [totpToken, setTotpToken] = useState("");
|
||||||
url: `${siteInfo.url}/login`
|
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
|
||||||
},
|
const [status, setStatus] = useState<string>("");
|
||||||
{
|
const [isError, setIsError] = useState(false);
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "FAQPage",
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
event.preventDefault();
|
||||||
"@type": "Question",
|
setStatus("Signing in...");
|
||||||
name: item.question,
|
setIsError(false);
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
try {
|
||||||
}))
|
const res = await fetch("/api/auth/login", {
|
||||||
}
|
method: "POST",
|
||||||
];
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
});
|
||||||
event.preventDefault();
|
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||||
setStatus("Signing in...");
|
if (!res.ok || payload.error) {
|
||||||
try {
|
setStatus(payload.error?.message ?? "Login failed.");
|
||||||
const res = await fetch("/api/auth/login", {
|
setIsError(true);
|
||||||
method: "POST",
|
return;
|
||||||
headers: { "Content-Type": "application/json" },
|
}
|
||||||
body: JSON.stringify({ email, password })
|
|
||||||
});
|
if (payload.data.requiresTwoFactor) {
|
||||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
setRequiresTwoFactor(true);
|
||||||
if (!res.ok || payload.error) {
|
setStatus("Enter the code from your authenticator app.");
|
||||||
setStatus(payload.error?.message ?? "Login failed.");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
setStatus(`Welcome back, ${payload.data.user.email}`);
|
storeAuthTokens({
|
||||||
localStorage.setItem("ledgerone_token", payload.data.token);
|
accessToken: payload.data.accessToken,
|
||||||
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
|
refreshToken: payload.data.refreshToken,
|
||||||
router.push("/app");
|
user: payload.data.user,
|
||||||
} catch {
|
});
|
||||||
setStatus("Login failed.");
|
setStatus(`Welcome back, ${payload.data.user.email}`);
|
||||||
}
|
router.push(nextPath);
|
||||||
};
|
} catch {
|
||||||
|
setStatus("Login failed. Please try again.");
|
||||||
return (
|
setIsError(true);
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
}
|
||||||
<SiteHeader />
|
};
|
||||||
|
|
||||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
return (
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
<form className="space-y-6" onSubmit={onSubmit}>
|
||||||
|
<div>
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||||
<div className="flex justify-center">
|
Email address
|
||||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
</label>
|
||||||
L1
|
<div className="mt-1">
|
||||||
</div>
|
<input
|
||||||
</div>
|
id="email" name="email" type="email" autoComplete="email" required
|
||||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
Sign in to your account
|
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||||
</h2>
|
/>
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
</div>
|
||||||
Or{" "}
|
</div>
|
||||||
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
|
||||||
start your 14-day free trial
|
<div>
|
||||||
</Link>
|
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||||
</p>
|
Password
|
||||||
</div>
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<input
|
||||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
id="password" name="password" type="password" autoComplete="current-password" required
|
||||||
<form className="space-y-6" onSubmit={onSubmit}>
|
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
<div>
|
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
/>
|
||||||
Email address
|
</div>
|
||||||
</label>
|
</div>
|
||||||
<div className="mt-1">
|
|
||||||
<input
|
{requiresTwoFactor && (
|
||||||
id="email"
|
<div>
|
||||||
name="email"
|
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||||
type="email"
|
Authenticator Code
|
||||||
autoComplete="email"
|
</label>
|
||||||
required
|
<div className="mt-1">
|
||||||
value={email}
|
<input
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
|
||||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
placeholder="6-digit code" autoComplete="one-time-code" required
|
||||||
/>
|
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\D/g, ""))}
|
||||||
</div>
|
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
)}
|
||||||
Password
|
|
||||||
</label>
|
<div>
|
||||||
<div className="mt-1">
|
<button
|
||||||
<input
|
type="submit"
|
||||||
id="password"
|
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||||
name="password"
|
>
|
||||||
type="password"
|
{requiresTwoFactor ? "Verify" : "Sign in"}
|
||||||
autoComplete="current-password"
|
</button>
|
||||||
required
|
</div>
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<div className="text-center">
|
||||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
/>
|
Forgot your password?
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{status && (
|
||||||
<button
|
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
|
||||||
type="submit"
|
<p className="text-sm font-medium text-center">{status}</p>
|
||||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
</div>
|
||||||
>
|
)}
|
||||||
Sign in
|
</form>
|
||||||
</button>
|
);
|
||||||
</div>
|
}
|
||||||
</form>
|
|
||||||
|
export default function LoginPage() {
|
||||||
{status && (
|
const schema = [
|
||||||
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
|
{
|
||||||
<div className="flex">
|
"@context": "https://schema.org",
|
||||||
<div className="ml-3">
|
"@type": "WebPage",
|
||||||
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
|
name: "LedgerOne Login",
|
||||||
</div>
|
description: "Sign in to LedgerOne to access your audit-ready ledger.",
|
||||||
</div>
|
url: `${siteInfo.url}/login`,
|
||||||
</div>
|
},
|
||||||
)}
|
{
|
||||||
</div>
|
"@context": "https://schema.org",
|
||||||
</div>
|
"@type": "FAQPage",
|
||||||
</div>
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
|
"@type": "Question",
|
||||||
<SiteFooter />
|
name: item.question,
|
||||||
<PageSchema schema={schema} />
|
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||||
</div>
|
})),
|
||||||
);
|
},
|
||||||
}
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||||
|
<SiteHeader />
|
||||||
|
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||||
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||||
|
L1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
|
Or{" "}
|
||||||
|
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||||
|
start your 14-day free trial
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
|
<PageSchema schema={schema} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
474
app/page.tsx
474
app/page.tsx
@ -1,237 +1,237 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SiteFooter } from "../components/site-footer";
|
import { SiteFooter } from "../components/site-footer";
|
||||||
import { SiteHeader } from "../components/site-header";
|
import { SiteHeader } from "../components/site-header";
|
||||||
import { PageSchema } from "../components/page-schema";
|
import { PageSchema } from "../components/page-schema";
|
||||||
import { GrowthSimulator } from "../components/growth-simulator";
|
import { GrowthSimulator } from "../components/growth-simulator";
|
||||||
import { siteInfo } from "../data/site";
|
import { siteInfo } from "../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne - The Financial Control Platform for Modern Business",
|
title: "LedgerOne - The Financial Control Platform for Modern Business",
|
||||||
description: "Connect all your accounts, automate your bookkeeping, and get audit-ready financials in real-time.",
|
description: "Connect all your accounts, automate your bookkeeping, and get audit-ready financials in real-time.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
name: "LedgerOne",
|
name: "LedgerOne",
|
||||||
url: siteInfo.url,
|
url: siteInfo.url,
|
||||||
potentialAction: {
|
potentialAction: {
|
||||||
"@type": "SearchAction",
|
"@type": "SearchAction",
|
||||||
target: `${siteInfo.url}/search?q={search_term_string}`,
|
target: `${siteInfo.url}/search?q={search_term_string}`,
|
||||||
"query-input": "required name=search_term_string"
|
"query-input": "required name=search_term_string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "LedgerOne",
|
name: "LedgerOne",
|
||||||
url: siteInfo.url,
|
url: siteInfo.url,
|
||||||
logo: `${siteInfo.url}/logo.png`,
|
logo: `${siteInfo.url}/logo.png`,
|
||||||
sameAs: [
|
sameAs: [
|
||||||
"https://twitter.com/ledgerone",
|
"https://twitter.com/ledgerone",
|
||||||
"https://linkedin.com/company/ledgerone"
|
"https://linkedin.com/company/ledgerone"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* Hero Section - Monarch Style Clean Split */}
|
{/* Hero Section - Monarch Style Clean Split */}
|
||||||
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
|
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
|
|
||||||
{/* Left Column: Copy */}
|
{/* Left Column: Copy */}
|
||||||
<div className="max-w-2xl animate-slide-up">
|
<div className="max-w-2xl animate-slide-up">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm mb-8">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground backdrop-blur-sm mb-8">
|
||||||
<span className="flex h-2 w-2 rounded-full bg-primary"></span>
|
<span className="flex h-2 w-2 rounded-full bg-primary"></span>
|
||||||
Now available for US & Canadian businesses
|
Now available for US & Canadian businesses
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight">
|
<h1 className="text-5xl font-bold tracking-tight text-foreground sm:text-7xl mb-6 leading-tight">
|
||||||
Master your money <br />
|
Master your money <br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
|
||||||
with total clarity.
|
with total clarity.
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-lg text-muted-foreground mb-10">
|
<p className="mt-6 text-lg text-muted-foreground mb-10">
|
||||||
LedgerOne connects all your financial accounts in one place. Automate bookkeeping, track cash flow, and stay audit-ready without the spreadsheet chaos.
|
LedgerOne connects all your financial accounts in one place. Automate bookkeeping, track cash flow, and stay audit-ready without the spreadsheet chaos.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="rounded-full bg-primary px-8 py-4 text-base font-bold text-primary-foreground shadow-lg shadow-primary/25 hover:bg-primary/90 hover:-translate-y-1 transition-all"
|
className="rounded-full bg-primary px-8 py-4 text-base font-bold text-primary-foreground shadow-lg shadow-primary/25 hover:bg-primary/90 hover:-translate-y-1 transition-all"
|
||||||
>
|
>
|
||||||
Start your free trial
|
Start your free trial
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/demo"
|
href="/demo"
|
||||||
className="rounded-full bg-background border border-border px-8 py-4 text-base font-semibold text-foreground hover:bg-secondary transition-colors"
|
className="rounded-full bg-background border border-border px-8 py-4 text-base font-semibold text-foreground hover:bg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
See how it works
|
See how it works
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Composed Media Stack */}
|
{/* Right Column: Composed Media Stack */}
|
||||||
<div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200">
|
<div className="relative h-[600px] w-full flex items-center justify-center animate-fade-in delay-200">
|
||||||
{/* 1. Base Layer: Video (Desktop View) */}
|
{/* 1. Base Layer: Video (Desktop View) */}
|
||||||
<div className="absolute top-0 right-0 w-[90%] h-[80%] rounded-2xl overflow-hidden border border-border shadow-2xl bg-background/50 backdrop-blur-xl z-10">
|
<div className="absolute top-0 right-0 w-[90%] h-[80%] rounded-2xl overflow-hidden border border-border shadow-2xl bg-background/50 backdrop-blur-xl z-10">
|
||||||
<video
|
<video
|
||||||
poster="/images/hero_celebration_video_poster_1769386269277.png"
|
poster="/images/hero_celebration_video_poster_1769386269277.png"
|
||||||
className="w-full h-full object-cover opacity-80"
|
className="w-full h-full object-cover opacity-80"
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
playsInline
|
playsInline
|
||||||
>
|
>
|
||||||
<source src="/videos/hero.mp4" type="video/mp4" />
|
<source src="/videos/hero.mp4" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
{/* Overlay Gradient */}
|
{/* Overlay Gradient */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Middle Layer: Interactive Simulator (Floating Card) */}
|
{/* 2. Middle Layer: Interactive Simulator (Floating Card) */}
|
||||||
<div className="absolute bottom-10 right-10 w-[320px] z-20 hidden xl:block">
|
<div className="absolute bottom-10 right-10 w-[320px] z-20 hidden xl:block">
|
||||||
<div className="bg-background/80 backdrop-blur-md border border-border rounded-2xl p-1 shadow-glass transform hover:scale-105 transition-transform duration-500">
|
<div className="bg-background/80 backdrop-blur-md border border-border rounded-2xl p-1 shadow-glass transform hover:scale-105 transition-transform duration-500">
|
||||||
<GrowthSimulator />
|
<GrowthSimulator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Social Proof */}
|
{/* Social Proof */}
|
||||||
<section className="py-12 border-y border-border bg-secondary/20">
|
<section className="py-12 border-y border-border bg-secondary/20">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8 text-center">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8 text-center">
|
||||||
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8">
|
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-8">
|
||||||
Trusted by forward-thinking finance teams
|
Trusted by forward-thinking finance teams
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-5 opacity-60 grayscale hover:grayscale-0 transition-all duration-500">
|
<div className="grid grid-cols-2 gap-8 md:grid-cols-5 opacity-60 grayscale hover:grayscale-0 transition-all duration-500">
|
||||||
{/* Placeholders for logos */}
|
{/* Placeholders for logos */}
|
||||||
<div className="flex items-center justify-center font-bold text-xl">Acme Corp</div>
|
<div className="flex items-center justify-center font-bold text-xl">Acme Corp</div>
|
||||||
<div className="flex items-center justify-center font-bold text-xl">GlobalTech</div>
|
<div className="flex items-center justify-center font-bold text-xl">GlobalTech</div>
|
||||||
<div className="flex items-center justify-center font-bold text-xl">Nebula</div>
|
<div className="flex items-center justify-center font-bold text-xl">Nebula</div>
|
||||||
<div className="flex items-center justify-center font-bold text-xl">Vertex</div>
|
<div className="flex items-center justify-center font-bold text-xl">Vertex</div>
|
||||||
<div className="flex items-center justify-center font-bold text-xl">Horizon</div>
|
<div className="flex items-center justify-center font-bold text-xl">Horizon</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Grid */}
|
{/* Feature Grid */}
|
||||||
<section className="py-24 lg:py-32">
|
<section className="py-24 lg:py-32">
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-20">
|
<div className="text-center max-w-3xl mx-auto mb-20">
|
||||||
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||||
Everything you need to manage your wealth.
|
Everything you need to manage your wealth.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-lg text-muted-foreground">
|
<p className="mt-4 text-lg text-muted-foreground">
|
||||||
Stop logging into ten different sites. LedgerOne brings your entire financial life into a single, secure, and beautiful view.
|
Stop logging into ten different sites. LedgerOne brings your entire financial life into a single, secure, and beautiful view.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-12 lg:grid-cols-3">
|
<div className="grid gap-12 lg:grid-cols-3">
|
||||||
{/* Feature 1: Connect */}
|
{/* Feature 1: Connect */}
|
||||||
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
||||||
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3>
|
<h3 className="text-xl font-bold text-foreground mb-3">Sync Everything</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically.
|
Connect over 11,000 financial institutions. Banks, credit cards, loans, and investments update automatically.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
||||||
<Image
|
<Image
|
||||||
src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop"
|
src="https://images.unsplash.com/photo-1563986768609-322da13575f3?q=80&w=1470&auto=format&fit=crop"
|
||||||
alt="Bank connections"
|
alt="Bank connections"
|
||||||
width={400}
|
width={400}
|
||||||
height={250}
|
height={250}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature 2: Visualize */}
|
{/* Feature 2: Visualize */}
|
||||||
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
||||||
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3>
|
<h3 className="text-xl font-bold text-foreground mb-3">Visualize Cash Flow</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
See exactly where your money goes. Track income vs expenses with beautiful, interactive charts.
|
See exactly where your money goes. Track income vs expenses with beautiful, interactive charts.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
||||||
<Image
|
<Image
|
||||||
src="/images/feature-cashflow.png"
|
src="/images/feature-cashflow.png"
|
||||||
alt="Cash flow chart"
|
alt="Cash flow chart"
|
||||||
width={400}
|
width={400}
|
||||||
height={250}
|
height={250}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature 3: Report */}
|
{/* Feature 3: Report */}
|
||||||
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
<div className="glass-panel rounded-3xl p-8 hover:border-primary/50 transition-colors group">
|
||||||
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
<div className="h-12 w-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary mb-6 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3>
|
<h3 className="text-xl font-bold text-foreground mb-3">Custom Reports</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Build the exact report you need. Filter by category, tag, or merchant and export to CSV for your accountant.
|
Build the exact report you need. Filter by category, tag, or merchant and export to CSV for your accountant.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
<div className="rounded-xl overflow-hidden border border-border shadow-sm">
|
||||||
<Image
|
<Image
|
||||||
src="/images/feature-reports.png"
|
src="/images/feature-reports.png"
|
||||||
alt="Report builder"
|
alt="Report builder"
|
||||||
width={400}
|
width={400}
|
||||||
height={250}
|
height={250}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="py-24 relative overflow-hidden">
|
<section className="py-24 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-primary/5 -z-10" />
|
<div className="absolute inset-0 bg-primary/5 -z-10" />
|
||||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
||||||
<h2 className="text-4xl font-bold tracking-tight text-foreground mb-6">
|
<h2 className="text-4xl font-bold tracking-tight text-foreground mb-6">
|
||||||
Ready to take control?
|
Ready to take control?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-muted-foreground mb-10">
|
<p className="text-xl text-muted-foreground mb-10">
|
||||||
Join thousands of business owners who trust LedgerOne for their financial clarity.
|
Join thousands of business owners who trust LedgerOne for their financial clarity.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="inline-block rounded-full bg-primary px-10 py-4 text-lg font-bold text-primary-foreground shadow-xl shadow-primary/30 hover:bg-primary/90 hover:-translate-y-1 transition-all"
|
className="inline-block rounded-full bg-primary px-10 py-4 text-lg font-bold text-primary-foreground shadow-xl shadow-primary/30 hover:bg-primary/90 hover:-translate-y-1 transition-all"
|
||||||
>
|
>
|
||||||
Start your 14-day free trial
|
Start your 14-day free trial
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<p className="mt-4 text-sm text-muted-foreground">
|
||||||
No credit card required. Cancel anytime.
|
No credit card required. Cancel anytime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,178 +1,178 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ContactSection } from "../../components/contact-section";
|
import { ContactSection } from "../../components/contact-section";
|
||||||
import { DemoCta } from "../../components/demo-cta";
|
import { DemoCta } from "../../components/demo-cta";
|
||||||
import { FaqSection } from "../../components/faq-section";
|
import { FaqSection } from "../../components/faq-section";
|
||||||
import { PageSchema } from "../../components/page-schema";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { defaultFaqs } from "../../data/faq";
|
import { defaultFaqs } from "../../data/faq";
|
||||||
import { siteInfo } from "../../data/site";
|
import { siteInfo } from "../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Pricing",
|
title: "Pricing",
|
||||||
description:
|
description:
|
||||||
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
name: "Starter",
|
name: "Starter",
|
||||||
price: "Free",
|
price: "Free",
|
||||||
tagline: "Best for getting your first ledger online.",
|
tagline: "Best for getting your first ledger online.",
|
||||||
badge: "2 accounts",
|
badge: "2 accounts",
|
||||||
features: [
|
features: [
|
||||||
"First two connected accounts",
|
"First two connected accounts",
|
||||||
"Unlimited exports",
|
"Unlimited exports",
|
||||||
"Rule engine access",
|
"Rule engine access",
|
||||||
"Audit logs included"
|
"Audit logs included"
|
||||||
],
|
],
|
||||||
cta: "Start free",
|
cta: "Start free",
|
||||||
primary: false
|
primary: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Unlimited",
|
name: "Unlimited",
|
||||||
price: "$9",
|
price: "$9",
|
||||||
tagline: "Scale your ledger without limits.",
|
tagline: "Scale your ledger without limits.",
|
||||||
badge: "Unlimited accounts",
|
badge: "Unlimited accounts",
|
||||||
features: [
|
features: [
|
||||||
"Unlimited connected accounts",
|
"Unlimited connected accounts",
|
||||||
"Priority sync cadence",
|
"Priority sync cadence",
|
||||||
"Advanced rule automation",
|
"Advanced rule automation",
|
||||||
"Team-ready exports"
|
"Team-ready exports"
|
||||||
],
|
],
|
||||||
cta: "Choose Unlimited",
|
cta: "Choose Unlimited",
|
||||||
primary: true
|
primary: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const comparisons = [
|
const comparisons = [
|
||||||
{ label: "Connected accounts", starter: "2", pro: "Unlimited" },
|
{ label: "Connected accounts", starter: "2", pro: "Unlimited" },
|
||||||
{ label: "Exports", starter: "Unlimited", pro: "Unlimited" },
|
{ label: "Exports", starter: "Unlimited", pro: "Unlimited" },
|
||||||
{ label: "Rule engine", starter: "Core rules", pro: "Advanced rules" },
|
{ label: "Rule engine", starter: "Core rules", pro: "Advanced rules" },
|
||||||
{ label: "Audit logs", starter: "Included", pro: "Included" },
|
{ label: "Audit logs", starter: "Included", pro: "Included" },
|
||||||
{ label: "Support", starter: "Standard", pro: "Priority" }
|
{ label: "Support", starter: "Standard", pro: "Priority" }
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const schema = [
|
const schema = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne Pricing",
|
name: "LedgerOne Pricing",
|
||||||
description:
|
description:
|
||||||
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.",
|
||||||
url: `${siteInfo.url}/pricing`
|
url: `${siteInfo.url}/pricing`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "FAQPage",
|
"@type": "FAQPage",
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
"@type": "Question",
|
"@type": "Question",
|
||||||
name: item.question,
|
name: item.question,
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
<main className="flex-1 pt-32 pb-24 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
|
<div className="text-center max-w-3xl mx-auto mb-16 animate-slide-up">
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
|
||||||
Simple, transparent pricing.
|
Simple, transparent pricing.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-lg text-muted-foreground">
|
<p className="mt-6 text-lg text-muted-foreground">
|
||||||
Start with the essentials and upgrade only when you need more accounts.
|
Start with the essentials and upgrade only when you need more accounts.
|
||||||
Both plans include unlimited exports and audit logs.
|
Both plans include unlimited exports and audit logs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<div
|
<div
|
||||||
key={plan.name}
|
key={plan.name}
|
||||||
className={`rounded-2xl p-8 border transition-all ${plan.primary
|
className={`rounded-2xl p-8 border transition-all ${plan.primary
|
||||||
? "border-primary bg-background shadow-glow-teal ring-1 ring-primary"
|
? "border-primary bg-background shadow-glow-teal ring-1 ring-primary"
|
||||||
: "border-border bg-background/50 shadow-sm hover:shadow-md glass-panel"
|
: "border-border bg-background/50 shadow-sm hover:shadow-md glass-panel"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
|
<h3 className="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||||
{plan.primary && (
|
{plan.primary && (
|
||||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||||
Most Popular
|
Most Popular
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-baseline text-foreground">
|
<div className="mt-4 flex items-baseline text-foreground">
|
||||||
<span className="text-4xl font-bold tracking-tight">{plan.price}</span>
|
<span className="text-4xl font-bold tracking-tight">{plan.price}</span>
|
||||||
{plan.price !== "Free" && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
|
{plan.price !== "Free" && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p>
|
<p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p>
|
||||||
|
|
||||||
<ul className="mt-8 space-y-4">
|
<ul className="mt-8 space-y-4">
|
||||||
{plan.features.map((feature) => (
|
{plan.features.map((feature) => (
|
||||||
<li key={feature} className="flex items-start">
|
<li key={feature} className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="ml-3 text-sm text-muted-foreground">{feature}</p>
|
<p className="ml-3 text-sm text-muted-foreground">{feature}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className={`mt-8 block w-full rounded-lg px-4 py-3 text-center text-sm font-semibold shadow-sm transition-all hover:-translate-y-0.5 ${plan.primary
|
className={`mt-8 block w-full rounded-lg px-4 py-3 text-center text-sm font-semibold shadow-sm transition-all hover:-translate-y-0.5 ${plan.primary
|
||||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
: "bg-background text-primary ring-1 ring-inset ring-primary/20 hover:ring-primary/40"
|
: "bg-background text-primary ring-1 ring-inset ring-primary/20 hover:ring-primary/40"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.cta}
|
{plan.cta}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-24 max-w-4xl mx-auto glass-panel rounded-xl p-1">
|
<div className="mt-24 max-w-4xl mx-auto glass-panel rounded-xl p-1">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-xl font-bold text-foreground">Compare plans</h2>
|
<h2 className="text-xl font-bold text-foreground">Compare plans</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-secondary/30">
|
<thead className="bg-secondary/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Feature</th>
|
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Feature</th>
|
||||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Starter</th>
|
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Starter</th>
|
||||||
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Unlimited</th>
|
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Unlimited</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-background divide-y divide-border">
|
<tbody className="bg-background divide-y divide-border">
|
||||||
{comparisons.map((item) => (
|
{comparisons.map((item) => (
|
||||||
<tr key={item.label}>
|
<tr key={item.label}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">{item.label}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">{item.label}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.starter}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.starter}</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.pro}</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.pro}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<PageSchema schema={schema} />
|
<PageSchema schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,84 +2,84 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "../../components/app-shell";
|
import { AppShell } from "../../components/app-shell";
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
meta: { timestamp: string; version: "v1" };
|
meta: { timestamp: string; version: "v1" };
|
||||||
error: null | { message: string; code?: string };
|
error: null | { message: string; code?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProfileData = {
|
type ProfileData = {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
companyName?: string | null;
|
companyName?: string | null;
|
||||||
addressLine1i: string | null;
|
addressLine1i: string | null;
|
||||||
addressLine2i: string | null;
|
addressLine2i: string | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
state?: string | null;
|
state?: string | null;
|
||||||
postalCode?: string | null;
|
postalCode?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [phone, setPhone] = useState("");
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [addressLine1, setAddressLine1] = useState("");
|
const [addressLine1, setAddressLine1] = useState("");
|
||||||
const [addressLine2, setAddressLine2] = useState("");
|
const [addressLine2, setAddressLine2] = useState("");
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [state, setState] = useState("");
|
const [state, setState] = useState("");
|
||||||
const [postalCode, setPostalCode] = useState("");
|
const [postalCode, setPostalCode] = useState("");
|
||||||
const [country, setCountry] = useState("");
|
const [country, setCountry] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem("ledgerone_token") ?? "";
|
const stored = localStorage.getItem("ledgerone_token") ?? "";
|
||||||
setToken(stored);
|
setToken(stored);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatus("Please sign in to update your profile.");
|
setStatus("Please sign in to update your profile.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("Saving profile...");
|
setStatus("Saving profile...");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/profile", {
|
const res = await fetch("/api/auth/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fullName,
|
fullName,
|
||||||
phone: phone || undefined,
|
phone: phone || undefined,
|
||||||
companyName: companyName || undefined,
|
companyName: companyName || undefined,
|
||||||
addressLine1: addressLine1 || undefined,
|
addressLine1: addressLine1 || undefined,
|
||||||
addressLine2: addressLine2 || undefined,
|
addressLine2: addressLine2 || undefined,
|
||||||
city: city || undefined,
|
city: city || undefined,
|
||||||
state: state || undefined,
|
state: state || undefined,
|
||||||
postalCode: postalCode || undefined,
|
postalCode: postalCode || undefined,
|
||||||
country: country || undefined
|
country: country || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const payload = (await res.json()) as ApiResponse<ProfileData>;
|
const payload = (await res.json()) as ApiResponse<ProfileData>;
|
||||||
if (!res.ok || payload.error) {
|
if (!res.ok || payload.error) {
|
||||||
setStatus(payload.error?.message ?? "Profile update failed.");
|
setStatus(payload.error?.message ?? "Profile update failed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("Profile updated.");
|
setStatus("Profile updated.");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Profile update failed.");
|
setStatus("Profile update failed.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
||||||
<div className="app-card p-10">
|
<div className="app-card p-10">
|
||||||
@ -89,106 +89,106 @@ export default function ProfilePage() {
|
|||||||
Add the details we need to personalize your ledger workspace.
|
Add the details we need to personalize your ledger workspace.
|
||||||
</p>
|
</p>
|
||||||
<form className="mt-8 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
|
<form className="mt-8 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Full name
|
Full name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(event) => setFullName(event.target.value)}
|
onChange={(event) => setFullName(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">Phone</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">Phone</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(event) => setPhone(event.target.value)}
|
onChange={(event) => setPhone(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Company
|
Company
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={companyName}
|
value={companyName}
|
||||||
onChange={(event) => setCompanyName(event.target.value)}
|
onChange={(event) => setCompanyName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Address line 1
|
Address line 1
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={addressLine1}
|
value={addressLine1}
|
||||||
onChange={(event) => setAddressLine1(event.target.value)}
|
onChange={(event) => setAddressLine1(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Address line 2
|
Address line 2
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={addressLine2}
|
value={addressLine2}
|
||||||
onChange={(event) => setAddressLine2(event.target.value)}
|
onChange={(event) => setAddressLine2(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">City</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">City</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={city}
|
value={city}
|
||||||
onChange={(event) => setCity(event.target.value)}
|
onChange={(event) => setCity(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">State</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">State</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={state}
|
value={state}
|
||||||
onChange={(event) => setState(event.target.value)}
|
onChange={(event) => setState(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Postal code
|
Postal code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={postalCode}
|
value={postalCode}
|
||||||
onChange={(event) => setPostalCode(event.target.value)}
|
onChange={(event) => setPostalCode(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">Country</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">Country</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
value={country}
|
value={country}
|
||||||
onChange={(event) => setCountry(event.target.value)}
|
onChange={(event) => setCountry(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
||||||
>
|
>
|
||||||
Save profile
|
Save profile
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
|
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,179 +1,168 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, FormEvent } from "react";
|
import { useState, FormEvent } from "react";
|
||||||
import { ContactSection } from "../../components/contact-section";
|
import { PageSchema } from "../../components/page-schema";
|
||||||
import { DemoCta } from "../../components/demo-cta";
|
import { SiteFooter } from "../../components/site-footer";
|
||||||
import { FaqSection } from "../../components/faq-section";
|
import { SiteHeader } from "../../components/site-header";
|
||||||
import { PageSchema } from "../../components/page-schema";
|
import { defaultFaqs } from "../../data/faq";
|
||||||
import { SiteFooter } from "../../components/site-footer";
|
import { siteInfo } from "../../data/site";
|
||||||
import { SiteHeader } from "../../components/site-header";
|
import { storeAuthTokens } from "@/lib/api";
|
||||||
import { defaultFaqs } from "../../data/faq";
|
|
||||||
import { siteInfo } from "../../data/site";
|
type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
type ApiResponse<T> = {
|
meta: { timestamp: string; version: "v1" };
|
||||||
data: T;
|
error: null | { message: string; code?: string };
|
||||||
meta: { timestamp: string; version: "v1" };
|
};
|
||||||
error: null | { message: string; code?: string };
|
|
||||||
};
|
type AuthData = {
|
||||||
|
user: { id: string; email: string; fullName?: string };
|
||||||
type AuthData = { user: { id: string; email: string }; token: string };
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
export default function RegisterPage() {
|
message?: string;
|
||||||
const router = useRouter();
|
};
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
export default function RegisterPage() {
|
||||||
const [status, setStatus] = useState<string>("");
|
const router = useRouter();
|
||||||
const schema = [
|
const [email, setEmail] = useState("");
|
||||||
{
|
const [password, setPassword] = useState("");
|
||||||
"@context": "https://schema.org",
|
const [status, setStatus] = useState<string>("");
|
||||||
"@type": "WebPage",
|
const [isError, setIsError] = useState(false);
|
||||||
name: "LedgerOne Create Account",
|
|
||||||
description: "Create a LedgerOne account and start with two free accounts.",
|
const schema = [
|
||||||
url: `${siteInfo.url}/register`
|
{
|
||||||
},
|
"@context": "https://schema.org",
|
||||||
{
|
"@type": "WebPage",
|
||||||
"@context": "https://schema.org",
|
name: "LedgerOne Create Account",
|
||||||
"@type": "FAQPage",
|
description: "Create a LedgerOne account and start with two free accounts.",
|
||||||
mainEntity: defaultFaqs.map((item) => ({
|
url: `${siteInfo.url}/register`,
|
||||||
"@type": "Question",
|
},
|
||||||
name: item.question,
|
{
|
||||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
"@context": "https://schema.org",
|
||||||
}))
|
"@type": "FAQPage",
|
||||||
}
|
mainEntity: defaultFaqs.map((item) => ({
|
||||||
];
|
"@type": "Question",
|
||||||
|
name: item.question,
|
||||||
const onSubmit = async (event: FormEvent) => {
|
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||||
event.preventDefault();
|
})),
|
||||||
setStatus("Creating account...");
|
},
|
||||||
try {
|
];
|
||||||
const res = await fetch("/api/auth/register", {
|
|
||||||
method: "POST",
|
const onSubmit = async (event: FormEvent) => {
|
||||||
headers: { "Content-Type": "application/json" },
|
event.preventDefault();
|
||||||
body: JSON.stringify({ email, password })
|
setStatus("Creating account...");
|
||||||
});
|
setIsError(false);
|
||||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
try {
|
||||||
if (!res.ok || payload.error) {
|
const res = await fetch("/api/auth/register", {
|
||||||
setStatus(payload.error?.message ?? "Registration failed.");
|
method: "POST",
|
||||||
return;
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
body: JSON.stringify({ email, password }),
|
||||||
setStatus(`Welcome, ${payload.data.user.email}`);
|
});
|
||||||
localStorage.setItem("ledgerone_token", payload.data.token);
|
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||||
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
|
if (!res.ok || payload.error) {
|
||||||
router.push("/login");
|
setStatus(payload.error?.message ?? "Registration failed.");
|
||||||
} catch {
|
setIsError(true);
|
||||||
setStatus("Registration failed.");
|
return;
|
||||||
}
|
}
|
||||||
};
|
storeAuthTokens({
|
||||||
|
accessToken: payload.data.accessToken,
|
||||||
return (
|
refreshToken: payload.data.refreshToken,
|
||||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
user: payload.data.user,
|
||||||
<SiteHeader />
|
});
|
||||||
|
setStatus("Account created! Please verify your email.");
|
||||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
router.push("/app");
|
||||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
} catch {
|
||||||
|
setStatus("Registration failed. Please try again.");
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
setIsError(true);
|
||||||
<div className="flex justify-center">
|
}
|
||||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
};
|
||||||
L1
|
|
||||||
</div>
|
return (
|
||||||
</div>
|
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
<SiteHeader />
|
||||||
Create your account
|
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||||
</h2>
|
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
Already have an account?{" "}
|
<div className="flex justify-center">
|
||||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||||
Sign in
|
L1
|
||||||
</Link>
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||||
|
Create your account
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
</h2>
|
||||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
<form className="space-y-6" onSubmit={onSubmit}>
|
Already have an account?{" "}
|
||||||
<div>
|
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
Sign in
|
||||||
Email address
|
</Link>
|
||||||
</label>
|
</p>
|
||||||
<div className="mt-1">
|
</div>
|
||||||
<input
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
id="email"
|
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||||
name="email"
|
<form className="space-y-6" onSubmit={onSubmit}>
|
||||||
type="email"
|
<div>
|
||||||
autoComplete="email"
|
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||||
required
|
Email address
|
||||||
value={email}
|
</label>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
<div className="mt-1">
|
||||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
<input
|
||||||
/>
|
id="email" name="email" type="email" autoComplete="email" required
|
||||||
</div>
|
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
</div>
|
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||||
|
/>
|
||||||
<div>
|
</div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
</div>
|
||||||
Password
|
<div>
|
||||||
</label>
|
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||||
<div className="mt-1">
|
Password
|
||||||
<input
|
</label>
|
||||||
id="password"
|
<div className="mt-1">
|
||||||
name="password"
|
<input
|
||||||
type="password"
|
id="password" name="password" type="password" autoComplete="new-password" required
|
||||||
autoComplete="new-password"
|
minLength={8}
|
||||||
required
|
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
value={password}
|
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
/>
|
||||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
</div>
|
||||||
/>
|
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<div>
|
<input
|
||||||
<div className="flex items-center gap-2 mb-6">
|
id="terms" name="terms" type="checkbox" required
|
||||||
<input
|
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
|
||||||
id="terms"
|
onChange={(e) => {
|
||||||
name="terms"
|
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
|
||||||
type="checkbox"
|
if (btn) btn.disabled = !e.target.checked;
|
||||||
required
|
}}
|
||||||
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
|
/>
|
||||||
onChange={(e) => {
|
<label htmlFor="terms" className="text-sm text-muted-foreground">
|
||||||
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
|
I agree to the{" "}
|
||||||
if (btn) btn.disabled = !e.target.checked;
|
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
|
||||||
}}
|
and{" "}
|
||||||
/>
|
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
|
||||||
<label htmlFor="terms" className="text-sm text-muted-foreground">
|
</label>
|
||||||
I agree to the <Link href="/terms" className="text-primary hover:underline">Terms of Service</Link> and <Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
|
</div>
|
||||||
</label>
|
<button
|
||||||
</div>
|
id="submit-btn" type="submit" disabled
|
||||||
|
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||||
<button
|
>
|
||||||
id="submit-btn"
|
Create account
|
||||||
type="submit"
|
</button>
|
||||||
disabled
|
</div>
|
||||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
</form>
|
||||||
>
|
{status && (
|
||||||
Create account
|
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
|
||||||
</button>
|
<p className="text-sm font-medium text-center">{status}</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
</div>
|
||||||
{status && (
|
</div>
|
||||||
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
|
</div>
|
||||||
<div className="flex">
|
<SiteFooter />
|
||||||
<div className="ml-3">
|
<PageSchema schema={schema} />
|
||||||
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SiteFooter />
|
|
||||||
<PageSchema schema={schema} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
147
app/reset-password/page.tsx
Normal file
147
app/reset-password/page.tsx
Normal 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
220
app/settings/2fa/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,19 +5,24 @@ const settingsItems = [
|
|||||||
{
|
{
|
||||||
title: "Profile",
|
title: "Profile",
|
||||||
description: "Update company details, contact info, and onboarding fields.",
|
description: "Update company details, contact info, and onboarding fields.",
|
||||||
href: "/settings/profile"
|
href: "/settings/profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Two-Factor Auth",
|
||||||
|
description: "Add a TOTP authenticator app for extra security.",
|
||||||
|
href: "/settings/2fa",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Subscription",
|
title: "Subscription",
|
||||||
description: "View plan details, upgrade options, and billing cadence.",
|
description: "View plan details, upgrade options, and billing cadence.",
|
||||||
href: "/settings/subscription"
|
href: "/settings/subscription",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
|
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{settingsItems.map((item) => (
|
{settingsItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.title}
|
key={item.title}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,91 +1,164 @@
|
|||||||
import { AppShell } from "../../../components/app-shell";
|
"use client";
|
||||||
|
|
||||||
const plans = [
|
import { useEffect, useState } from "react";
|
||||||
{
|
import { AppShell } from "../../../components/app-shell";
|
||||||
name: "Free",
|
import { apiFetch } from "@/lib/api";
|
||||||
price: "$0",
|
|
||||||
cadence: "forever",
|
type ApiResponse<T> = {
|
||||||
highlight: "Connect up to 2 accounts",
|
data: T;
|
||||||
features: [
|
meta: { timestamp: string; version: "v1" };
|
||||||
"2 connected accounts",
|
error: null | { message: string; code?: string };
|
||||||
"30-day transaction history",
|
};
|
||||||
"Basic exports",
|
|
||||||
"Email support"
|
type SubscriptionData = {
|
||||||
]
|
plan?: string;
|
||||||
},
|
status?: string;
|
||||||
{
|
billingCycleAnchor?: number;
|
||||||
name: "Pro Monthly",
|
cancelAtPeriodEnd?: boolean;
|
||||||
price: "$9",
|
};
|
||||||
cadence: "per month",
|
|
||||||
highlight: "Unlimited connected accounts",
|
const PLAN_LABELS: Record<string, string> = {
|
||||||
features: [
|
free: "Free",
|
||||||
"Unlimited accounts",
|
pro: "Pro",
|
||||||
"12-month history",
|
elite: "Elite",
|
||||||
"Advanced exports + rules",
|
};
|
||||||
"Priority support"
|
|
||||||
]
|
const PLAN_DESCRIPTIONS: Record<string, string> = {
|
||||||
},
|
free: "Up to 2 accounts, basic CSV export, 30-day history.",
|
||||||
{
|
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
|
||||||
name: "Pro Annual",
|
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
|
||||||
price: "$90",
|
};
|
||||||
cadence: "per year",
|
|
||||||
highlight: "Two months free",
|
|
||||||
features: [
|
|
||||||
"Unlimited accounts",
|
|
||||||
"12-month history",
|
|
||||||
"Advanced exports + rules",
|
|
||||||
"Priority support"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SubscriptionPage() {
|
export default function SubscriptionPage() {
|
||||||
return (
|
const [sub, setSub] = useState<SubscriptionData | null>(null);
|
||||||
<AppShell title="Subscription" subtitle="Choose a plan that fits your team.">
|
const [loading, setLoading] = useState(true);
|
||||||
<div className="app-card p-6">
|
const [actionStatus, setActionStatus] = useState("");
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">Current plan</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold">Free Plan</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted">
|
|
||||||
Free includes up to two connected accounts. Upgrade any time to unlock
|
|
||||||
unlimited accounts and advanced automation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="app-button rounded-full px-5 py-2 text-xs font-semibold"
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
useEffect(() => {
|
||||||
{plans.map((plan) => (
|
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||||
<div key={plan.name} className="app-card p-6">
|
.then((res) => {
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">{plan.name}</p>
|
if (!res.error) setSub(res.data);
|
||||||
<div className="mt-3 flex items-baseline gap-2">
|
})
|
||||||
<span className="text-3xl font-semibold">{plan.price}</span>
|
.catch(() => {})
|
||||||
<span className="text-xs text-muted">{plan.cadence}</span>
|
.finally(() => setLoading(false));
|
||||||
</div>
|
}, []);
|
||||||
<p className="mt-2 text-sm text-muted">{plan.highlight}</p>
|
|
||||||
<ul className="mt-4 space-y-2 text-xs text-muted">
|
const handleUpgrade = async (plan: string) => {
|
||||||
{plan.features.map((feature) => (
|
setActionLoading(true);
|
||||||
<li key={feature} className="flex items-center gap-2">
|
setActionStatus("Redirecting to checkout...");
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
{feature}
|
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
|
||||||
</li>
|
method: "POST",
|
||||||
))}
|
body: JSON.stringify({
|
||||||
</ul>
|
plan,
|
||||||
|
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
|
||||||
|
cancelUrl: `${appUrl}/settings/subscription`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setActionLoading(false);
|
||||||
|
if (res.error || !res.data?.url) {
|
||||||
|
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePortal = async () => {
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionStatus("Redirecting to billing portal...");
|
||||||
|
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
|
||||||
|
});
|
||||||
|
setActionLoading(false);
|
||||||
|
if (res.error || !res.data?.url) {
|
||||||
|
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = res.data.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPlan = sub?.plan ?? "free";
|
||||||
|
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
|
||||||
|
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
{/* Current plan card */}
|
||||||
|
<div className="glass-panel rounded-2xl p-8">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
|
||||||
|
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
|
||||||
|
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
|
||||||
|
{sub.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(sub?.status === "active" || currentPlan !== "free") && (
|
||||||
|
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
|
||||||
|
{sub?.cancelAtPeriodEnd && (
|
||||||
|
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentPlan !== "free" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={handlePortal}
|
||||||
className="mt-6 w-full rounded-full border border-ink/10 bg-white/5 px-4 py-2 text-xs font-semibold text-ink"
|
disabled={actionLoading}
|
||||||
|
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{plan.name === "Free" ? "Current plan" : "Upgrade"}
|
Manage Billing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade options */}
|
||||||
|
{currentPlan === "free" && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{(["pro", "elite"] as const).map((plan) => (
|
||||||
|
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||||
|
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpgrade(plan)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Upgrade to {PLAN_LABELS[plan]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPlan === "pro" && (
|
||||||
|
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||||
|
<p className="text-lg font-bold text-foreground">Elite</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpgrade("elite")}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Upgrade to Elite
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{actionStatus && (
|
||||||
|
<p className="text-sm text-muted-foreground">{actionStatus}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
118
app/verify-email/page.tsx
Normal file
118
app/verify-email/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
|
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
|
||||||
|
import { apiFetch, clearAuth, getStoredUser } from "@/lib/api";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/app", label: "Dashboard" },
|
{ href: "/app", label: "Dashboard" },
|
||||||
@ -12,28 +13,107 @@ const navItems = [
|
|||||||
{ href: "/rules", label: "Rules" },
|
{ href: "/rules", label: "Rules" },
|
||||||
{ href: "/exports", label: "Exports" },
|
{ href: "/exports", label: "Exports" },
|
||||||
{ href: "/tax", label: "Tax" },
|
{ href: "/tax", label: "Tax" },
|
||||||
{ href: "/settings", label: "Settings" }
|
{ href: "/settings", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullName?: string | null;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
twoFactorEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
plan?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function initials(user: User): string {
|
||||||
|
if (user.fullName) {
|
||||||
|
const parts = user.fullName.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.email.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(user: User): string {
|
||||||
|
return user.fullName?.trim() || user.email;
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ title, subtitle, children }: AppShellProps) {
|
export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [user, setUser] = useState<User | null>(getStoredUser<User>());
|
||||||
|
const [planLabel, setPlanLabel] = useState("Free Plan");
|
||||||
|
|
||||||
const onLogout = () => {
|
useEffect(() => {
|
||||||
localStorage.removeItem("ledgerone_token");
|
// Fetch latest user profile
|
||||||
localStorage.removeItem("ledgerone_user_id");
|
apiFetch<{ user: User }>("/api/auth/me")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.error && res.data?.user) {
|
||||||
|
setUser(res.data.user);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Fetch subscription plan
|
||||||
|
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.error && res.data?.plan) {
|
||||||
|
const p = res.data.plan;
|
||||||
|
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onLogout = async () => {
|
||||||
|
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
clearAuth();
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NavLinks = () => (
|
||||||
|
<>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-secondary text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
||||||
{/* Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
|
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
|
||||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -48,32 +128,65 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
|||||||
<div className="px-2 py-2 mb-2">
|
<div className="px-2 py-2 mb-2">
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||||
</div>
|
</div>
|
||||||
{navItems.map((item) => {
|
<NavLinks />
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${isActive
|
|
||||||
? "bg-secondary text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Icons could be added here */}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto p-4 border-t border-border">
|
<div className="mt-auto p-4 border-t border-border">
|
||||||
<div className="flex items-center gap-3 px-2 py-2">
|
<div className="flex items-center gap-3 px-2 py-2">
|
||||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">
|
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||||
AC
|
{user ? initials(user) : "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<p className="text-sm font-medium text-foreground truncate">Alex Chen</p>
|
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">Pro Plan</p>
|
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Sidebar drawer */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden ${
|
||||||
|
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
|
||||||
|
L1
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-1 flex-1">
|
||||||
|
<div className="px-2 py-2 mb-2">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||||
|
</div>
|
||||||
|
<NavLinks />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3 px-2 py-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||||
|
{user ? initials(user) : "?"}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -81,31 +194,46 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-6 lg:px-8">
|
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
{/* Breadcrumbs or Title */}
|
{/* Hamburger — mobile only */}
|
||||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
<button
|
||||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
className="lg:hidden text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||||
|
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3 lg:gap-4">
|
||||||
<div className="relative hidden md:block">
|
<div className="relative hidden md:block">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||||
/>
|
/>
|
||||||
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<CurrencyToggle />
|
<CurrencyToggle />
|
||||||
<MoodToggle />
|
<MoodToggle />
|
||||||
<button onClick={onLogout} className="text-sm font-medium text-muted-foreground hover:text-foreground">
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">
|
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,80 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
|
|
||||||
const onSubmit = (event: React.FormEvent) => {
|
const onSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setStatus("Thanks! We will reach out within 1 business day.");
|
setStatus("Thanks! We will reach out within 1 business day.");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto mt-16 max-w-6xl px-6">
|
<section className="mx-auto mt-16 max-w-6xl px-6">
|
||||||
<div className="rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
|
<div className="rounded-3xl border border-ink/10 bg-white/80 p-8 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">Contact us</p>
|
<p className="text-xs uppercase tracking-[0.3em] text-muted">Contact us</p>
|
||||||
<h2 className="mt-3 text-2xl font-semibold">Let's talk about your ledger.</h2>
|
<h2 className="mt-3 text-2xl font-semibold">Let's talk about your ledger.</h2>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<p className="mt-2 text-sm text-muted">
|
||||||
Tell us about your workflow and we will suggest the best LedgerOne setup.
|
Tell us about your workflow and we will suggest the best LedgerOne setup.
|
||||||
</p>
|
</p>
|
||||||
<form className="mt-6 grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
<form className="mt-6 grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Full name
|
Full name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">Work email</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">Work email</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">Company</label>
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">Company</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
Monthly transactions
|
Monthly transactions
|
||||||
</label>
|
</label>
|
||||||
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
|
<select className="w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm">
|
||||||
<option>Under 1,000</option>
|
<option>Under 1,000</option>
|
||||||
<option>1,000 - 10,000</option>
|
<option>1,000 - 10,000</option>
|
||||||
<option>10,000 - 50,000</option>
|
<option>10,000 - 50,000</option>
|
||||||
<option>50,000+</option>
|
<option>50,000+</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
<label className="text-xs uppercase tracking-[0.2em] text-muted">
|
||||||
What do you want to solve?
|
What do you want to solve?
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
className="min-h-[120px] w-full rounded-2xl border border-ink/10 bg-white px-4 py-3 text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
className="w-full rounded-2xl bg-ink px-4 py-3 text-sm font-semibold text-haze"
|
||||||
>
|
>
|
||||||
Send message
|
Send message
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
|
{status ? <p className="mt-4 text-xs text-muted">{status}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +1,58 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function CurrencyToggle() {
|
export function CurrencyToggle() {
|
||||||
const [currency, setCurrency] = useState<"USD" | "CAD">("USD");
|
const [currency, setCurrency] = useState<"USD" | "CAD">("USD");
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
setCurrency((prev) => (prev === "USD" ? "CAD" : "USD"));
|
setCurrency((prev) => (prev === "USD" ? "CAD" : "USD"));
|
||||||
// In a real app, this would update a context or store
|
// In a real app, this would update a context or store
|
||||||
document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'");
|
document.documentElement.style.setProperty("--currency-symbol", currency === "USD" ? "'C$'" : "'$'");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-muted"
|
className="flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span className={currency === "USD" ? "text-primary font-bold" : "text-muted-foreground"}>USD</span>
|
<span className={currency === "USD" ? "text-primary font-bold" : "text-muted-foreground"}>USD</span>
|
||||||
<span className="h-3 w-[1px] bg-muted-foreground/20" />
|
<span className="h-3 w-[1px] bg-muted-foreground/20" />
|
||||||
<span className={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span>
|
<span className={currency === "CAD" ? "text-primary font-bold" : "text-muted-foreground"}>CAD</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MoodToggle() {
|
export function MoodToggle() {
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.documentElement.classList.contains("dark")) {
|
if (document.documentElement.classList.contains("dark")) {
|
||||||
setIsDark(true);
|
setIsDark(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const next = !isDark;
|
const next = !isDark;
|
||||||
setIsDark(next);
|
setIsDark(next);
|
||||||
if (next) {
|
if (next) {
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove("dark");
|
document.documentElement.classList.remove("dark");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
className="rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||||
title="Toggle Mood Mode"
|
title="Toggle Mood Mode"
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{isDark ? (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,56 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
meta: { timestamp: string; version: "v1" };
|
meta: { timestamp: string; version: "v1" };
|
||||||
error: null | { message: string; code?: string };
|
error: null | { message: string; code?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ExportData = { status?: string; url?: string; csv?: string };
|
type ExportData = { status?: string; url?: string; csv?: string };
|
||||||
|
|
||||||
async function getJson<T>(path: string) {
|
async function getJson<T>(path: string) {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Request failed");
|
throw new Error("Request failed");
|
||||||
}
|
}
|
||||||
const payload = (await res.json()) as ApiResponse<T>;
|
const payload = (await res.json()) as ApiResponse<T>;
|
||||||
return payload.data;
|
return payload.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportDownloadButton() {
|
export function ExportDownloadButton() {
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
|
|
||||||
const onDownload = async () => {
|
const onDownload = async () => {
|
||||||
setStatus("Building export...");
|
setStatus("Building export...");
|
||||||
try {
|
try {
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
const userId = localStorage.getItem("ledgerone_user_id");
|
||||||
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||||
const data = await getJson<ExportData>(`/api/exports/csv${query}`);
|
const data = await getJson<ExportData>(`/api/exports/csv${query}`);
|
||||||
if (data.csv) {
|
if (data.csv) {
|
||||||
const blob = new Blob([data.csv], { type: "text/csv" });
|
const blob = new Blob([data.csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
setStatus("Download opened.");
|
setStatus("Download opened.");
|
||||||
} else {
|
} else {
|
||||||
setStatus("Export ready.");
|
setStatus("Export ready.");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Unable to download export.");
|
setStatus("Unable to download export.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
className="w-full rounded-xl bg-ink px-4 py-2 text-sm font-semibold text-haze"
|
className="w-full rounded-xl bg-ink px-4 py-2 text-sm font-semibold text-haze"
|
||||||
>
|
>
|
||||||
Download export
|
Download export
|
||||||
</button>
|
</button>
|
||||||
{status ? <p className="text-xs text-muted">{status}</p> : null}
|
{status ? <p className="text-xs text-muted">{status}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { defaultFaqs } from "../data/faq";
|
import { defaultFaqs } from "../data/faq";
|
||||||
|
|
||||||
type FaqSectionProps = {
|
type FaqSectionProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
@ -19,17 +19,17 @@ export function FaqSection({ title, subtitle, limit }: FaqSectionProps) {
|
|||||||
{subtitle ??
|
{subtitle ??
|
||||||
"Find pricing, account, export, and security answers for LedgerOne."}
|
"Find pricing, account, export, and security answers for LedgerOne."}
|
||||||
</p>
|
</p>
|
||||||
<ol className="mt-6 space-y-5 text-sm text-muted">
|
<ol className="mt-6 space-y-5 text-sm text-muted">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<li key={item.question} className="space-y-2">
|
<li key={item.question} className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-moss">
|
<p className="text-xs uppercase tracking-[0.2em] text-moss">
|
||||||
{index + 1}. {item.question}
|
{index + 1}. {item.question}
|
||||||
</p>
|
</p>
|
||||||
<p>{item.answer}</p>
|
<p>{item.answer}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,111 +1,111 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ year: "2026", savings: 12000, investing: 12000 },
|
{ year: "2026", savings: 12000, investing: 12000 },
|
||||||
{ year: "2027", savings: 24000, investing: 25400 },
|
{ year: "2027", savings: 24000, investing: 25400 },
|
||||||
{ year: "2028", savings: 36000, investing: 40100 },
|
{ year: "2028", savings: 36000, investing: 40100 },
|
||||||
{ year: "2029", savings: 48000, investing: 56500 },
|
{ year: "2029", savings: 48000, investing: 56500 },
|
||||||
{ year: "2030", savings: 60000, investing: 74800 },
|
{ year: "2030", savings: 60000, investing: 74800 },
|
||||||
{ year: "2031", savings: 72000, investing: 95200 },
|
{ year: "2031", savings: 72000, investing: 95200 },
|
||||||
{ year: "2032", savings: 84000, investing: 118000 },
|
{ year: "2032", savings: 84000, investing: 118000 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function GrowthSimulator() {
|
export function GrowthSimulator() {
|
||||||
const [monthlyContribution, setMonthlyContribution] = useState(1000);
|
const [monthlyContribution, setMonthlyContribution] = useState(1000);
|
||||||
const [mode, setMode] = useState<"savings" | "investing">("investing");
|
const [mode, setMode] = useState<"savings" | "investing">("investing");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-panel rounded-3xl p-8 shadow-glass">
|
<div className="glass-panel rounded-3xl p-8 shadow-glass">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-6 mb-8">
|
<div className="flex flex-wrap items-center justify-between gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-foreground">Projected Growth</h3>
|
<h3 className="text-xl font-bold text-foreground">Projected Growth</h3>
|
||||||
<p className="text-sm text-muted-foreground">Simulate your wealth accumulation over 6 years.</p>
|
<p className="text-sm text-muted-foreground">Simulate your wealth accumulation over 6 years.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-secondary rounded-full p-1">
|
<div className="flex items-center gap-2 bg-secondary rounded-full p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode("savings")}
|
onClick={() => setMode("savings")}
|
||||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "savings" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "savings" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Savings
|
Savings
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode("investing")}
|
onClick={() => setMode("investing")}
|
||||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "investing" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${mode === "investing" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Investing
|
Investing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[300px] w-full">
|
<div className="h-[300px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={data}>
|
<AreaChart data={data}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorGrowth" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorGrowth" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0} />
|
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="year"
|
dataKey="year"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
dy={10}
|
dy={10}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
tickFormatter={(value) => `$${value / 1000}k`}
|
tickFormatter={(value) => `$${value / 1000}k`}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "var(--background)",
|
backgroundColor: "var(--background)",
|
||||||
borderColor: "var(--border)",
|
borderColor: "var(--border)",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)"
|
boxShadow: "0 4px 12px rgba(0,0,0,0.1)"
|
||||||
}}
|
}}
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={mode}
|
dataKey={mode}
|
||||||
stroke="var(--primary)"
|
stroke="var(--primary)"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
fill="url(#colorGrowth)"
|
fill="url(#colorGrowth)"
|
||||||
animationDuration={1500}
|
animationDuration={1500}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Monthly Contribution</span>
|
<span className="text-muted-foreground">Monthly Contribution</span>
|
||||||
<span className="font-bold text-foreground">${monthlyContribution}</span>
|
<span className="font-bold text-foreground">${monthlyContribution}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="100"
|
min="100"
|
||||||
max="5000"
|
max="5000"
|
||||||
step="100"
|
step="100"
|
||||||
value={monthlyContribution}
|
value={monthlyContribution}
|
||||||
onChange={(e) => setMonthlyContribution(Number(e.target.value))}
|
onChange={(e) => setMonthlyContribution(Number(e.target.value))}
|
||||||
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>$100</span>
|
<span>$100</span>
|
||||||
<span>$5,000</span>
|
<span>$5,000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,91 +1,91 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
meta: { timestamp: string; version: "v1" };
|
meta: { timestamp: string; version: "v1" };
|
||||||
error: null | { message: string; code?: string };
|
error: null | { message: string; code?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type LinkTokenData = { linkToken?: string };
|
type LinkTokenData = { linkToken?: string };
|
||||||
type ExportData = { status?: string; url?: string; csv?: string };
|
type ExportData = { status?: string; url?: string; csv?: string };
|
||||||
|
|
||||||
async function postJson<T>(path: string) {
|
async function postJson<T>(path: string) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Request failed");
|
throw new Error("Request failed");
|
||||||
}
|
}
|
||||||
const payload = (await res.json()) as ApiResponse<T>;
|
const payload = (await res.json()) as ApiResponse<T>;
|
||||||
return payload.data;
|
return payload.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string) {
|
async function getJson<T>(path: string) {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Request failed");
|
throw new Error("Request failed");
|
||||||
}
|
}
|
||||||
const payload = (await res.json()) as ApiResponse<T>;
|
const payload = (await res.json()) as ApiResponse<T>;
|
||||||
return payload.data;
|
return payload.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroActions() {
|
export function HeroActions() {
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
|
|
||||||
const onStart = async () => {
|
const onStart = async () => {
|
||||||
setStatus("Requesting a secure link token...");
|
setStatus("Requesting a secure link token...");
|
||||||
try {
|
try {
|
||||||
const data = await postJson<LinkTokenData>("/api/accounts/link");
|
const data = await postJson<LinkTokenData>("/api/accounts/link");
|
||||||
if (data.linkToken) {
|
if (data.linkToken) {
|
||||||
setStatus(`Link token ready: ${data.linkToken}`);
|
setStatus(`Link token ready: ${data.linkToken}`);
|
||||||
} else {
|
} else {
|
||||||
setStatus("Link token requested.");
|
setStatus("Link token requested.");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Unable to request link token.");
|
setStatus("Unable to request link token.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onViewExport = async () => {
|
const onViewExport = async () => {
|
||||||
setStatus("Preparing export sample...");
|
setStatus("Preparing export sample...");
|
||||||
try {
|
try {
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
const userId = localStorage.getItem("ledgerone_user_id");
|
||||||
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||||
const data = await getJson<ExportData>(`/api/exports/csv${query}`);
|
const data = await getJson<ExportData>(`/api/exports/csv${query}`);
|
||||||
if (data.csv) {
|
if (data.csv) {
|
||||||
const blob = new Blob([data.csv], { type: "text/csv" });
|
const blob = new Blob([data.csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
setStatus("Export sample opened.");
|
setStatus("Export sample opened.");
|
||||||
} else {
|
} else {
|
||||||
setStatus("Export sample ready.");
|
setStatus("Export sample ready.");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Unable to fetch export sample.");
|
setStatus("Unable to fetch export sample.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStart}
|
onClick={onStart}
|
||||||
className="rounded-full bg-ink px-6 py-3 text-sm font-semibold text-haze shadow-glow"
|
className="rounded-full bg-ink px-6 py-3 text-sm font-semibold text-haze shadow-glow"
|
||||||
>
|
>
|
||||||
Start a private ledger
|
Start a private ledger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onViewExport}
|
onClick={onViewExport}
|
||||||
className="rounded-full border border-ink/20 bg-white/70 px-6 py-3 text-sm font-semibold text-ink"
|
className="rounded-full border border-ink/20 bg-white/70 px-6 py-3 text-sm font-semibold text-ink"
|
||||||
>
|
>
|
||||||
View export sample
|
View export sample
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{status ? <p className="text-xs text-muted">{status}</p> : null}
|
{status ? <p className="text-xs text-muted">{status}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
type SchemaProps = {
|
type SchemaProps = {
|
||||||
schema: Record<string, unknown> | Array<Record<string, unknown>>;
|
schema: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PageSchema({ schema }: SchemaProps) {
|
export function PageSchema({ schema }: SchemaProps) {
|
||||||
const payload = Array.isArray(schema) ? schema : [schema];
|
const payload = Array.isArray(schema) ? schema : [schema];
|
||||||
return (
|
return (
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(payload) }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
115
lib/api.ts
Normal file
115
lib/api.ts
Normal 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
73
lib/backend.ts
Normal 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
38
middleware.ts
Normal 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
1
package-lock.json
generated
@ -1433,6 +1433,7 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@ -21,7 +21,10 @@
|
|||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
@ -32,4 +35,4 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
240
write-frontend-1-lib.mjs
Normal file
240
write-frontend-1-lib.mjs
Normal 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
764
write-frontend-2-auth.mjs
Normal 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'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");
|
||||||
370
write-frontend-3-proxies.mjs
Normal file
370
write-frontend-3-proxies.mjs
Normal 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)");
|
||||||
249
write-frontend-4-appshell.mjs
Normal file
249
write-frontend-4-appshell.mjs
Normal 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");
|
||||||
594
write-frontend-6-settings.mjs
Normal file
594
write-frontend-6-settings.mjs
Normal 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");
|
||||||
517
write-frontend-7-transactions.mjs
Normal file
517
write-frontend-7-transactions.mjs
Normal 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");
|
||||||
221
write-frontend-8-exports.mjs
Normal file
221
write-frontend-8-exports.mjs
Normal 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");
|
||||||
Loading…
x
Reference in New Issue
Block a user