Polish LedgerOne frontend signup, auth, and marketing UI

Made-with: Cursor
This commit is contained in:
sharada-gif 2026-03-18 13:02:58 -07:00
commit 28cefdc7e7
134 changed files with 15983 additions and 0 deletions

6
.dockerignore Normal file
View File

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

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# --- DEPENDENCIES ---
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# --- NEXT.JS BUILD OUTPUT ---
.next/
out/
build/
# --- ENVIRONMENT FILES ---
.env
.env.local
.env.development
.env.development.local
.env.production
.env.production.local
.env.test
.env.test.local
# --- PLAYWRIGHT TEST OUTPUT ---
/test-results/
/playwright-report/
/blob-report/
/coverage/
# --- TYPESCRIPT ---
*.tsbuildinfo
# --- LOG FILES ---
*.log
logs/
# --- SYSTEM FILES ---
.DS_Store
Thumbs.db
# --- EDITOR / OS FILES ---
.vscode/
.idea/
*.swp
# --- MISC ---
*.pem
*.key
*.crt

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
# ─── Stage 2: Production ─────────────────────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3052
CMD ["node", "server.js"]

199
app/about/page.tsx Normal file
View File

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

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "2fa/disable");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/enable");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/generate");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts/link-token");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/manual");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/forgot-password");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/logout");
}

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

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
export async function PATCH(req: NextRequest) {
return proxyRequest(req, "auth/profile");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/refresh");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/reset-password");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/verify-email");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/csv");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "exports/sheets");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "google/connect");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "google/disconnect");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "google/exchange");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "google/status");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/exchange");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/link-token");
}

10
app/api/rules/route.ts Normal file
View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "rules");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules/suggestions");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/checkout");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/portal");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "stripe/subscription");
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `tax/returns/${params.id}/export`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `transactions/${params.id}/derived`);
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/cashflow");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/manual");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/merchants");
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/summary");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/sync");
}

251
app/app/connect/page.tsx Normal file
View File

@ -0,0 +1,251 @@
"use client";
import { AppShell } from "../../../components/app-shell";
import { useCallback, useEffect, useState } from "react";
import { usePlaidLink } from "react-plaid-link";
type Account = {
id: string;
institutionName: string;
accountType: string;
mask?: string | null;
};
export default function ConnectPage() {
const [status, setStatus] = useState("");
const [linkToken, setLinkToken] = useState<string | null>(null);
const [manualMode, setManualMode] = useState(false);
const [manualBank, setManualBank] = useState("");
const [manualRouting, setManualRouting] = useState("");
const [manualAccount, setManualAccount] = useState("");
const [manualType, setManualType] = useState("checking");
const [accounts, setAccounts] = useState<Account[]>([]);
const createLinkToken = useCallback(async () => {
setStatus("Requesting Plaid link token...");
try {
const res = await fetch("/api/plaid/link-token", { method: "POST" });
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to create link token.");
return;
}
const token = payload.data?.linkToken ?? payload.data?.link_token;
if (token) {
setLinkToken(token);
setStatus("Link token ready.");
} else {
setStatus("Link token created.");
}
} catch {
setStatus("Unable to create link token.");
}
}, []);
const loadAccounts = useCallback(async () => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
return;
}
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
if (!res.ok) {
return;
}
const payload = await res.json();
setAccounts(payload.data ?? []);
}, []);
useEffect(() => {
createLinkToken();
loadAccounts();
}, [createLinkToken, loadAccounts]);
const onSuccess = useCallback(
async (publicToken: string) => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id. Please sign in again.");
return;
}
setStatus("Exchanging public token...");
try {
const res = await fetch("/api/plaid/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publicToken, userId })
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to exchange token.");
return;
}
setStatus("Bank account connected.");
await loadAccounts();
} catch {
setStatus("Unable to exchange token.");
}
},
[loadAccounts]
);
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess,
onExit: () => {
setStatus("Plaid Link closed.");
}
});
const onManualSubmit = (event: React.FormEvent) => {
event.preventDefault();
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id. Please sign in again.");
return;
}
const payload = {
userId,
institutionName: manualBank,
accountType: manualType,
mask: manualAccount.slice(-4)
};
fetch("/api/accounts/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then((res) => res.json())
.then((data) => {
if (data?.error) {
setStatus(data.error?.message ?? "Unable to save manual account.");
return;
}
setStatus(`Manual account saved for ${manualBank}.`);
setManualBank("");
setManualRouting("");
setManualAccount("");
setManualType("checking");
loadAccounts();
})
.catch(() => {
setStatus("Unable to save manual account.");
});
};
return (
<AppShell
title="Connect a bank"
subtitle="Link your first account to begin syncing transactions."
>
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-bold text-foreground">Bank connections</h2>
<p className="mt-2 text-sm text-muted-foreground">
Securely connect your bank or card account to start syncing.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
className="rounded-full bg-primary px-5 py-2 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => (ready ? open() : createLinkToken())}
disabled={!ready && !linkToken}
>
Connect with Plaid
</button>
<button
type="button"
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
onClick={() => setManualMode((value) => !value)}
>
{manualMode ? "Hide manual entry" : "Enter bank details"}
</button>
</div>
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
{accounts.length ? (
<div className="mt-6 space-y-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Connected accounts
</p>
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm"
>
<div>
<p className="font-bold text-foreground">{account.institutionName}</p>
<p className="text-xs text-muted-foreground">
{account.accountType} {account.mask ? `- ${account.mask}` : ""}
</p>
</div>
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
Connected
</span>
</div>
))}
</div>
) : null}
{manualMode ? (
<form className="mt-6 grid gap-4 md:grid-cols-2" onSubmit={onManualSubmit}>
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Bank name
</label>
<input
className="w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
value={manualBank}
onChange={(event) => setManualBank(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Account type
</label>
<select
className="w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
value={manualType}
onChange={(event) => setManualType(event.target.value)}
>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
<option value="credit">Credit</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Routing number
</label>
<input
className="w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
value={manualRouting}
onChange={(event) => setManualRouting(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Account number
</label>
<input
className="w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
value={manualAccount}
onChange={(event) => setManualAccount(event.target.value)}
required
/>
</div>
<div className="md:col-span-2">
<button
type="submit"
className="rounded-full bg-primary px-5 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Save bank details
</button>
</div>
</form>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Your first two connections are free. Upgrade to add unlimited accounts.
</p>
</div>
</AppShell>
);
}

772
app/app/page copy 2.tsx Normal file
View File

@ -0,0 +1,772 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type Summary = {
total: string;
count: number;
income?: string;
expense?: string;
net?: string;
};
type CashflowPoint = {
month: string;
income: string;
expense: string;
net: string;
};
type MerchantInsight = {
merchant: string;
total: string;
count: number;
};
const recentTransactions = [
{
date: "Oct 24, 2023",
description: "Whole Foods Market",
category: "Groceries",
account: "Chase Sapphire",
amount: "-$142.30"
},
{
date: "Oct 23, 2023",
description: "Apple Subscription",
category: "Services",
account: "Apple Card",
amount: "-$14.99"
},
{
date: "Oct 22, 2023",
description: "Stripe Payout",
category: "Income",
account: "Mercury Business",
amount: "+$4,200.00"
},
{
date: "Oct 22, 2023",
description: "Shell Gasoline",
category: "Transport",
account: "Chase Sapphire",
amount: "-$52.12"
}
];
// ---------- helpers ----------
function formatMonthLabel(yyyyMm: string) {
const [y, m] = yyyyMm.split("-").map((v) => Number(v));
const d = new Date(y, (m || 1) - 1, 1);
return d.toLocaleString(undefined, { month: "short", year: "numeric" });
}
function formatCurrency(n: number) {
const abs = Math.abs(n);
const sign = n < 0 ? "-" : "";
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function parseMoney(s?: string) {
return Number.parseFloat(s ?? "0") || 0;
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
// ---------- tiny UI bits ----------
function Pill({
label,
value,
tone = "neutral"
}: {
label: string;
value: string;
tone?: "neutral" | "good" | "bad";
}) {
const toneCls =
tone === "good"
? "text-primary bg-primary/10"
: tone === "bad"
? "text-red-500 bg-red-500/10"
: "text-muted-foreground bg-secondary/40";
return (
<span className={`inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs ${toneCls}`}>
<span className="text-[10px] uppercase tracking-[0.18em]">{label}</span>
<span className="font-semibold text-foreground">{value}</span>
</span>
);
}
function ChartLoadingSkeleton() {
// A nicer loading state than a big black rectangle:
// grid + animated wave + fake bars/line.
return (
<div className="relative h-56 w-full overflow-hidden rounded-xl border border-border bg-background/50">
<div
className="absolute inset-0"
style={{
backgroundImage:
"linear-gradient(rgba(49, 98, 99, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(49, 98, 99, 0.06) 1px, transparent 1px)",
backgroundSize: "40px 40px"
}}
/>
{/* shimmer wave */}
<motion.div
className="absolute inset-0"
initial={{ x: "-60%" }}
animate={{ x: "160%" }}
transition={{ duration: 1.3, repeat: Infinity, ease: "linear" }}
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 40%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.06) 60%, transparent 100%)"
}}
/>
{/* fake bars */}
<div className="absolute inset-x-4 bottom-4 flex items-end gap-3">
{Array.from({ length: 10 }).map((_, i) => (
<motion.div
key={i}
className="w-4 rounded-lg bg-secondary/60 border border-border"
initial={{ height: 10 }}
animate={{ height: [10, 36, 18, 42, 22, 50, 30, 44][i % 8] }}
transition={{ duration: 0.8, repeat: Infinity, repeatType: "mirror", delay: i * 0.06 }}
/>
))}
</div>
{/* fake line */}
<motion.div
className="absolute left-6 right-6 top-12 h-[2px] rounded-full bg-primary/40"
initial={{ scaleX: 0, originX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.9, repeat: Infinity, repeatType: "mirror" }}
/>
</div>
);
}
// ---------- chart ----------
function CashflowChart({
data,
loading
}: {
data: CashflowPoint[];
loading: boolean;
}) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const parsed = useMemo(() => {
return (data ?? []).map((d) => {
const income = parseMoney(d.income);
const expense = parseMoney(d.expense);
const net = parseMoney(d.net);
return { ...d, income, expense, net, label: formatMonthLabel(d.month) };
});
}, [data]);
const chart = useMemo(() => {
const W = 640;
const H = 240;
const PAD_L = 36;
const PAD_R = 18;
const PAD_T = 16;
const PAD_B = 34;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
if (!parsed.length) {
return {
W,
H,
PAD_L,
PAD_R,
PAD_T,
PAD_B,
innerW,
innerH,
yZero: PAD_T + innerH / 2,
points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[],
lineD: "",
areaD: "",
bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[],
ticks: [] as { y: number; value: number }[]
};
}
const nets = parsed.map((d) => d.net);
const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v)));
const yMax = maxAbs;
const yMin = -maxAbs;
const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1);
const yScale = (v: number) => {
const t = (v - yMax) / (yMin - yMax);
return PAD_T + t * innerH;
};
const yZero = yScale(0);
const points = parsed.map((d, i) => {
const x = PAD_L + xStep * i;
const y = yScale(d.net);
return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month };
});
const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
const areaD = [
`M ${points[0].x} ${yZero}`,
...points.map((p) => `L ${p.x} ${p.y}`),
`L ${points[points.length - 1].x} ${yZero}`,
"Z"
].join(" ");
const maxBar = Math.max(1, ...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense))));
const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.46);
const barBand = innerW / parsed.length;
const barW = Math.max(10, Math.min(22, barBand * 0.42));
const bars = parsed.map((d, i) => {
const cx = PAD_L + barBand * i + barBand / 2;
const x = cx - barW / 2;
const hIncome = barScale(d.income);
const hExpense = barScale(d.expense);
const baseY = PAD_T + innerH;
const yIncome = baseY - hIncome;
const yExpense = baseY - hExpense;
return { x, w: barW, yIncome, hIncome, yExpense, hExpense };
});
const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) }));
return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks };
}, [parsed]);
const active = activeIndex === null ? null : parsed[activeIndex];
function pickNearestIndex(clientX: number) {
if (!wrapRef.current || !chart.points.length) return null;
const rect = wrapRef.current.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * chart.W;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < chart.points.length; i++) {
const d = Math.abs(chart.points[i].x - x);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
if (loading) return <ChartLoadingSkeleton />;
if (!parsed.length) {
return (
<div className="mt-4 rounded-xl border border-border p-4 bg-background/50">
<div className="h-56 grid place-items-center text-sm text-muted-foreground">No cash flow data available</div>
</div>
);
}
return (
<div
ref={wrapRef}
className="mt-4 rounded-xl border border-border p-4 bg-background/50 relative overflow-hidden"
style={{
backgroundImage:
"linear-gradient(rgba(49, 98, 99, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(49, 98, 99, 0.06) 1px, transparent 1px)",
backgroundSize: "40px 40px"
}}
onMouseLeave={() => setActiveIndex(null)}
onMouseMove={(e) => setActiveIndex(pickNearestIndex(e.clientX))}
>
{/* subtle moving glow */}
<motion.div
className="pointer-events-none absolute -inset-24 opacity-40"
animate={{ rotate: 360 }}
transition={{ duration: 18, repeat: Infinity, ease: "linear" }}
style={{
background:
"radial-gradient(circle at 30% 20%, rgba(55,121,185,0.14), transparent 45%), radial-gradient(circle at 70% 60%, rgba(49,98,99,0.12), transparent 50%)"
}}
/>
<svg viewBox={`0 0 ${chart.W} ${chart.H}`} className="relative h-56 w-full">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.03" />
</linearGradient>
<linearGradient id="barsIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.12" />
</linearGradient>
<linearGradient id="barsExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(239 68 68)" stopOpacity="0.55" />
<stop offset="100%" stopColor="rgb(239 68 68)" stopOpacity="0.12" />
</linearGradient>
</defs>
{/* ticks + baseline pulse */}
{chart.ticks.map((t) => (
<g key={t.value}>
<line
x1={chart.PAD_L}
x2={chart.W - chart.PAD_R}
y1={t.y}
y2={t.y}
stroke="currentColor"
strokeOpacity={t.value === 0 ? 0.18 : 0.08}
/>
<text x={6} y={t.y + 4} fill="currentColor" className="text-[10px] text-muted-foreground">
{formatCurrency(t.value)}
</text>
</g>
))}
<motion.line
x1={chart.PAD_L}
x2={chart.W - chart.PAD_R}
y1={chart.yZero}
y2={chart.yZero}
stroke="currentColor"
strokeOpacity="0.18"
strokeDasharray="6 6"
animate={{ strokeOpacity: [0.10, 0.22, 0.10] }}
transition={{ duration: 2.2, repeat: Infinity }}
/>
{/* bars */}
{chart.bars.map((b, i) => (
<g key={i}>
<motion.rect
x={b.x - 6}
y={b.yExpense}
width={b.w}
height={b.hExpense}
rx="7"
fill="url(#barsExpense)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hExpense, y: b.yExpense }}
transition={{ duration: 0.6, delay: i * 0.05, ease: [0.2, 0.8, 0.2, 1] }}
/>
<motion.rect
x={b.x + 6}
y={b.yIncome}
width={b.w}
height={b.hIncome}
rx="7"
fill="url(#barsIncome)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hIncome, y: b.yIncome }}
transition={{ duration: 0.6, delay: i * 0.05 + 0.06, ease: [0.2, 0.8, 0.2, 1] }}
/>
</g>
))}
{/* area */}
<motion.path
d={chart.areaD}
fill="url(#netFill)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
/>
{/* net line */}
<motion.path
d={chart.lineD}
fill="none"
stroke="var(--primary)"
strokeWidth="3.2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.05, ease: [0.2, 0.8, 0.2, 1] }}
/>
{/* points */}
{chart.points.map((p, i) => {
const isActive = i === activeIndex;
return (
<g key={i}>
{isActive ? <circle cx={p.x} cy={p.y} r="14" fill="var(--primary)" opacity="0.10" /> : null}
<motion.circle
cx={p.x}
cy={p.y}
r={isActive ? 6 : 4}
fill="var(--primary)"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.28, delay: i * 0.03 }}
opacity={isActive ? 1 : 0.75}
/>
</g>
);
})}
{/* hover crosshair */}
{activeIndex !== null && chart.points[activeIndex] ? (
<g>
<line
x1={chart.points[activeIndex].x}
x2={chart.points[activeIndex].x}
y1={chart.PAD_T}
y2={chart.PAD_T + chart.innerH}
stroke="currentColor"
strokeOpacity="0.14"
strokeDasharray="4 4"
/>
</g>
) : null}
{/* x labels */}
{chart.points.map((p, i) => (
<text
key={i}
x={p.x}
y={chart.H - 10}
textAnchor="middle"
fill="currentColor"
className="text-[10px] text-muted-foreground"
opacity={i % 2 === 0 ? 0.95 : 0.55}
>
{(parsed[i]?.month ?? "").slice(2)} {/* "25-08" vibe */}
</text>
))}
</svg>
{/* Tooltip */}
<AnimatePresence>
{active && activeIndex !== null ? (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.16 }}
className="absolute top-3 right-3 rounded-xl border border-border bg-background/92 backdrop-blur px-3 py-2 shadow-sm"
>
<div className="text-xs font-semibold text-foreground">{active.label}</div>
<div className="mt-1 grid gap-1 text-[11px] text-muted-foreground">
<div className="flex items-center justify-between gap-6">
<span>Income</span>
<span className="font-semibold text-foreground">{formatCurrency(active.income)}</span>
</div>
<div className="flex items-center justify-between gap-6">
<span>Expense</span>
<span className="font-semibold text-foreground">{formatCurrency(active.expense)}</span>
</div>
<div className="h-px bg-border my-1" />
<div className="flex items-center justify-between gap-6">
<span>Net</span>
<span className={`font-bold ${active.net < 0 ? "text-red-500" : "text-primary"}`}>
{formatCurrency(active.net)}
</span>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
{/* Legend */}
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary" />
Net (line)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-red-500" />
Expense (bar)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary/70" />
Income (bar)
</span>
</div>
</div>
);
}
// ---------- page ----------
export default function AppHomePage() {
const [summary, setSummary] = useState<Summary | null>(null);
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setLoading(false);
return;
}
const query = `?user_id=${encodeURIComponent(userId)}`;
Promise.all([
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>),
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>),
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>),
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>)
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data);
if (!merchantsRes.error) setMerchants(merchantsRes.data);
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
})
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const netNow = useMemo(() => parseMoney(summary?.net), [summary?.net]);
const incomeNow = useMemo(() => parseMoney(summary?.income), [summary?.income]);
const expenseNow = useMemo(() => parseMoney(summary?.expense), [summary?.expense]);
const netTone: "good" | "bad" | "neutral" = netNow > 0 ? "good" : netNow < 0 ? "bad" : "neutral";
const bestWorst = useMemo(() => {
if (!cashflow.length) return null;
const parsed = cashflow.map((c) => ({ month: c.month, net: parseMoney(c.net) }));
const best = parsed.reduce((a, b) => (b.net > a.net ? b : a), parsed[0]);
const worst = parsed.reduce((a, b) => (b.net < a.net ? b : a), parsed[0]);
return { best, worst };
}, [cashflow]);
return (
<AppShell title="Dashboard" subtitle="Performance overview and live account health.">
{/* top filters row (slightly nicer motion) */}
<motion.section
className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Last 30 days
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
All categories
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Combined accounts
</span>
<div className="ml-auto hidden lg:flex items-center gap-2">
<Pill label="Sync" value={loading ? "Updating…" : "Live"} tone="neutral" />
{bestWorst ? (
<Pill label="Worst" value={`${bestWorst.worst.month}: ${formatCurrency(bestWorst.worst.net)}`} tone="bad" />
) : null}
</div>
</motion.section>
{/* KPI cards */}
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{
title: "Income (30d)",
value: loading ? "—" : formatCurrency(incomeNow),
sub: "Deposits and credits",
badge: "Inflow",
badgeCls: "text-primary bg-primary/10"
},
{
title: "Expenses (30d)",
value: loading ? "—" : formatCurrency(expenseNow),
sub: "Bills and spending",
badge: "Outflow",
badgeCls: "text-red-500 bg-red-500/10"
},
{
title: "Net cash flow",
value: loading ? "—" : formatCurrency(netNow),
sub: summary ? `${summary.count} transactions` : "No data yet",
badge: "30d",
badgeCls: netTone === "bad" ? "text-red-500 bg-red-500/10" : "text-primary bg-primary/10"
},
{
title: "Active accounts",
value: loading ? "—" : `${accountCount ?? 0}`,
sub: "Bank + card connections",
badge: "Linked",
badgeCls: "text-primary bg-primary/10"
}
].map((card, idx) => (
<motion.div
key={card.title}
className="glass-panel p-5 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: idx * 0.05 }}
>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{card.title}</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">{card.value}</p>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${card.badgeCls}`}>{card.badge}</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">{card.sub}</p>
</motion.div>
))}
</section>
{/* main grid */}
<section className="mt-6 grid gap-4 xl:grid-cols-[2fr_1fr]">
<motion.div
className="glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.05 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Monthly Cash-Flow</p>
<p className="text-xs text-muted-foreground">Net flow over last 6 months</p>
</div>
<div className="hidden md:flex items-center gap-2">
<Pill label="Net" value={loading ? "—" : formatCurrency(netNow)} tone={netTone} />
<Pill label="Window" value="6M" tone="neutral" />
</div>
</div>
<CashflowChart data={cashflow} loading={loading} />
<div className="mt-4 grid gap-2 text-xs text-muted-foreground md:grid-cols-3">
{(cashflow ?? []).map((item) => {
const net = parseMoney(item.net);
return (
<div key={item.month} className="flex items-center justify-between rounded-lg border border-border bg-secondary/20 px-3 py-2">
<span className="font-medium">{formatMonthLabel(item.month)}</span>
<span className={`font-bold ${net < 0 ? "text-red-500" : "text-foreground"}`}>
{formatCurrency(net)}
</span>
</div>
);
})}
</div>
</motion.div>
<motion.div
className="glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.1 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Top Merchants</p>
<p className="text-xs text-muted-foreground">Highest spend by merchant</p>
</div>
</div>
<div className="mt-6 space-y-4 text-xs">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-secondary/20 p-3">
<div className="h-3 w-2/3 bg-secondary/60 rounded animate-pulse" />
<div className="mt-2 h-3 w-1/2 bg-secondary/60 rounded animate-pulse" />
</div>
))}
</div>
) : merchants.length ? (
merchants.map((merchant, i) => (
<motion.div
key={merchant.merchant}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: i * 0.04 }}
className="flex items-center justify-between border-b border-border pb-2 last:border-0 last:pb-0"
>
<div>
<p className="text-sm font-medium text-foreground">{merchant.merchant}</p>
<p className="text-xs text-muted-foreground">
{merchant.count} transaction{merchant.count === 1 ? "" : "s"}
</p>
</div>
<span className="text-sm font-bold text-foreground">${merchant.total}</span>
</motion.div>
))
) : (
<p className="text-sm text-muted-foreground">No merchant insights yet.</p>
)}
</div>
</motion.div>
</section>
{/* table */}
<motion.section
className="mt-6 glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.15 }}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-foreground">Recent Transactions</h2>
<span className="text-xs font-medium text-primary cursor-pointer hover:underline">View all records</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.2em] text-muted-foreground font-semibold">
<tr className="border-b border-border">
<th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th>
<th className="pb-3">Category</th>
<th className="pb-3">Account</th>
<th className="pb-3 pr-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{recentTransactions.map((tx) => (
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{tx.date}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
</td>
<td className="py-3">{tx.account}</td>
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}>
{tx.amount}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.section>
</AppShell>
);
}

677
app/app/page copy.tsx Normal file
View File

@ -0,0 +1,677 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type Summary = {
total: string;
count: number;
income?: string;
expense?: string;
net?: string;
};
type CashflowPoint = {
month: string;
income: string;
expense: string;
net: string;
};
type MerchantInsight = {
merchant: string;
total: string;
count: number;
};
type Account = {
id: string;
};
const recentTransactions = [
{
date: "Oct 24, 2023",
description: "Whole Foods Market",
category: "Groceries",
account: "Chase Sapphire",
amount: "-$142.30"
},
{
date: "Oct 23, 2023",
description: "Apple Subscription",
category: "Services",
account: "Apple Card",
amount: "-$14.99"
},
{
date: "Oct 22, 2023",
description: "Stripe Payout",
category: "Income",
account: "Mercury Business",
amount: "+$4,200.00"
},
{
date: "Oct 22, 2023",
description: "Shell Gasoline",
category: "Transport",
account: "Chase Sapphire",
amount: "-$52.12"
}
];
import { motion, AnimatePresence } from "framer-motion";
function formatMonthLabel(yyyyMm: string) {
// "2026-01" -> "Jan 2026"
const [y, m] = yyyyMm.split("-").map((v) => Number(v));
const d = new Date(y, (m || 1) - 1, 1);
return d.toLocaleString(undefined, { month: "short", year: "numeric" });
}
function formatCurrency(n: number) {
const abs = Math.abs(n);
const sign = n < 0 ? "-" : "";
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function CashflowChart({
data,
loading
}: {
data: CashflowPoint[];
loading: boolean;
}) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const parsed = useMemo(() => {
return (data ?? []).map((d) => {
const income = Number.parseFloat(d.income ?? "0") || 0;
const expense = Number.parseFloat(d.expense ?? "0") || 0;
const net = Number.parseFloat(d.net ?? "0") || 0;
return {
...d,
income,
expense,
net,
label: formatMonthLabel(d.month)
};
});
}, [data]);
const chart = useMemo(() => {
const W = 600;
const H = 240;
const PAD_L = 28;
const PAD_R = 18;
const PAD_T = 18;
const PAD_B = 30;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
if (!parsed.length) {
return {
W,
H,
PAD_L,
PAD_R,
PAD_T,
PAD_B,
innerW,
innerH,
yZero: PAD_T + innerH / 2,
points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[],
lineD: "",
areaD: "",
bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[],
ticks: [] as { y: number; value: number }[]
};
}
const nets = parsed.map((d) => d.net);
// symmetric range around 0 so negatives/positives are balanced visually
const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v)));
const yMax = maxAbs;
const yMin = -maxAbs;
const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1);
const yScale = (v: number) => {
// map yMax -> PAD_T, yMin -> PAD_T+innerH
const t = (v - yMax) / (yMin - yMax);
return PAD_T + t * innerH;
};
const yZero = yScale(0);
const points = parsed.map((d, i) => {
const x = PAD_L + xStep * i;
const y = yScale(d.net);
return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month };
});
const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
// area fill to the zero baseline
const areaD = [
`M ${points[0].x} ${yZero}`,
...points.map((p) => `L ${p.x} ${p.y}`),
`L ${points[points.length - 1].x} ${yZero}`,
"Z"
].join(" ");
// income/expense bars (scaled using max of income/expense)
const maxBar = Math.max(
1,
...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense)))
);
const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.42);
const barBand = innerW / parsed.length;
const barW = Math.max(10, Math.min(22, barBand * 0.45));
const bars = parsed.map((d, i) => {
const cx = PAD_L + barBand * i + barBand / 2;
const x = cx - barW / 2;
const hIncome = barScale(d.income);
const hExpense = barScale(d.expense);
// draw both as upward bars from the bottom area
const baseY = PAD_T + innerH; // bottom
const yIncome = baseY - hIncome;
const yExpense = baseY - hExpense;
return { x, w: barW, yIncome, hIncome, yExpense, hExpense };
});
// y-axis ticks for context (3 ticks)
const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) }));
return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks };
}, [parsed]);
const active = activeIndex === null ? null : parsed[activeIndex];
function pickNearestIndex(clientX: number) {
if (!wrapRef.current || !chart.points.length) return null;
const rect = wrapRef.current.getBoundingClientRect();
// convert clientX to svg X (0..W)
const x = ((clientX - rect.left) / rect.width) * chart.W;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < chart.points.length; i++) {
const d = Math.abs(chart.points[i].x - x);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
return (
<div
ref={wrapRef}
className="mt-4 rounded-xl border border-border p-4 bg-background/50 relative overflow-hidden"
style={{
backgroundImage:
"linear-gradient(rgba(49, 98, 99, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(49, 98, 99, 0.06) 1px, transparent 1px)",
backgroundSize: "40px 40px"
}}
onMouseLeave={() => setActiveIndex(null)}
onMouseMove={(e) => {
const idx = pickNearestIndex(e.clientX);
setActiveIndex(idx);
}}
>
<svg viewBox={`0 0 ${chart.W} ${chart.H}`} className="h-56 w-full">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.22" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.02" />
</linearGradient>
<linearGradient id="barsIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.10" />
</linearGradient>
<linearGradient id="barsExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(239 68 68)" stopOpacity="0.55" />
<stop offset="100%" stopColor="rgb(239 68 68)" stopOpacity="0.10" />
</linearGradient>
</defs>
{loading ? (
<rect
x="0"
y="0"
width={chart.W}
height={chart.H}
fill="currentColor"
className="text-secondary/30 animate-pulse"
/>
) : !chart.points.length ? (
<text x={chart.W / 2} y={chart.H / 2} textAnchor="middle" fill="currentColor" className="text-xs text-muted-foreground">
No cash flow data available
</text>
) : (
<>
{/* y ticks */}
{chart.ticks.map((t) => (
<g key={t.value}>
<line
x1={chart.PAD_L}
x2={chart.W - chart.PAD_R}
y1={t.y}
y2={t.y}
stroke="currentColor"
strokeOpacity={t.value === 0 ? 0.18 : 0.08}
/>
<text
x={6}
y={t.y + 4}
fill="currentColor"
className="text-[10px] text-muted-foreground"
>
{formatCurrency(t.value)}
</text>
</g>
))}
{/* income/expense bars (bottom) */}
{chart.bars.map((b, i) => (
<g key={i}>
<motion.rect
x={b.x - 6}
y={b.yExpense}
width={b.w}
height={b.hExpense}
rx="6"
fill="url(#barsExpense)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hExpense, y: b.yExpense }}
transition={{ duration: 0.55, delay: i * 0.04, ease: [0.2, 0.8, 0.2, 1] }}
opacity={0.9}
/>
<motion.rect
x={b.x + 6}
y={b.yIncome}
width={b.w}
height={b.hIncome}
rx="6"
fill="url(#barsIncome)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hIncome, y: b.yIncome }}
transition={{ duration: 0.55, delay: i * 0.04 + 0.06, ease: [0.2, 0.8, 0.2, 1] }}
opacity={0.9}
/>
</g>
))}
{/* net area */}
<motion.path
d={chart.areaD}
fill="url(#netFill)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.45 }}
/>
{/* net line */}
<motion.path
d={chart.lineD}
fill="none"
stroke="var(--primary)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.9, ease: [0.2, 0.8, 0.2, 1] }}
/>
{/* points */}
{chart.points.map((p, i) => {
const isActive = i === activeIndex;
return (
<g key={i}>
<motion.circle
cx={p.x}
cy={p.y}
r={isActive ? 6 : 4}
fill="var(--primary)"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.25, delay: i * 0.03 }}
opacity={isActive ? 1 : 0.75}
/>
{/* subtle glow ring on active */}
{isActive ? (
<circle cx={p.x} cy={p.y} r="12" fill="var(--primary)" opacity="0.10" />
) : null}
</g>
);
})}
{/* hover crosshair */}
{activeIndex !== null && chart.points[activeIndex] ? (
<g>
<line
x1={chart.points[activeIndex].x}
x2={chart.points[activeIndex].x}
y1={chart.PAD_T}
y2={chart.PAD_T + chart.innerH}
stroke="currentColor"
strokeOpacity="0.15"
strokeDasharray="4 4"
/>
</g>
) : null}
{/* x labels (every point; small) */}
{chart.points.map((p, i) => (
<text
key={i}
x={p.x}
y={chart.H - 10}
textAnchor="middle"
fill="currentColor"
className="text-[10px] text-muted-foreground"
opacity={i % 2 === 0 ? 0.9 : 0.55}
>
{parsed[i]?.month ?? ""}
</text>
))}
</>
)}
</svg>
{/* Tooltip */}
<AnimatePresence>
{!loading && active && activeIndex !== null ? (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.18 }}
className="absolute top-3 right-3 rounded-xl border border-border bg-background/90 backdrop-blur px-3 py-2 shadow-sm"
>
<div className="text-xs font-semibold text-foreground">{active.label}</div>
<div className="mt-1 grid gap-1 text-[11px] text-muted-foreground">
<div className="flex items-center justify-between gap-6">
<span>Income</span>
<span className="font-semibold text-foreground">{formatCurrency(active.income)}</span>
</div>
<div className="flex items-center justify-between gap-6">
<span>Expense</span>
<span className="font-semibold text-foreground">{formatCurrency(active.expense)}</span>
</div>
<div className="h-px bg-border my-1" />
<div className="flex items-center justify-between gap-6">
<span>Net</span>
<span className={`font-bold ${active.net < 0 ? "text-red-500" : "text-primary"}`}>
{formatCurrency(active.net)}
</span>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
{/* Legend */}
{!loading && parsed.length ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary" />
Net (line)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-red-500" />
Expense (bar)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary/70" />
Income (bar)
</span>
</div>
) : null}
</div>
);
}
export default function AppHomePage() {
const [summary, setSummary] = useState<Summary | null>(null);
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setLoading(false);
return;
}
const query = `?user_id=${encodeURIComponent(userId)}`;
Promise.all([
fetch(`/api/transactions/summary${query}`).then((res) => res.json()),
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json()),
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json()),
fetch(`/api/accounts${query}`).then((res) => res.json())
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data);
if (!merchantsRes.error) setMerchants(merchantsRes.data);
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
})
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const cashflowPath = useMemo(() => {
if (!cashflow.length) {
return "";
}
const values = cashflow.map((item) => Number.parseFloat(item.net));
const max = Math.max(...values.map((v) => Math.abs(v)), 1);
const stepX = 460 / Math.max(values.length - 1, 1);
return values
.map((value, index) => {
const x = 10 + stepX * index;
const y = 170 - (value / max) * 120;
return `${index === 0 ? "M" : "L"} ${x} ${y}`;
})
.join(" ");
}, [cashflow]);
return (
<AppShell title="Dashboard" subtitle="Performance overview and live account health.">
<section className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Last 30 days
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
All categories
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Combined accounts
</span>
<span className="ml-auto hidden text-xs text-muted-foreground lg:block">Auto sync enabled</span>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="glass-panel p-5 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Income (30d)</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">
{summary?.income ? `$${summary.income}` : "$0.00"}
</p>
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-full">Inflow</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">Deposits and credits</p>
</div>
<div className="glass-panel p-5 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Expenses (30d)</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">
{summary?.expense ? `$${summary.expense}` : "$0.00"}
</p>
<span className="text-xs font-medium text-red-500 bg-red-500/10 px-2 py-1 rounded-full">Outflow</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">Bills and spending</p>
</div>
<div className="glass-panel p-5 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Net cash flow</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">
{summary?.net ? `$${summary.net}` : "$0.00"}
</p>
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-full">30d</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{summary ? `${summary.count} transactions` : "No data yet"}
</p>
</div>
<div className="glass-panel p-5 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Active accounts</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">
{accountCount !== null ? accountCount : 0}
</p>
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-full">Linked</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">Bank + card connections</p>
</div>
</section>
<section className="mt-6 grid gap-4 xl:grid-cols-[2fr_1fr]">
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Monthly Cash-Flow</p>
<p className="text-xs text-muted-foreground">Net flow over last 6 months</p>
</div>
<div className="text-xs text-muted-foreground font-medium">Income vs expense</div>
</div>
<CashflowChart data={cashflow} loading={loading} />
<div className="mt-4 grid gap-2 text-xs text-muted-foreground md:grid-cols-3">
{cashflow.map((item) => (
<div key={item.month} className="flex items-center justify-between">
<span className="font-medium">{item.month}</span>
<span className="text-foreground font-bold">${item.net}</span>
</div>
))}
</div>
</div>
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Top Merchants</p>
<p className="text-xs text-muted-foreground">Highest spend by merchant</p>
</div>
</div>
<div className="mt-6 space-y-4 text-xs">
{merchants.length ? (
merchants.map((merchant) => (
<div key={merchant.merchant} className="flex items-center justify-between border-b border-border pb-2 last:border-0 last:pb-0">
<div>
<p className="text-sm font-medium text-foreground">{merchant.merchant}</p>
<p className="text-xs text-muted-foreground">
{merchant.count} transaction{merchant.count === 1 ? "" : "s"}
</p>
</div>
<span className="text-sm font-bold text-foreground">${merchant.total}</span>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No merchant insights yet.</p>
)}
</div>
</div>
</section>
<section className="mt-6 glass-panel p-6 rounded-2xl shadow-sm">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-foreground">Recent Transactions</h2>
<span className="text-xs font-medium text-primary cursor-pointer hover:underline">View all records</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.2em] text-muted-foreground font-semibold">
<tr className="border-b border-border">
<th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th>
<th className="pb-3">Category</th>
<th className="pb-3">Account</th>
<th className="pb-3 pr-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{recentTransactions.map((tx) => (
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{tx.date}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
</td>
<td className="py-3">{tx.account}</td>
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith('+') ? 'text-primary' : 'text-foreground'}`}>
{tx.amount}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</AppShell>
);
}

769
app/app/page.tsx Normal file
View File

@ -0,0 +1,769 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type Summary = {
total: string;
count: number;
income?: string;
expense?: string;
net?: string;
};
type CashflowPoint = {
month: string;
income: string;
expense: string;
net: string;
};
type MerchantInsight = {
merchant: string;
total: string;
count: number;
};
type TxRow = {
id: string;
date: string;
description: string;
category?: string | null;
accountId?: string | null;
amount: string;
};
// ---------- helpers ----------
function formatMonthLabel(yyyyMm: string) {
const [y, m] = yyyyMm.split("-").map((v) => Number(v));
const d = new Date(y, (m || 1) - 1, 1);
return d.toLocaleString(undefined, { month: "short", year: "numeric" });
}
function formatCurrency(n: number) {
const abs = Math.abs(n);
const sign = n < 0 ? "-" : "";
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function parseMoney(s?: string) {
return Number.parseFloat(s ?? "0") || 0;
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
// ---------- tiny UI bits ----------
function Pill({
label,
value,
tone = "neutral"
}: {
label: string;
value: string;
tone?: "neutral" | "good" | "bad";
}) {
const toneCls =
tone === "good"
? "text-primary bg-primary/10"
: tone === "bad"
? "text-red-500 bg-red-500/10"
: "text-muted-foreground bg-secondary/40";
return (
<span className={`inline-flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs ${toneCls}`}>
<span className="text-[10px] uppercase tracking-[0.18em]">{label}</span>
<span className="font-semibold text-foreground">{value}</span>
</span>
);
}
function ChartLoadingSkeleton() {
// A nicer loading state than a big black rectangle:
// grid + animated wave + fake bars/line.
return (
<div className="relative h-56 w-full overflow-hidden rounded-xl border border-border bg-background/50">
<div
className="absolute inset-0"
style={{
backgroundImage:
"linear-gradient(rgba(49, 98, 99, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(49, 98, 99, 0.06) 1px, transparent 1px)",
backgroundSize: "40px 40px"
}}
/>
{/* shimmer wave */}
<motion.div
className="absolute inset-0"
initial={{ x: "-60%" }}
animate={{ x: "160%" }}
transition={{ duration: 1.3, repeat: Infinity, ease: "linear" }}
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 40%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.06) 60%, transparent 100%)"
}}
/>
{/* fake bars */}
<div className="absolute inset-x-4 bottom-4 flex items-end gap-3">
{Array.from({ length: 10 }).map((_, i) => (
<motion.div
key={i}
className="w-4 rounded-lg bg-secondary/60 border border-border"
initial={{ height: 10 }}
animate={{ height: [10, 36, 18, 42, 22, 50, 30, 44][i % 8] }}
transition={{ duration: 0.8, repeat: Infinity, repeatType: "mirror", delay: i * 0.06 }}
/>
))}
</div>
{/* fake line */}
<motion.div
className="absolute left-6 right-6 top-12 h-[2px] rounded-full bg-primary/40"
initial={{ scaleX: 0, originX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.9, repeat: Infinity, repeatType: "mirror" }}
/>
</div>
);
}
// ---------- chart ----------
function CashflowChart({
data,
loading
}: {
data: CashflowPoint[];
loading: boolean;
}) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const parsed = useMemo(() => {
return (data ?? []).map((d) => {
const income = parseMoney(d.income);
const expense = parseMoney(d.expense);
const net = parseMoney(d.net);
return { ...d, income, expense, net, label: formatMonthLabel(d.month) };
});
}, [data]);
const chart = useMemo(() => {
const W = 640;
const H = 240;
const PAD_L = 36;
const PAD_R = 18;
const PAD_T = 16;
const PAD_B = 34;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
if (!parsed.length) {
return {
W,
H,
PAD_L,
PAD_R,
PAD_T,
PAD_B,
innerW,
innerH,
yZero: PAD_T + innerH / 2,
points: [] as { x: number; y: number; net: number; income: number; expense: number; label: string; month: string }[],
lineD: "",
areaD: "",
bars: [] as { x: number; w: number; yIncome: number; hIncome: number; yExpense: number; hExpense: number }[],
ticks: [] as { y: number; value: number }[]
};
}
const nets = parsed.map((d) => d.net);
const maxAbs = Math.max(1, ...nets.map((v) => Math.abs(v)));
const yMax = maxAbs;
const yMin = -maxAbs;
const xStep = parsed.length === 1 ? 0 : innerW / (parsed.length - 1);
const yScale = (v: number) => {
const t = (v - yMax) / (yMin - yMax);
return PAD_T + t * innerH;
};
const yZero = yScale(0);
const points = parsed.map((d, i) => {
const x = PAD_L + xStep * i;
const y = yScale(d.net);
return { x, y, net: d.net, income: d.income, expense: d.expense, label: d.label, month: d.month };
});
const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
const areaD = [
`M ${points[0].x} ${yZero}`,
...points.map((p) => `L ${p.x} ${p.y}`),
`L ${points[points.length - 1].x} ${yZero}`,
"Z"
].join(" ");
const maxBar = Math.max(1, ...parsed.map((d) => Math.max(Math.abs(d.income), Math.abs(d.expense))));
const barScale = (v: number) => (Math.abs(v) / maxBar) * (innerH * 0.46);
const barBand = innerW / parsed.length;
const barW = Math.max(10, Math.min(22, barBand * 0.42));
const bars = parsed.map((d, i) => {
const cx = PAD_L + barBand * i + barBand / 2;
const x = cx - barW / 2;
const hIncome = barScale(d.income);
const hExpense = barScale(d.expense);
const baseY = PAD_T + innerH;
const yIncome = baseY - hIncome;
const yExpense = baseY - hExpense;
return { x, w: barW, yIncome, hIncome, yExpense, hExpense };
});
const ticks = [-yMax, 0, yMax].map((v) => ({ value: v, y: yScale(v) }));
return { W, H, PAD_L, PAD_R, PAD_T, PAD_B, innerW, innerH, yZero, points, lineD, areaD, bars, ticks };
}, [parsed]);
const active = activeIndex === null ? null : parsed[activeIndex];
function pickNearestIndex(clientX: number) {
if (!wrapRef.current || !chart.points.length) return null;
const rect = wrapRef.current.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * chart.W;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < chart.points.length; i++) {
const d = Math.abs(chart.points[i].x - x);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
if (loading) return <ChartLoadingSkeleton />;
if (!parsed.length) {
return (
<div className="mt-4 rounded-xl border border-border p-4 bg-background/50">
<div className="h-56 grid place-items-center text-sm text-muted-foreground">No cash flow data available</div>
</div>
);
}
return (
<div
ref={wrapRef}
className="mt-4 rounded-xl border border-border p-4 bg-background/50 relative overflow-hidden"
style={{
backgroundImage:
"linear-gradient(rgba(49, 98, 99, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(49, 98, 99, 0.06) 1px, transparent 1px)",
backgroundSize: "40px 40px"
}}
onMouseLeave={() => setActiveIndex(null)}
onMouseMove={(e) => setActiveIndex(pickNearestIndex(e.clientX))}
>
{/* subtle moving glow */}
<motion.div
className="pointer-events-none absolute -inset-24 opacity-40"
animate={{ rotate: 360 }}
transition={{ duration: 18, repeat: Infinity, ease: "linear" }}
style={{
background:
"radial-gradient(circle at 30% 20%, rgba(55,121,185,0.14), transparent 45%), radial-gradient(circle at 70% 60%, rgba(49,98,99,0.12), transparent 50%)"
}}
/>
<svg viewBox={`0 0 ${chart.W} ${chart.H}`} className="relative h-56 w-full">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.03" />
</linearGradient>
<linearGradient id="barsIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.12" />
</linearGradient>
<linearGradient id="barsExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(239 68 68)" stopOpacity="0.55" />
<stop offset="100%" stopColor="rgb(239 68 68)" stopOpacity="0.12" />
</linearGradient>
</defs>
{/* ticks + baseline pulse */}
{chart.ticks.map((t) => (
<g key={t.value}>
<line
x1={chart.PAD_L}
x2={chart.W - chart.PAD_R}
y1={t.y}
y2={t.y}
stroke="currentColor"
strokeOpacity={t.value === 0 ? 0.18 : 0.08}
/>
<text x={6} y={t.y + 4} fill="currentColor" className="text-[10px] text-muted-foreground">
{formatCurrency(t.value)}
</text>
</g>
))}
<motion.line
x1={chart.PAD_L}
x2={chart.W - chart.PAD_R}
y1={chart.yZero}
y2={chart.yZero}
stroke="currentColor"
strokeOpacity="0.18"
strokeDasharray="6 6"
animate={{ strokeOpacity: [0.10, 0.22, 0.10] }}
transition={{ duration: 2.2, repeat: Infinity }}
/>
{/* bars */}
{chart.bars.map((b, i) => (
<g key={i}>
<motion.rect
x={b.x - 6}
y={b.yExpense}
width={b.w}
height={b.hExpense}
rx="7"
fill="url(#barsExpense)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hExpense, y: b.yExpense }}
transition={{ duration: 0.6, delay: i * 0.05, ease: [0.2, 0.8, 0.2, 1] }}
/>
<motion.rect
x={b.x + 6}
y={b.yIncome}
width={b.w}
height={b.hIncome}
rx="7"
fill="url(#barsIncome)"
initial={{ height: 0, y: chart.PAD_T + chart.innerH }}
animate={{ height: b.hIncome, y: b.yIncome }}
transition={{ duration: 0.6, delay: i * 0.05 + 0.06, ease: [0.2, 0.8, 0.2, 1] }}
/>
</g>
))}
{/* area */}
<motion.path
d={chart.areaD}
fill="url(#netFill)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
/>
{/* net line */}
<motion.path
d={chart.lineD}
fill="none"
stroke="var(--primary)"
strokeWidth="3.2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.05, ease: [0.2, 0.8, 0.2, 1] }}
/>
{/* points */}
{chart.points.map((p, i) => {
const isActive = i === activeIndex;
return (
<g key={i}>
{isActive ? <circle cx={p.x} cy={p.y} r="14" fill="var(--primary)" opacity="0.10" /> : null}
<motion.circle
cx={p.x}
cy={p.y}
r={isActive ? 6 : 4}
fill="var(--primary)"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.28, delay: i * 0.03 }}
opacity={isActive ? 1 : 0.75}
/>
</g>
);
})}
{/* hover crosshair */}
{activeIndex !== null && chart.points[activeIndex] ? (
<g>
<line
x1={chart.points[activeIndex].x}
x2={chart.points[activeIndex].x}
y1={chart.PAD_T}
y2={chart.PAD_T + chart.innerH}
stroke="currentColor"
strokeOpacity="0.14"
strokeDasharray="4 4"
/>
</g>
) : null}
{/* x labels */}
{chart.points.map((p, i) => (
<text
key={i}
x={p.x}
y={chart.H - 10}
textAnchor="middle"
fill="currentColor"
className="text-[10px] text-muted-foreground"
opacity={i % 2 === 0 ? 0.95 : 0.55}
>
{(parsed[i]?.month ?? "").slice(2)} {/* "25-08" vibe */}
</text>
))}
</svg>
{/* Tooltip */}
<AnimatePresence>
{active && activeIndex !== null ? (
<motion.div
key="tooltip"
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.16 }}
className="absolute top-3 right-3 rounded-xl border border-border bg-background/92 backdrop-blur px-3 py-2 shadow-sm"
>
<div className="text-xs font-semibold text-foreground">{active.label}</div>
<div className="mt-1 grid gap-1 text-[11px] text-muted-foreground">
<div className="flex items-center justify-between gap-6">
<span>Income</span>
<span className="font-semibold text-foreground">{formatCurrency(active.income)}</span>
</div>
<div className="flex items-center justify-between gap-6">
<span>Expense</span>
<span className="font-semibold text-foreground">{formatCurrency(active.expense)}</span>
</div>
<div className="h-px bg-border my-1" />
<div className="flex items-center justify-between gap-6">
<span>Net</span>
<span className={`font-bold ${active.net < 0 ? "text-red-500" : "text-primary"}`}>
{formatCurrency(active.net)}
</span>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
{/* Legend */}
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary" />
Net (line)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-red-500" />
Expense (bar)
</span>
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-secondary/40 px-2 py-1">
<span className="h-2 w-2 rounded-full bg-primary/70" />
Income (bar)
</span>
</div>
</div>
);
}
// ---------- page ----------
export default function AppHomePage() {
const [summary, setSummary] = useState<Summary | null>(null);
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null);
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
apiFetch<Summary>("/api/transactions/summary"),
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
})
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const netNow = useMemo(() => parseMoney(summary?.net), [summary?.net]);
const incomeNow = useMemo(() => parseMoney(summary?.income), [summary?.income]);
const expenseNow = useMemo(() => parseMoney(summary?.expense), [summary?.expense]);
const netTone: "good" | "bad" | "neutral" = netNow > 0 ? "good" : netNow < 0 ? "bad" : "neutral";
const bestWorst = useMemo(() => {
if (!cashflow.length) return null;
const parsed = cashflow.map((c) => ({ month: c.month, net: parseMoney(c.net) }));
const best = parsed.reduce((a, b) => (b.net > a.net ? b : a), parsed[0]);
const worst = parsed.reduce((a, b) => (b.net < a.net ? b : a), parsed[0]);
return { best, worst };
}, [cashflow]);
return (
<AppShell title="Dashboard" subtitle="Performance overview and live account health.">
{/* top filters row (slightly nicer motion) */}
<motion.section
className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Last 30 days
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
All categories
</span>
<span className="flex items-center gap-2 px-4 py-2 rounded-full bg-secondary/50 border border-border font-medium">
<span className="h-2 w-2 rounded-full bg-primary" />
Combined accounts
</span>
<div className="ml-auto hidden lg:flex items-center gap-2">
<Pill label="Sync" value={loading ? "Updating…" : "Live"} tone="neutral" />
{bestWorst ? (
<Pill label="Worst" value={`${bestWorst.worst.month}: ${formatCurrency(bestWorst.worst.net)}`} tone="bad" />
) : null}
</div>
</motion.section>
{/* KPI cards */}
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
{
title: "Income (30d)",
value: loading ? "—" : formatCurrency(incomeNow),
sub: "Deposits and credits",
badge: "Inflow",
badgeCls: "text-primary bg-primary/10"
},
{
title: "Expenses (30d)",
value: loading ? "—" : formatCurrency(expenseNow),
sub: "Bills and spending",
badge: "Outflow",
badgeCls: "text-red-500 bg-red-500/10"
},
{
title: "Net cash flow",
value: loading ? "—" : formatCurrency(netNow),
sub: summary ? `${summary.count} transactions` : "No data yet",
badge: "30d",
badgeCls: netTone === "bad" ? "text-red-500 bg-red-500/10" : "text-primary bg-primary/10"
},
{
title: "Active accounts",
value: loading ? "—" : `${accountCount ?? 0}`,
sub: "Bank + card connections",
badge: "Linked",
badgeCls: "text-primary bg-primary/10"
}
].map((card, idx) => (
<motion.div
key={card.title}
className="glass-panel p-5 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: idx * 0.05 }}
>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{card.title}</p>
<div className="mt-3 flex items-baseline justify-between">
<p className="text-2xl font-bold text-foreground">{card.value}</p>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${card.badgeCls}`}>{card.badge}</span>
</div>
<p className="mt-2 text-xs text-muted-foreground">{card.sub}</p>
</motion.div>
))}
</section>
{/* main grid */}
<section className="mt-6 grid gap-4 xl:grid-cols-[2fr_1fr]">
<motion.div
className="glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.05 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Monthly Cash-Flow</p>
<p className="text-xs text-muted-foreground">Net flow over last 6 months</p>
</div>
<div className="hidden md:flex items-center gap-2">
<Pill label="Net" value={loading ? "—" : formatCurrency(netNow)} tone={netTone} />
<Pill label="Window" value="6M" tone="neutral" />
</div>
</div>
<CashflowChart data={cashflow} loading={loading} />
<div className="mt-4 grid gap-2 text-xs text-muted-foreground md:grid-cols-3">
{(cashflow ?? []).map((item) => {
const net = parseMoney(item.net);
return (
<div key={item.month} className="flex items-center justify-between rounded-lg border border-border bg-secondary/20 px-3 py-2">
<span className="font-medium">{formatMonthLabel(item.month)}</span>
<span className={`font-bold ${net < 0 ? "text-red-500" : "text-foreground"}`}>
{formatCurrency(net)}
</span>
</div>
);
})}
</div>
</motion.div>
<motion.div
className="glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.1 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-foreground">Top Merchants</p>
<p className="text-xs text-muted-foreground">Highest spend by merchant</p>
</div>
</div>
<div className="mt-6 space-y-4 text-xs">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-secondary/20 p-3">
<div className="h-3 w-2/3 bg-secondary/60 rounded animate-pulse" />
<div className="mt-2 h-3 w-1/2 bg-secondary/60 rounded animate-pulse" />
</div>
))}
</div>
) : merchants.length ? (
merchants.map((merchant, i) => (
<motion.div
key={merchant.merchant}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: i * 0.04 }}
className="flex items-center justify-between border-b border-border pb-2 last:border-0 last:pb-0"
>
<div>
<p className="text-sm font-medium text-foreground">{merchant.merchant}</p>
<p className="text-xs text-muted-foreground">
{merchant.count} transaction{merchant.count === 1 ? "" : "s"}
</p>
</div>
<span className="text-sm font-bold text-foreground">${merchant.total}</span>
</motion.div>
))
) : (
<p className="text-sm text-muted-foreground">No merchant insights yet.</p>
)}
</div>
</motion.div>
</section>
{/* table */}
<motion.section
className="mt-6 glass-panel p-6 rounded-2xl shadow-sm"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.15 }}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-foreground">Recent Transactions</h2>
<span className="text-xs font-medium text-primary cursor-pointer hover:underline">View all records</span>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.2em] text-muted-foreground font-semibold">
<tr className="border-b border-border">
<th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th>
<th className="pb-3">Category</th>
<th className="pb-3 pr-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
</tr>
))
) : recentTxs.length ? (
recentTxs.map((tx) => {
const amt = Number.parseFloat(tx.amount ?? "0");
const fmtAmt = formatCurrency(amt);
const isIncome = amt >= 0;
return (
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
{tx.category ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
) : <span className="text-muted-foreground"></span>}
</td>
<td className={`py-3 pr-2 text-right font-bold ${isIncome ? "text-primary" : "text-foreground"}`}>
{fmtAmt}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-8 text-center text-muted-foreground">
No transactions yet. Connect a bank account to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.section>
</AppShell>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
export default function GoogleCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error) {
setStatus("error");
setMessage(error === "access_denied" ? "You declined Google access." : `Google returned an error: ${error}`);
return;
}
if (!code) {
setStatus("error");
setMessage("No authorization code received from Google.");
return;
}
apiFetch<{ connected: boolean; googleEmail: string }>("/api/google/exchange", {
method: "POST",
body: JSON.stringify({ code }),
}).then((res) => {
if (res.error) {
setStatus("error");
setMessage(res.error.message ?? "Failed to connect Google account.");
} else {
setStatus("success");
setMessage(`Connected as ${res.data?.googleEmail ?? "your Google account"}.`);
setTimeout(() => router.replace("/exports"), 2000);
}
});
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="glass-panel rounded-2xl p-10 text-center max-w-sm w-full shadow-lg">
{status === "loading" && (
<>
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Connecting your Google account...</p>
</>
)}
{status === "success" && (
<>
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Google Connected!</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<p className="text-xs text-muted-foreground mt-2">Redirecting to Exports...</p>
</>
)}
{status === "error" && (
<>
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Connection Failed</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<button
onClick={() => router.replace("/exports")}
className="mt-4 text-xs text-primary hover:underline"
>
Back to Exports
</button>
</>
)}
</div>
</div>
);
}

106
app/blog/[slug]/page.tsx Normal file
View File

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

109
app/blog/page.tsx Normal file
View File

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

159
app/book-demo/page.tsx Normal file
View File

@ -0,0 +1,159 @@
import Link from "next/link";
import { Background } from "../../components/background";
import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
const inputClass =
"block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2.5 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all";
export const metadata = {
title: "Book a Demo",
description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
keywords: siteInfo.keywords
};
export default function BookDemoPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Book a LedgerOne Demo",
description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
url: `${siteInfo.url}/book-demo`
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
];
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex-1 pt-24 pb-16">
<div className="mx-auto max-w-6xl px-6 lg:px-8">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-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">
Book a demo
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
Schedule time with the LedgerOne team.
</h1>
<p className="text-lg text-muted-foreground">
We will walk you through account connections, rule automation, and
audit-ready exports based on your workflow.
</p>
<div className="rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 p-6 shadow-glass backdrop-blur-sm">
<form className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Full name
</label>
<input
className={inputClass}
type="text"
required
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Work email
</label>
<input
className={inputClass}
type="email"
required
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Preferred date
</label>
<input
className={inputClass}
type="date"
required
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Team size
</label>
<select className={inputClass}>
<option>1-5</option>
<option>6-20</option>
<option>21-50</option>
<option>50+</option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<label className="block text-sm font-medium text-foreground">
What should we focus on?
</label>
<textarea
className={`${inputClass} min-h-[120px] resize-y`}
rows={4}
/>
</div>
<div className="md:col-span-2">
<button type="submit" className="btn-primary w-full py-3">
Request demo
</button>
</div>
</form>
<p className="mt-4 text-xs text-muted-foreground">
Prefer email? Reach us at{" "}
<Link className="text-primary hover:underline" href="/contact">
the contact page
</Link>
.
</p>
</div>
</div>
<div className="rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 p-6 shadow-glass backdrop-blur-sm space-y-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
What you&apos;ll see
</p>
<ul className="space-y-3 text-sm text-muted-foreground">
<li className="flex gap-2">
<span className="text-primary mt-0.5"></span>
Connect accounts and review the raw ledger flow.
</li>
<li className="flex gap-2">
<span className="text-primary mt-0.5"></span>
Watch rule automation run and inspect the audit trail.
</li>
<li className="flex gap-2">
<span className="text-primary mt-0.5"></span>
Export a complete ledger package ready for review.
</li>
</ul>
</div>
</section>
</div>
</main>
<div className="relative z-10">
<ContactSection />
<DemoCta />
<FaqSection limit={8} />
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

View File

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

View File

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

View File

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

View File

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

154
app/contact/page.tsx Normal file
View File

@ -0,0 +1,154 @@
import Link from "next/link";
import { ContactSection } from "../../components/contact-section";
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "Contact Us",
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
keywords: siteInfo.keywords
};
export default function ContactPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Contact LedgerOne",
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
url: `${siteInfo.url}/contact`
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-24 pb-16 relative overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-10 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-6">
Support & Sales
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
How can we help?
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Whether you have a question about features, pricing, or need a demo, our team is ready to answer all your questions.
</p>
</div>
<div className="grid md:grid-cols-2 gap-12 max-w-5xl mx-auto">
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<h2 className="text-2xl font-bold text-foreground mb-6">Send us a message</h2>
<form className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-foreground">
Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
className="block w-full rounded-lg border border-border bg-background/50 px-4 py-2 text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
placeholder="Your name"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email
</label>
<div className="mt-1">
<input
type="email"
name="email"
id="email"
className="block w-full rounded-lg border border-border bg-background/50 px-4 py-2 text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-foreground">
Message
</label>
<div className="mt-1">
<textarea
id="message"
name="message"
rows={4}
className="block w-full rounded-lg border border-border bg-background/50 px-4 py-2 text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm"
placeholder="How can we help you?"
/>
</div>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary px-4 py-3 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 Message
</button>
</form>
</div>
<div className="space-y-8">
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<h3 className="text-lg font-bold text-foreground mb-2">Contact Information</h3>
<div className="space-y-4 text-muted-foreground">
<p className="flex items-center gap-3">
<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="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>
support@ledgerone.com
</p>
<p className="flex items-center gap-3">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
123 Fintech Blvd, San Francisco, CA 94105
</p>
</div>
</div>
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<h3 className="text-lg font-bold text-foreground mb-4">Join our community</h3>
<div className="flex gap-4">
{/* Social Icons */}
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors cursor-pointer">
<span className="sr-only">Twitter</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" /></svg>
</div>
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors cursor-pointer">
<span className="sr-only">LinkedIn</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path fillRule="evenodd" d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" clipRule="evenodd" /></svg>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

282
app/demo/page.tsx Normal file
View File

@ -0,0 +1,282 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { SiteHeader } from "../../components/site-header";
import { SiteFooter } from "../../components/site-footer";
const balances = [18420, 19280, 20540, 19980, 21450, 22890, 22120];
const maxBalance = Math.max(...balances);
const expenses = [
{ label: "Rent & mortgage", value: 2800 },
{ label: "Payroll", value: 9200 },
{ label: "Software & tools", value: 1450 },
{ label: "Vendors", value: 2280 },
];
const aiMessages = [
"Youre on track to finish the month with a $6,920 surplus if spending stays at the current pace.",
"Dining is trending 14% above your usual pattern. Consider capping at $620 to stay on target.",
"You can safely move $1,500 into savings without dropping below your $10k buffer.",
];
export default function DemoPage() {
const [aiIndex, setAiIndex] = useState(0);
const [aiVisible, setAiVisible] = useState(false);
useEffect(() => {
let timeout: NodeJS.Timeout;
let interval: NodeJS.Timeout;
const startLoop = () => {
setAiVisible(false);
timeout = setTimeout(() => {
setAiVisible(true);
}, 1000);
};
startLoop();
interval = setInterval(() => {
setAiIndex((prev) => (prev + 1) % aiMessages.length);
startLoop();
}, 5000);
return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-20 pb-12">
<div className="mx-auto max-w-6xl px-6 lg:px-8 space-y-8">
<header className="space-y-3">
<p className="text-xs font-semibold tracking-[0.25em] text-emerald-400 uppercase">
Demo · LedgerOne
</p>
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-slate-50">
AI-powered cash control dashboard
</h1>
<p className="text-sm sm:text-base text-slate-400 max-w-2xl">
This is a looping, demo-only view designed for screen recordings. All data is fake but
behaves like a live, AI-assisted finance cockpit.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: animated cashflow graph */}
<section className="lg:col-span-2 rounded-3xl border border-slate-800 bg-slate-900/60 px-5 pt-4 pb-6 shadow-[0_30px_120px_rgba(15,23,42,0.9)] backdrop-blur-xl">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="space-y-1">
<p className="text-xs font-medium text-slate-400">Projected balance · next 30 days</p>
<p className="text-sm font-semibold text-slate-50">
$22,890 <span className="text-emerald-400 text-xs font-normal"> +$3,410</span>
</p>
</div>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-emerald-400" />
<span>Balance</span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-sky-400" />
<span>Income</span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-rose-400" />
<span>Outflows</span>
</div>
</div>
</div>
{/* Animated bar/line combo chart */}
<div className="relative h-60 rounded-2xl bg-gradient-to-b from-slate-900/60 to-slate-950/90 overflow-hidden">
<svg viewBox="0 0 100 40" className="absolute inset-0 opacity-40">
<defs>
<linearGradient id="balanceLine" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22c55e" />
<stop offset="50%" stopColor="#38bdf8" />
<stop offset="100%" stopColor="#a855f7" />
</linearGradient>
</defs>
<motion.path
d="
M 0 30
C 15 28, 25 26, 35 24
S 55 20, 65 18
S 85 16, 100 14
"
fill="none"
stroke="url(#balanceLine)"
strokeWidth="1.5"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
/>
</svg>
<div className="absolute inset-x-6 bottom-4 flex items-end justify-between gap-4">
{balances.map((v, i) => {
const height = (v / maxBalance) * 100;
return (
<motion.div
key={i}
className="flex-1 flex flex-col items-center gap-1"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.08 }}
>
<motion.div
className="w-full rounded-t-full bg-gradient-to-t from-slate-800 via-sky-500 to-emerald-400 shadow-[0_0_25px_rgba(56,189,248,0.5)]"
style={{ height: `${height}%` }}
animate={{ scaleY: [0.7, 1, 0.85, 1] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut", delay: i * 0.05 }}
/>
<span className="text-[10px] text-slate-500">D{i + 1}</span>
</motion.div>
);
})}
</div>
<div className="absolute left-1/2 bottom-3 -translate-x-1/2 rounded-full bg-amber-500/15 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-400/50 flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-amber-300 animate-pulse" />
<span>Balance dip in 6 days · adjust discretionary by ~12%</span>
</div>
</div>
</section>
{/* Right: budget ring + expense breakdown */}
<section className="space-y-4">
<div className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4 shadow-[0_20px_80px_rgba(15,23,42,0.9)] backdrop-blur-xl">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-400">Monthly budget</p>
<p className="text-sm font-semibold text-slate-50">
$18,400{" "}
<span className="ml-1 text-[11px] font-normal text-emerald-400">Safe to spend: $4,120</span>
</p>
</div>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-300">
Healthy
</span>
</div>
<div className="flex items-center gap-4">
{/* Animated progress ring */}
<div className="relative h-20 w-20">
<svg viewBox="0 0 36 36" className="h-20 w-20 -rotate-90">
<path
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(30,64,175,0.35)"
strokeWidth="3"
/>
<motion.path
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#demoRingGradient)"
strokeWidth="3"
strokeLinecap="round"
animate={{ strokeDasharray: ["55, 100", "78, 100", "65, 100"] }}
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
/>
<defs>
<linearGradient id="demoRingGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22c55e" />
<stop offset="50%" stopColor="#38bdf8" />
<stop offset="100%" stopColor="#a855f7" />
</linearGradient>
</defs>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-0.5">
<motion.span
className="text-sm font-semibold text-slate-50"
animate={{ opacity: [0.7, 1, 0.7] }}
transition={{ duration: 2.4, repeat: Infinity }}
>
74%
</motion.span>
<span className="text-[10px] text-slate-400">used</span>
</div>
</div>
<div className="flex-1 space-y-2">
{expenses.map((e) => {
const pct = e.value / 18400;
return (
<div key={e.label} className="space-y-0.5">
<div className="flex items-center justify-between text-[11px] text-slate-300">
<span>{e.label}</span>
<span className="text-slate-400">
${e.value.toLocaleString()}{" "}
<span className="text-slate-500">
· {(pct * 100).toFixed(0)}%
</span>
</span>
</div>
<div className="h-1.5 rounded-full bg-slate-800 overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 via-sky-400 to-violet-500"
initial={{ width: 0 }}
animate={{ width: `${Math.min(pct * 100, 100)}%` }}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* AI chat card */}
<div className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4 shadow-[0_20px_80px_rgba(15,23,42,0.9)] backdrop-blur-xl space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-500/15 text-[11px] text-emerald-300 shadow-[0_0_22px_rgba(34,197,94,0.7)]">
AI
</div>
<div className="text-xs text-slate-300">
<p className="font-medium">LedgerOne Copilot</p>
<p className="text-[11px] text-slate-500">Monitors cash flow in real-time</p>
</div>
</div>
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-[10px] text-slate-300">
Demo mode
</span>
</div>
<div className="space-y-2 text-[11px]">
<div className="w-fit max-w-[90%] rounded-2xl bg-slate-800/90 px-3 py-2 text-slate-100">
How much can we safely move into savings this month?
</div>
<motion.div
key={aiIndex}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: aiVisible ? 1 : 0, y: aiVisible ? 0 : 4 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="ml-auto w-fit max-w-[90%] rounded-2xl bg-emerald-500/10 px-3 py-2 text-emerald-50 ring-1 ring-emerald-400/30"
>
{aiMessages[aiIndex]}
</motion.div>
</div>
</div>
</section>
</div>
</div>
</main>
<SiteFooter />
</div>
);
}

15
app/exports/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Exports | LedgerOne</title>
<meta
name="description"
content="Generate audit-ready exports with raw and derived ledger data."
/>
<meta
name="keywords"
content="ledgerone exports, csv export, audit-ready export"
/>
</>
);
}

269
app/exports/page.tsx Normal file
View File

@ -0,0 +1,269 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ExportData = { status: string; csv?: string; rowCount?: number };
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
export default function ExportsPage() {
const [csvStatus, setCsvStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom");
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false,
});
useEffect(() => {
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
});
}, []);
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
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 onConnectGoogle = async () => {
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
if (res.error) {
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
return;
}
if (res.data?.authUrl) {
window.location.href = res.data.authUrl;
}
};
const onDisconnectGoogle = async () => {
setDisconnecting(true);
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
setDisconnecting(false);
if (!res.error) {
setGoogleStatus({ connected: false });
setSheetsStatus("Google account disconnected.");
setSheetsUrl(null);
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
if (url) {
setSheetsUrl(url);
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Filters */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={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="mt-6" />
{/* Export cards */}
<div className="grid gap-4 md:grid-cols-2">
{/* CSV */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download CSV</p>
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
</div>
</div>
<button
onClick={onExportCsv}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Export CSV
</button>
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div>
{/* Google Sheets */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
{googleStatus?.connected ? (
<div className="flex items-center gap-1 mt-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
)}
</div>
</div>
{googleStatus?.connected ? (
<>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
<button
onClick={onDisconnectGoogle}
disabled={disconnecting}
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
>
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
</button>
</>
) : (
<button
onClick={onConnectGoogle}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
>
Connect Google Account
</button>
)}
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div>
</div>
</AppShell>
);
}

73
app/faq/page.tsx Normal file
View File

@ -0,0 +1,73 @@
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "FAQ",
description: "Common questions about LedgerOne pricing, accounts, and security.",
keywords: siteInfo.keywords
};
export default function FaqPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne FAQ",
description: "Common questions about LedgerOne pricing, accounts, and security.",
url: `${siteInfo.url}/faq`
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
];
return (
<div className="min-h-screen bg-background font-sans text-foreground flex flex-col">
<SiteHeader />
<main className="flex-1 pt-24 pb-16 relative overflow-hidden">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-40" />
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<div className="glass-panel rounded-3xl p-6 sm:p-10 shadow-sm">
<div className="mb-8">
<p className="text-xs uppercase tracking-[0.2em] text-primary font-bold mb-4">FAQ</p>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl">
Common questions, clear answers.
</h1>
<p className="mt-4 text-lg text-muted-foreground">
Find pricing, account, export, and security answers for LedgerOne.
</p>
</div>
<div className="space-y-8">
{defaultFaqs.map((faq, index) => (
<div key={index} className="pb-6 last:pb-0">
<h3 className="text-lg font-bold text-foreground uppercase tracking-wide mb-3">
{index + 1}. {faq.question}
</h3>
<p className="text-muted-foreground leading-relaxed">
{faq.answer}
</p>
</div>
))}
</div>
</div>
</div>
</main>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Background } from "../../components/background";
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="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<div className="relative z-10 flex flex-1 flex-col justify-center pt-24 pb-16 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div
className="h-12 w-12 rounded-full flex items-center justify-center text-white font-bold text-xl shadow-[0_12px_30px_var(--gradient-glow)]"
style={{ background: "linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end))" }}
>
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="rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 py-8 px-4 shadow-glass backdrop-blur-sm 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="btn-primary inline-flex justify-center py-2.5 px-4">
Back to sign in
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<p className="mt-1 text-xs text-muted-foreground">
We&#39;ll send a reset link to this address if it has an account.
</p>
<div className="mt-2">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"
/>
</div>
</div>
<button type="submit" className="btn-primary w-full py-3">
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 text-red-600 dark:text-red-400">{status}</p>
</div>
)}
</form>
)}
</div>
</div>
</div>
<div className="relative z-10">
<SiteFooter />
</div>
</div>
);
}

258
app/globals.css Normal file
View File

@ -0,0 +1,258 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-inter: 'Inter', sans-serif;
--font-space: 'Space Grotesk', sans-serif;
/* Light Mode (hero / site-wide) */
--background: #F0EEE9;
--foreground: #121212;
--primary: #316263;
--primary-foreground: #FFFFFF;
--secondary: #E0DED9;
--secondary-foreground: #121212;
--muted: #D6D4D0;
--muted-foreground: #666666;
--accent: #B6FF3B;
--accent-foreground: #121212;
--border: #D6D4D0;
/* Hero gradient (CTA / headlines) - use site-wide */
--gradient-start: #6366f1;
--gradient-mid: #a855f7;
--gradient-end: #34d399;
--gradient-glow: rgba(88, 80, 236, 0.45);
--gradient-glow-hover: rgba(56, 189, 248, 0.5);
--accent-emerald: #10b981;
--accent-emerald-muted: rgba(34, 197, 94, 0.35);
}
.dark {
/* Dark Mode (Charcoal/Electric Cyan) */
--background: #121212;
--foreground: #F0EEE9;
--primary: #00FFFF;
--primary-foreground: #121212;
--secondary: #1E1E1E;
--secondary-foreground: #F0EEE9;
--muted: #2A2A2A;
--muted-foreground: #A0A0A0;
--accent: #316263;
--accent-foreground: #FFFFFF;
--border: #2A2A2A;
}
html,
body {
padding: 0;
margin: 0;
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-inter);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Glassmorphism Utilities */
.glass-panel {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.dark .glass-panel {
background: rgba(18, 18, 18, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Landing hero: animated gradient glow (1020s loop, subtle) */
@keyframes hero-glow-shift {
0%, 100% {
opacity: 0.35;
transform: scale(1) translate(0, 0);
}
33% {
opacity: 0.5;
transform: scale(1.08) translate(3%, -2%);
}
66% {
opacity: 0.4;
transform: scale(1.04) translate(-2%, 2%);
}
}
.animate-hero-glow {
animation: hero-glow-shift 15s ease-in-out infinite;
}
/* Soft gradient base for hero/feature pages: blue → lavender → green (ethereal) */
.page-soft-bg {
background: linear-gradient(
135deg,
#f0f9ff 0%,
#e0f2fe 15%,
#f5f3ff 40%,
#faf5ff 55%,
#f0fdf4 75%,
#ecfdf5 100%
);
background-attachment: fixed;
}
/* Subtle floating animation for gradient blobs */
@keyframes blob-float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(2%, -3%) scale(1.02);
}
66% {
transform: translate(-2%, 2%) scale(0.98);
}
}
.animate-blob-float {
animation: blob-float 18s ease-in-out infinite;
}
/* Mesh Gradient */
.mesh-gradient {
background-color: #F0EEE9;
background-image:
radial-gradient(at 0% 0%, rgba(49, 98, 99, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(182, 255, 59, 0.1) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(49, 98, 99, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(255, 255, 255, 0.5) 0px, transparent 50%);
}
.dark .mesh-gradient {
background-color: #121212;
background-image:
radial-gradient(at 0% 0%, rgba(0, 255, 255, 0.1) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(49, 98, 99, 0.2) 0px, transparent 50%);
}
/* Utility for text balance */
.text-balance {
text-wrap: balance;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* ============================================
GLOBAL THEME (hero section = source of truth)
Use these classes site-wide for consistency.
============================================ */
@layer components {
/* Primary CTA - gradient, glow, rounded-full */
.btn-primary {
@apply inline-flex items-center justify-center rounded-full px-7 py-3 text-sm font-semibold text-white outline-none ring-2 ring-transparent ring-offset-2 ring-offset-background transition-all focus-visible:ring-emerald-400/80;
background: linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end));
box-shadow: 0 18px 60px var(--gradient-glow);
}
.btn-primary:hover {
box-shadow: 0 22px 70px var(--gradient-glow-hover);
transform: translateY(-2px);
}
.btn-primary:active {
transform: translateY(0);
}
/* Secondary - soft border, backdrop blur */
.btn-secondary {
@apply inline-flex items-center justify-center rounded-full border border-border px-7 py-3 text-sm font-semibold text-foreground backdrop-blur-md outline-none ring-2 ring-transparent ring-offset-2 ring-offset-background transition-colors focus-visible:ring-emerald-400/80;
background-color: color-mix(in srgb, var(--background) 60%, transparent);
}
.btn-secondary:hover {
background-color: color-mix(in srgb, var(--secondary) 40%, transparent);
}
/* Small primary (e.g. navbar Get Started) */
.btn-primary-sm {
@apply inline-flex items-center justify-center rounded-full px-5 py-2.5 text-sm font-semibold text-white outline-none ring-2 ring-transparent ring-offset-2 ring-offset-background transition-all focus-visible:ring-emerald-400/80 active:scale-[0.98];
background: linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end));
box-shadow: 0 18px 60px var(--gradient-glow);
}
.btn-primary-sm:hover {
box-shadow: 0 22px 70px var(--gradient-glow-hover);
transform: translateY(-1px);
}
/* Pill / badge (hero "AI-native · Live predictions" style) */
.badge-pill {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-medium text-muted-foreground backdrop-blur-md;
border-color: color-mix(in srgb, var(--border) 60%, transparent);
background-color: color-mix(in srgb, var(--background) 60%, transparent);
box-shadow: 0 0 25px var(--accent-emerald-muted);
}
.badge-pill-dot {
@apply h-1.5 w-1.5 rounded-full bg-emerald-400;
}
/* Hero headline - gradient second line */
.heading-hero {
@apply text-balance text-4xl font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl;
}
.heading-hero-accent {
@apply block text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 via-sky-400 to-emerald-400;
}
/* Section headings site-wide */
.heading-section {
@apply text-3xl font-semibold tracking-tight text-foreground sm:text-4xl;
}
.heading-section-sub {
@apply mt-4 text-base text-muted-foreground sm:text-lg;
}
/* Body lead (hero subtext) */
.body-lead {
@apply text-pretty text-base text-muted-foreground sm:text-lg;
}
/* Feature bullets (Instant Plaid, Bank-grade, 14-day) */
.feature-bullets {
@apply flex flex-wrap items-center gap-6 text-xs text-muted-foreground;
}
.feature-bullet {
@apply flex items-center gap-2;
}
.feature-bullet-dot {
@apply h-1.5 w-1.5 rounded-full bg-emerald-400;
}
/* Card shell (hero-style glass) */
.card-glass {
@apply rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 shadow-glass;
}
/* Nav link - consistent with hero area */
.nav-link {
@apply text-sm font-medium text-muted-foreground transition-colors hover:text-foreground;
}
}

27
app/layout.tsx Normal file
View File

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import React from "react";
import { Inter, Space_Grotesk } from "next/font/google";
import "./globals.css";
import { siteInfo } from "../data/site";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const space = Space_Grotesk({ subsets: ["latin"], variable: "--font-space" });
export const metadata: Metadata = {
title: {
default: "LedgerOne",
template: "%s | LedgerOne"
},
description: siteInfo.description,
keywords: siteInfo.keywords
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${inter.variable} ${space.variable} font-sans min-h-screen bg-background text-foreground`}>
{children}
</body>
</html>
);
}

15
app/login/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Login | LedgerOne</title>
<meta
name="description"
content="Sign in to LedgerOne to access your audit-ready ledger and exports."
/>
<meta
name="keywords"
content="ledgerone login, audit-ready ledger, finance dashboard, transaction ledger"
/>
</>
);
}

251
app/login/page.tsx Normal file
View File

@ -0,0 +1,251 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { Background } from "../../components/background";
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;
};
const socialButtonClass =
"flex w-full items-center justify-center gap-3 rounded-xl border border-border bg-white py-3 px-4 text-sm font-medium text-foreground shadow-sm transition-all hover:shadow-md hover:border-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/20";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") ?? "/app";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [staySignedIn, setStaySignedIn] = useState(true);
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}>
{/* Social sign in */}
<div className="flex flex-col gap-3">
<button
type="button"
className={socialButtonClass}
aria-label="Continue with Apple"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
Continue with Apple
</button>
<button
type="button"
className={socialButtonClass}
aria-label="Continue with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Continue with Google
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-transparent px-3 text-muted-foreground">or</span>
</div>
</div>
<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 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"
/>
</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 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"
/>
</div>
</div>
<div className="flex items-center gap-3">
<input
id="staySignedIn"
name="staySignedIn"
type="checkbox"
checked={staySignedIn}
onChange={(e) => setStaySignedIn(e.target.checked)}
className="h-4 w-4 rounded border-border bg-white text-primary focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
/>
<label htmlFor="staySignedIn" className="text-sm text-muted-foreground">
Stay signed in
</label>
</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 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all tracking-widest text-center"
/>
</div>
</div>
)}
<div>
<button type="submit" className="btn-primary w-full py-3">
{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-primary/10 border border-primary/20"}`}>
<p className={`text-sm font-medium text-center ${isError ? "text-red-600 dark:text-red-400" : "text-foreground"}`}>{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="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<div className="relative z-10 flex flex-1 flex-col justify-center pt-24 pb-16 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div
className="h-12 w-12 rounded-full flex items-center justify-center text-white font-bold text-xl shadow-[0_12px_30px_var(--gradient-glow)]"
style={{ background: "linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end))" }}
>
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Sign In
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<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="rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 py-8 px-4 shadow-glass backdrop-blur-sm sm:px-10">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

94
app/page.tsx Normal file
View File

@ -0,0 +1,94 @@
import { Background } from "../components/background";
import { SiteFooter } from "../components/site-footer";
import { SiteHeader } from "../components/site-header";
import { PageSchema } from "../components/page-schema";
import { siteInfo } from "../data/site";
import { LandingHero } from "@/components/landing-hero";
import { LandingFeatures } from "../components/landing-features";
import { LandingFuture } from "../components/landing-future";
import { LandingCta } from "../components/landing-cta";
export const metadata = {
title: "LedgerOne - The Financial Control Platform for Modern Business",
description: "Connect all your accounts, automate your bookkeeping, and get audit-ready financials in real-time.",
keywords: siteInfo.keywords
};
export default function LandingPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebSite",
name: "LedgerOne",
url: siteInfo.url,
potentialAction: {
"@type": "SearchAction",
target: `${siteInfo.url}/search?q={search_term_string}`,
"query-input": "required name=search_term_string"
}
},
{
"@context": "https://schema.org",
"@type": "Organization",
name: "LedgerOne",
url: siteInfo.url,
logo: `${siteInfo.url}/logo.png`,
sameAs: [
"https://twitter.com/ledgerone",
"https://linkedin.com/company/ledgerone"
]
}
];
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex-1">
<LandingHero />
{/* TRUST SECTION */}
<section className="bg-secondary/20 py-8">
<div className="max-w-7xl mx-auto px-6 lg:px-8 text-center">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Trusted by teams who treat finance as a product
</p>
<div className="mb-6 flex flex-wrap items-center justify-center gap-8 text-sm text-muted-foreground">
<div className="flex items-baseline gap-2">
<span className="text-2xl font-semibold text-foreground">50,000+</span>
<span>users</span>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-baseline gap-2">
<span className="text-2xl font-semibold text-foreground">$120M+</span>
<span>tracked monthly</span>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-baseline gap-2">
<span className="text-2xl font-semibold text-foreground">4.9</span>
<span>avg satisfaction</span>
</div>
</div>
<div className="grid grid-cols-2 gap-6 md:grid-cols-5 md:gap-10 opacity-70 grayscale transition hover:opacity-100 hover:grayscale-0">
<div className="flex items-center justify-center text-sm font-semibold">Aurora Bank</div>
<div className="flex items-center justify-center text-sm font-semibold">Northwind</div>
<div className="flex items-center justify-center text-sm font-semibold">Summit Labs</div>
<div className="flex items-center justify-center text-sm font-semibold">Horizon Co.</div>
<div className="flex items-center justify-center text-sm font-semibold">Canvas Ventures</div>
</div>
</div>
</section>
<LandingFeatures />
<LandingFuture />
<LandingCta />
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

176
app/pricing/page.tsx Normal file
View File

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

136
app/privacy-policy/page.tsx Normal file
View File

@ -0,0 +1,136 @@
import Link from "next/link";
import { Background } from "../../components/background";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "Privacy Policy",
description: "How LedgerOne collects, uses, and protects your data.",
keywords: siteInfo.keywords
};
const sections = [
{ id: "collect", title: "1. Information We Collect" },
{ id: "use", title: "2. How We Use Information" },
{ id: "security", title: "3. Data Security" },
{ id: "ccpa", title: "4. California Privacy Rights (CCPA)" },
{ id: "pipeda", title: "5. Canadian Privacy Rights (PIPEDA)" },
{ id: "contact", title: "6. Contact Us" }
];
export default function PrivacyPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Privacy Policy",
description: "How LedgerOne collects, uses, and protects your data.",
url: `${siteInfo.url}/privacy-policy`
}
];
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex-1 pt-24 pb-16">
<div className="max-w-3xl mx-auto px-6 lg:px-8">
<div className="rounded-3xl border border-border/60 bg-gradient-to-b from-background/95 via-background to-background/90 p-6 shadow-glass backdrop-blur-sm sm:p-10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8 pb-6 border-b border-border/60">
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Privacy Policy
</h1>
<p className="text-sm text-muted-foreground">
Last updated: October 24, 2023
</p>
</div>
<nav className="mb-10 rounded-xl bg-secondary/20 border border-border/50 p-4" aria-label="On this page">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">On this page</p>
<ul className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{sections.map(({ id, title }) => (
<li key={id}>
<a href={`#${id}`} className="text-primary hover:underline">
{title.replace(/^\d+\.\s/, "")}
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-10 text-muted-foreground leading-relaxed">
<section id="collect">
<h2 className="text-foreground font-bold text-xl mb-3">1. Information We Collect</h2>
<p>
We collect information you provide directly to us, such as when you create an account, connect a bank account, or request customer support.
</p>
</section>
<section id="use">
<h2 className="text-foreground font-bold text-xl mb-3">2. How We Use Information</h2>
<p>
We use the information we collect to provide, maintain, and improve our services, such as to sync your transactions and generate reports.
</p>
</section>
<section id="security">
<h2 className="text-foreground font-bold text-xl mb-3">3. Data Security</h2>
<p>
We use industry-standard encryption and security measures to protect your data. We do not store your bank login credentials; they are handled by our secure partners (Plaid).
</p>
</section>
<section id="ccpa">
<h2 className="text-foreground font-bold text-xl mb-3">4. California Privacy Rights (CCPA)</h2>
<p>
If you are a California resident, you have specific rights regarding your personal information, including the right to request access to and deletion of your data. We do not sell your personal information. To exercise your rights, please{" "}
<Link href="/contact" className="text-primary hover:underline font-medium">
contact us
</Link>.
</p>
</section>
<section id="pipeda">
<h2 className="text-foreground font-bold text-xl mb-3">5. Canadian Privacy Rights (PIPEDA)</h2>
<p>
If you are a Canadian resident, you have the right to access your personal information and request corrections. We comply with the Personal Information Protection and Electronic Documents Act (PIPEDA) regarding the collection, use, and disclosure of personal information.
</p>
</section>
<section id="contact">
<h2 className="text-foreground font-bold text-xl mb-3">6. Contact Us</h2>
<p>
If you have any questions about this Privacy Policy, please contact us at{" "}
<a href="mailto:privacy@ledgerone.com" className="text-primary hover:underline font-medium">
privacy@ledgerone.com
</a>{" "}
or visit our{" "}
<Link href="/contact" className="text-primary hover:underline font-medium">
contact page
</Link>.
</p>
</section>
</div>
<div className="mt-12 pt-6 border-t border-border/60 flex flex-wrap gap-6 text-sm">
<Link href="/terms" className="text-primary hover:underline font-medium">
Terms of Service
</Link>
<Link href="/contact" className="text-primary hover:underline font-medium">
Contact us
</Link>
</div>
</div>
</div>
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

15
app/profile/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Profile | LedgerOne</title>
<meta
name="description"
content="Complete your LedgerOne profile after signing in."
/>
<meta
name="keywords"
content="ledgerone profile, finance workspace, audit-ready ledger setup"
/>
</>
);
}

197
app/profile/page.tsx Normal file
View File

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

15
app/register/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Create Account | LedgerOne</title>
<meta
name="description"
content="Create a LedgerOne account and start with two free connected accounts."
/>
<meta
name="keywords"
content="ledgerone signup, create account, finance ledger, audit-ready ledger"
/>
</>
);
}

305
app/register/page.tsx Normal file
View File

@ -0,0 +1,305 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react";
import { motion } from "framer-motion";
import { Background } from "../../components/background";
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;
};
const inputClass =
"block w-full appearance-none rounded-xl border border-border bg-white/90 px-4 py-3 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all";
const socialButtonClass =
"flex w-full items-center justify-center gap-3 rounded-xl border border-border bg-white py-3 px-4 text-sm font-medium text-foreground shadow-sm transition-all hover:shadow-md hover:border-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/20";
export default function RegisterPage() {
const router = useRouter();
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [agreeTerms, setAgreeTerms] = useState(false);
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();
if (password !== confirmPassword) {
setStatus("Passwords do not match.");
setIsError(true);
return;
}
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, fullName: fullName || undefined }),
});
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! Redirecting...");
router.push("/app");
} catch {
setStatus("Registration failed. Please try again.");
setIsError(true);
}
};
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex flex-1 flex-col justify-center px-6 py-12 lg:py-16">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md mx-auto text-center"
>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Create your LedgerOne account
</h1>
<p className="mt-2 text-muted-foreground">
Get AI-powered financial clarity in minutes.
</p>
<div className="mt-8 text-left">
{/* Social sign up */}
<div className="mt-8 flex flex-col gap-3">
<button
type="button"
className={socialButtonClass}
aria-label="Continue with Apple"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
Continue with Apple
</button>
<button
type="button"
className={socialButtonClass}
aria-label="Continue with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</button>
</div>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-transparent px-3 text-muted-foreground">or</span>
</div>
</div>
<form className="space-y-5" onSubmit={onSubmit}>
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-foreground mb-1.5">
Full name
</label>
<input
id="fullName"
name="fullName"
type="text"
autoComplete="name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className={inputClass}
placeholder="Jane Smith"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1.5">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
placeholder="you@company.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Password
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`${inputClass} pr-11`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-muted-foreground hover:text-foreground rounded-lg hover:bg-secondary/50 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878a4.5 4.5 0 106.262 6.262M4 4l16 16" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1.5">
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={inputClass}
placeholder="••••••••"
/>
</div>
<div className="flex items-start gap-3">
<input
id="terms"
name="terms"
type="checkbox"
required
checked={agreeTerms}
onChange={(e) => setAgreeTerms(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-border bg-white text-primary focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground leading-tight">
I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline font-medium">
Terms of Use
</Link>{" "}
and{" "}
<Link href="/privacy-policy" className="text-primary hover:underline font-medium">
Privacy Policy
</Link>
</label>
</div>
<button
type="submit"
className="btn-primary w-full py-3.5 text-base font-semibold transition-all hover:-translate-y-0.5 active:translate-y-0"
>
Create account
</button>
</form>
{status && (
<div
className={`mt-5 rounded-xl p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-primary/10 border border-primary/20"}`}
>
<p className={`text-sm font-medium text-center ${isError ? "text-red-600 dark:text-red-400" : "text-foreground"}`}>
{status}
</p>
</div>
)}
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="font-semibold text-primary hover:underline">
Sign in
</Link>
</p>
</motion.div>
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

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

@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { Background } from "../../components/background";
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 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"
/>
</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 text-foreground placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:text-sm transition-all"
/>
</div>
</div>
<button type="submit" className="btn-primary w-full py-3">
Reset Password
</button>
{status && (
<div className={`rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-primary/10 border border-primary/20"}`}>
<p className={`text-sm text-center ${isError ? "text-red-600 dark:text-red-400" : "text-foreground"}`}>{status}</p>
</div>
)}
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<div className="relative z-10 flex flex-1 flex-col justify-center pt-24 pb-16 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div
className="h-12 w-12 rounded-full flex items-center justify-center text-white font-bold text-xl shadow-[0_12px_30px_var(--gradient-glow)]"
style={{ background: "linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end))" }}
>
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="rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 py-8 px-4 shadow-glass backdrop-blur-sm sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
<div className="relative z-10">
<SiteFooter />
</div>
</div>
);
}

15
app/rules/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Rules Engine | LedgerOne</title>
<meta
name="description"
content="Manage transparent automation rules and audit-ready workflows in LedgerOne."
/>
<meta
name="keywords"
content="ledgerone rules, automation rules, audit-ready workflows"
/>
</>
);
}

299
app/rules/page.tsx Normal file
View File

@ -0,0 +1,299 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type RuleRow = {
id: string;
name: string;
priority: number;
isActive: boolean;
conditions: Record<string, unknown>;
actions: Record<string, unknown>;
};
type Suggestion = {
id: string;
name: string;
conditions: Record<string, unknown>;
actions: Record<string, unknown>;
confidence: number;
};
export default function RulesPage() {
const [rules, setRules] = useState<RuleRow[]>([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [status, setStatus] = useState("Loading rules...");
const [showNew, setShowNew] = useState(false);
const [form, setForm] = useState({
name: "",
priority: "",
textContains: "",
amountGreater: "",
amountLess: "",
setCategory: "",
setHidden: false,
isActive: true
});
const load = async () => {
const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
try {
const [rulesRes, suggestionsRes] = await Promise.all([
fetch(`/api/rules${query}`),
fetch(`/api/rules/suggestions${query}`)
]);
const rulesPayload = (await rulesRes.json()) as ApiResponse<RuleRow[]>;
const suggestionsPayload = (await suggestionsRes.json()) as ApiResponse<Suggestion[]>;
if (!rulesRes.ok || rulesPayload.error) {
setStatus(rulesPayload.error?.message ?? "Unable to load rules.");
return;
}
setRules(rulesPayload.data);
setSuggestions(suggestionsPayload.data ?? []);
setStatus(rulesPayload.data.length ? "" : "No rules yet.");
} catch {
setStatus("Unable to load rules.");
}
};
useEffect(() => {
load();
}, []);
const onCreate = async () => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id.");
return;
}
const payload = {
userId,
name: form.name || "Untitled rule",
priority: form.priority ? Number(form.priority) : undefined,
isActive: form.isActive,
conditions: {
textContains: form.textContains || undefined,
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined
},
actions: {
setCategory: form.setCategory || undefined,
setHidden: form.setHidden
}
};
try {
const res = await fetch("/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = (await res.json()) as ApiResponse<RuleRow>;
if (!res.ok || data.error) {
setStatus(data.error?.message ?? "Unable to create rule.");
return;
}
setShowNew(false);
setForm({
name: "",
priority: "",
textContains: "",
amountGreater: "",
amountLess: "",
setCategory: "",
setHidden: false,
isActive: true
});
setRules((prev) => [data.data, ...prev]);
setStatus("");
} catch {
setStatus("Unable to create rule.");
}
};
return (
<AppShell title="Rules" subtitle="Priority-ordered rules with full transparency.">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Active rules</span>
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Priority ordered</span>
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Auto applied</span>
</div>
<button
type="button"
onClick={() => setShowNew((prev) => !prev)}
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
{showNew ? "Close" : "New rule"}
</button>
</div>
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="glass-panel p-6 rounded-2xl shadow-sm">
{showNew ? (
<div className="mb-6 rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-bold text-foreground">Create a rule</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<input
type="text"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Rule name"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="number"
value={form.priority}
onChange={(event) =>
setForm((prev) => ({ ...prev, priority: event.target.value }))
}
placeholder="Priority (optional)"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.textContains}
onChange={(event) =>
setForm((prev) => ({ ...prev, textContains: event.target.value }))
}
placeholder="Description contains"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.setCategory}
onChange={(event) =>
setForm((prev) => ({ ...prev, setCategory: event.target.value }))
}
placeholder="Set category"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="number"
value={form.amountGreater}
onChange={(event) =>
setForm((prev) => ({ ...prev, amountGreater: event.target.value }))
}
placeholder="Amount greater than"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="number"
value={form.amountLess}
onChange={(event) =>
setForm((prev) => ({ ...prev, amountLess: event.target.value }))
}
placeholder="Amount less than"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.setHidden}
onChange={(event) =>
setForm((prev) => ({ ...prev, setHidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Hide matching transactions
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.isActive}
onChange={(event) =>
setForm((prev) => ({ ...prev, isActive: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Rule is active
</label>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={onCreate}
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Save rule
</button>
<button
type="button"
onClick={() => setShowNew(false)}
className="rounded-full border border-border bg-background px-4 py-2 text-xs font-semibold text-foreground hover:bg-secondary transition-colors"
>
Cancel
</button>
</div>
</div>
) : null}
{status ? <p className="text-sm text-muted-foreground">{status}</p> : null}
{rules.length ? (
<div className="mt-4 space-y-4">
{rules.map((rule) => (
<div
key={rule.id}
className="rounded-xl border border-border bg-background/50 p-4"
>
<div className="flex items-center justify-between">
<div>
<p className="font-bold text-foreground">{rule.name}</p>
<p className="text-xs text-muted-foreground">
Priority {rule.priority} - {rule.isActive ? "Active" : "Paused"}
</p>
</div>
<span className={`rounded-full border border-border px-2 py-1 text-xs font-medium ${rule.isActive ? "bg-primary/10 text-primary" : "bg-secondary text-muted-foreground"}`}>
{rule.isActive ? "Live" : "Paused"}
</span>
</div>
<div className="mt-3 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(rule.conditions)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(rule.actions)}
</div>
</div>
))}
</div>
) : null}
</div>
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground font-bold">AI Suggestions</p>
<h2 className="mt-3 text-xl font-bold text-foreground">Pattern-based rule ideas</h2>
<div className="mt-4 space-y-4">
{suggestions.length ? (
suggestions.map((item) => (
<div
key={item.id}
className="rounded-xl border border-border bg-background/50 p-4"
>
<p className="font-bold text-foreground">{item.name}</p>
<p className="mt-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(item.conditions)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(item.actions)}
</p>
<p className="mt-2 text-xs font-medium text-primary">
Confidence: {(item.confidence * 100).toFixed(0)}%
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No suggestions yet.</p>
)}
</div>
</div>
</div>
</AppShell>
);
}

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

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

42
app/settings/page.tsx Normal file
View File

@ -0,0 +1,42 @@
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>
);
}

View File

@ -0,0 +1,149 @@
"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>
);
}

View File

@ -0,0 +1,165 @@
"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>
);
}

15
app/tax/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Tax Workspace | LedgerOne</title>
<meta
name="description"
content="Prepare tax returns and export audit-ready tax packages in LedgerOne."
/>
<meta
name="keywords"
content="ledgerone tax, tax export, audit-ready tax package"
/>
</>
);
}

374
app/tax/page.tsx Normal file
View File

@ -0,0 +1,374 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TaxReturn = {
id: string;
taxYear: number;
filingType: "individual" | "business";
jurisdictions: string[];
status: "draft" | "ready" | "exported";
updatedAt: string;
};
export default function TaxPage() {
const [returns, setReturns] = useState<TaxReturn[]>([]);
const [status, setStatus] = useState("");
const [useLocal, setUseLocal] = useState(true);
const [useSample, setUseSample] = useState(true);
const [year, setYear] = useState(new Date().getFullYear());
const [filingType, setFilingType] = useState<"individual" | "business">("individual");
const [jurisdictions, setJurisdictions] = useState<string[]>(["CA", "NY"]);
const sampleProfile = {
taxpayer: {
name: "John Doe",
ssn: "111-22-3333",
dob: "1989-04-12",
filingStatus: "Single",
address: "123 Market St, San Francisco, CA"
},
income: {
w2: [{ employer: "Northwind Labs", wages: 92000, federalWithheld: 12000 }],
interest: [{ payer: "City Bank", amount: 320 }],
dividends: [{ payer: "Index Fund", amount: 480 }],
selfEmployment: [{ business: "Doe Consulting", income: 28000, expenses: 6400 }]
},
deductions: {
standard: true,
charitable: 600,
studentLoanInterest: 900
},
credits: {
education: 0,
childTax: 0
},
documents: [
"W-2 (Northwind Labs)",
"1099-INT (City Bank)",
"1099-DIV (Index Fund)",
"1099-NEC (Doe Consulting)",
"Health Insurance 1095-A",
"State withholding statement"
]
};
const states = [
{ code: "AL", name: "Alabama" },
{ code: "AK", name: "Alaska" },
{ code: "AZ", name: "Arizona" },
{ code: "AR", name: "Arkansas" },
{ code: "CA", name: "California" },
{ code: "CO", name: "Colorado" },
{ code: "CT", name: "Connecticut" },
{ code: "DE", name: "Delaware" },
{ code: "FL", name: "Florida" },
{ code: "GA", name: "Georgia" },
{ code: "HI", name: "Hawaii" },
{ code: "ID", name: "Idaho" },
{ code: "IL", name: "Illinois" },
{ code: "IN", name: "Indiana" },
{ code: "IA", name: "Iowa" },
{ code: "KS", name: "Kansas" },
{ code: "KY", name: "Kentucky" },
{ code: "LA", name: "Louisiana" },
{ code: "ME", name: "Maine" },
{ code: "MD", name: "Maryland" },
{ code: "MA", name: "Massachusetts" },
{ code: "MI", name: "Michigan" },
{ code: "MN", name: "Minnesota" },
{ code: "MS", name: "Mississippi" },
{ code: "MO", name: "Missouri" },
{ code: "MT", name: "Montana" },
{ code: "NE", name: "Nebraska" },
{ code: "NV", name: "Nevada" },
{ code: "NH", name: "New Hampshire" },
{ code: "NJ", name: "New Jersey" },
{ code: "NM", name: "New Mexico" },
{ code: "NY", name: "New York" },
{ code: "NC", name: "North Carolina" },
{ code: "ND", name: "North Dakota" },
{ code: "OH", name: "Ohio" },
{ code: "OK", name: "Oklahoma" },
{ code: "OR", name: "Oregon" },
{ code: "PA", name: "Pennsylvania" },
{ code: "RI", name: "Rhode Island" },
{ code: "SC", name: "South Carolina" },
{ code: "SD", name: "South Dakota" },
{ code: "TN", name: "Tennessee" },
{ code: "TX", name: "Texas" },
{ code: "UT", name: "Utah" },
{ code: "VT", name: "Vermont" },
{ code: "VA", name: "Virginia" },
{ code: "WA", name: "Washington" },
{ code: "WV", name: "West Virginia" },
{ code: "WI", name: "Wisconsin" },
{ code: "WY", name: "Wyoming" }
];
const localKey = "ledgerone_tax_returns";
const ensureUserId = () => {
if (typeof window === "undefined") {
return "";
}
let userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
userId = `demo_${crypto.randomUUID()}`;
localStorage.setItem("ledgerone_user_id", userId);
}
return userId;
};
const loadReturns = async () => {
if (useLocal && typeof window !== "undefined") {
const raw = localStorage.getItem(localKey);
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
setStatus("Running in local-only mode.");
return;
}
const userId = ensureUserId();
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
try {
const res = await fetch(`/api/tax/returns${query}`);
const payload = (await res.json()) as ApiResponse<TaxReturn[]>;
if (!res.ok || payload.error) {
throw new Error(payload.error?.message ?? "Unable to load returns.");
}
setReturns(payload.data);
setUseLocal(false);
return;
} catch {
if (typeof window !== "undefined") {
const raw = localStorage.getItem(localKey);
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
setUseLocal(true);
setStatus("Running in local-only mode (no backend).");
}
}
};
useEffect(() => {
loadReturns().catch(() => {
setStatus("Unable to load returns.");
});
}, []);
const createReturn = async () => {
const userId = ensureUserId();
setStatus("Creating return...");
const payload = {
userId,
taxYear: year,
filingType,
jurisdictions
};
if (useLocal) {
const nextReturn: TaxReturn = {
id: `local_${crypto.randomUUID()}`,
taxYear: payload.taxYear,
filingType: payload.filingType,
jurisdictions: payload.jurisdictions,
status: "draft",
updatedAt: new Date().toISOString()
};
const next = [...returns, nextReturn];
localStorage.setItem(localKey, JSON.stringify(next));
setReturns(next);
setStatus("Return created locally.");
return;
}
const res = await fetch("/api/tax/returns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const response = (await res.json()) as ApiResponse<TaxReturn>;
if (!res.ok || response.error) {
setStatus(response.error?.message ?? "Unable to create return.");
return;
}
setStatus("Return created.");
await loadReturns();
};
const exportReturn = async (id: string) => {
setStatus("Exporting return...");
if (useLocal) {
const ret = returns.find((item) => item.id === id);
const payload = {
return: ret,
documents: useSample ? sampleProfile.documents : [],
sampleData: useSample ? sampleProfile : null
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus("Export ready (local).");
return;
}
const res = await fetch(`/api/tax/returns/${id}/export`, { method: "POST" });
const response = (await res.json()) as ApiResponse<{
return: TaxReturn;
documents: unknown[];
}>;
if (!res.ok || response.error) {
setStatus(response.error?.message ?? "Export failed.");
return;
}
const blob = new Blob([JSON.stringify(response.data, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus("Export ready.");
await loadReturns();
};
return (
<AppShell title="Tax" subtitle="Prepare returns and export audit-ready packages.">
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-bold text-foreground">Create a return</h2>
<div className="mt-4 grid gap-4">
<div className="rounded-xl border border-border bg-background/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">Sample dataset</p>
<p className="text-xs text-muted-foreground">Use John Doe sample intake data.</p>
</div>
<button
type="button"
onClick={() => setUseSample((value) => !value)}
className={`rounded-full px-3 py-1 text-xs font-semibold transition-colors ${useSample ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground"
}`}
>
{useSample ? "On" : "Off"}
</button>
</div>
{useSample ? (
<div className="mt-4 text-xs text-muted-foreground">
<p>W-2 wages: $92,000 - 1099-NEC: $28,000</p>
<p>Standard deduction - CA + NY filings</p>
</div>
) : null}
</div>
<div>
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Tax year</label>
<input
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
type="number"
value={year}
onChange={(event) => setYear(Number(event.target.value))}
/>
</div>
<div>
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Filing type
</label>
<select
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
value={filingType}
onChange={(event) =>
setFilingType(event.target.value as "individual" | "business")
}
>
<option value="individual">Individual (1040)</option>
<option value="business">Business (1120/1065)</option>
</select>
</div>
<div>
<label className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">
Jurisdictions (states)
</label>
<select
className="mt-2 h-44 w-full rounded-xl border border-border bg-background/50 px-4 py-3 text-sm text-foreground focus:border-primary focus:ring-primary"
multiple
value={jurisdictions}
onChange={(event) =>
setJurisdictions(
Array.from(event.target.selectedOptions).map((option) => option.value)
)
}
>
{states.map((state) => (
<option key={state.code} value={state.code}>
{state.name} ({state.code})
</option>
))}
</select>
<p className="mt-2 text-xs text-muted-foreground">
Hold Ctrl/Command to select multiple states.
</p>
</div>
<div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Required documents</p>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground">
{sampleProfile.documents.map((doc) => (
<div key={doc} className="flex items-center justify-between">
<span>{doc}</span>
<span className="rounded-full bg-secondary px-2 py-1 text-foreground font-medium">Pending</span>
</div>
))}
</div>
</div>
<button
type="button"
onClick={createReturn}
className="rounded-xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Create return
</button>
{status ? <p className="text-xs font-medium text-primary">{status}</p> : null}
</div>
</div>
<div className="glass-panel p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-bold text-foreground">Your returns</h2>
<div className="mt-4 space-y-4">
{returns.length ? (
returns.map((ret) => (
<div key={ret.id} className="rounded-xl border border-border bg-background/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-bold text-foreground">
{ret.taxYear} - {ret.filingType}
</p>
<p className="text-xs text-muted-foreground">
States: {ret.jurisdictions.join(", ")}
</p>
{useSample ? (
<p className="text-xs text-muted-foreground">Sample: John Doe</p>
) : null}
</div>
<span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium text-foreground">
{ret.status}
</span>
</div>
<button
type="button"
onClick={() => exportReturn(ret.id)}
className="mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Export package
</button>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No returns yet.</p>
)}
</div>
</div>
</div>
</AppShell>
);
}

130
app/terms/page.tsx Normal file
View File

@ -0,0 +1,130 @@
import Link from "next/link";
import { Background } from "../../components/background";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "Terms of Service",
description: "Terms and conditions for using LedgerOne.",
keywords: siteInfo.keywords
};
const sections = [
{ id: "acceptance", title: "1. Acceptance of Terms" },
{ id: "service", title: "2. Service Description" },
{ id: "accounts", title: "3. User Accounts" },
{ id: "privacy", title: "4. Data Privacy" },
{ id: "governing", title: "5. Governing Law" },
{ id: "disputes", title: "6. Dispute Resolution" }
];
export default function TermsPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Terms of Service",
description: "Terms and conditions for using LedgerOne.",
url: `${siteInfo.url}/terms`
}
];
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex-1 pt-24 pb-16">
<div className="max-w-3xl mx-auto px-6 lg:px-8">
<div className="rounded-3xl border border-border/60 bg-gradient-to-b from-background/95 via-background to-background/90 p-6 shadow-glass backdrop-blur-sm sm:p-10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8 pb-6 border-b border-border/60">
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Terms of Service
</h1>
<p className="text-sm text-muted-foreground">
Last updated: October 24, 2023
</p>
</div>
<nav className="mb-10 rounded-xl bg-secondary/20 border border-border/50 p-4" aria-label="On this page">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">On this page</p>
<ul className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{sections.map(({ id, title }) => (
<li key={id}>
<a href={`#${id}`} className="text-primary hover:underline">
{title.replace(/^\d+\.\s/, "")}
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-10 text-muted-foreground leading-relaxed">
<section id="acceptance">
<h2 className="text-foreground font-bold text-xl mb-3">1. Acceptance of Terms</h2>
<p>
By accessing and using LedgerOne, you accept and agree to be bound by the terms and provisions of this agreement.
</p>
</section>
<section id="service">
<h2 className="text-foreground font-bold text-xl mb-3">2. Service Description</h2>
<p>
LedgerOne provides financial data aggregation, ledger management, and reporting tools. We are not a bank or financial advisor.
</p>
</section>
<section id="accounts">
<h2 className="text-foreground font-bold text-xl mb-3">3. User Accounts</h2>
<p>
You are responsible for maintaining the security of your account and password. LedgerOne cannot and will not be liable for any loss or damage from your failure to comply with this security obligation.
</p>
</section>
<section id="privacy">
<h2 className="text-foreground font-bold text-xl mb-3">4. Data Privacy</h2>
<p>
Your data is yours. We do not sell your financial data to third parties. See our{" "}
<Link href="/privacy-policy" className="text-primary hover:underline font-medium">
Privacy Policy
</Link>{" "}
for details on how we protect your information.
</p>
</section>
<section id="governing">
<h2 className="text-foreground font-bold text-xl mb-3">5. Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with the laws of the United States and Canada. LedgerOne Inc. and you irrevocably consent that the courts of the United States and Canada shall have exclusive jurisdiction to resolve any dispute which may arise in connection with these terms.
</p>
</section>
<section id="disputes">
<h2 className="text-foreground font-bold text-xl mb-3">6. Dispute Resolution</h2>
<p>
Any dispute arising out of or in connection with this contract, including any question regarding its existence, validity, or termination, shall be referred to and finally resolved by arbitration under the rules of the American Arbitration Association (AAA) for US residents, or the ADR Institute of Canada for Canadian residents.
</p>
</section>
</div>
<div className="mt-12 pt-6 border-t border-border/60 flex flex-wrap gap-6 text-sm">
<Link href="/privacy-policy" className="text-primary hover:underline font-medium">
Privacy Policy
</Link>
<Link href="/contact" className="text-primary hover:underline font-medium">
Contact us
</Link>
</div>
</div>
</div>
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

15
app/transactions/head.tsx Normal file
View File

@ -0,0 +1,15 @@
export default function Head() {
return (
<>
<title>Transactions | LedgerOne</title>
<meta
name="description"
content="Review raw transactions and layered categories in LedgerOne."
/>
<meta
name="keywords"
content="ledgerone transactions, transaction ledger, audit-ready transactions"
/>
</>
);
}

513
app/transactions/page.tsx Normal file
View File

@ -0,0 +1,513 @@
"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<{ transactions: TransactionRow[]; total: number }>(`/api/transactions${query}`);
if (res.error) {
setStatus(res.error.message ?? "Unable to load transactions.");
return;
}
const txs = res.data?.transactions ?? [];
setRows(txs);
setStatus(txs.length ? "" : "No transactions yet.");
};
const loadAccounts = async () => {
const res = await apiFetch<{ accounts: Account[]; total: number }>("/api/accounts");
if (!res.error) setAccounts(res.data?.accounts ?? []);
};
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>
);
}

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

@ -0,0 +1,121 @@
"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-full flex items-center justify-center text-white font-bold text-xl shadow-[0_12px_30px_var(--gradient-glow)]"
style={{ background: "linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end))" }}
>
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>
);
}

244
components/app-shell.tsx Normal file
View File

@ -0,0 +1,244 @@
"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>
);
}

44
components/background.tsx Normal file
View File

@ -0,0 +1,44 @@
"use client";
/**
* Reusable soft gradient background (glassmorphism / ethereal).
* Soft gradient base + blurred blobs (cyan, lavender, teal) for frosted glow.
* Use with a parent that has page-soft-bg and relative overflow-hidden.
* Wrap page content in a container with relative z-10.
*/
export function Background() {
return (
<div
className="absolute inset-0 z-0 overflow-hidden pointer-events-none"
aria-hidden
>
{/* Top-left: light airy blue / cyan */}
<div
className="absolute -top-32 -left-32 h-[700px] w-[700px] rounded-full bg-[#7dd3fc] opacity-30 blur-[100px] animate-blob-float"
style={{ animationDelay: "0s" }}
/>
<div
className="absolute top-0 left-1/4 h-[500px] w-[500px] rounded-full bg-[#38bdf8] opacity-20 blur-[80px] animate-blob-float"
style={{ animationDelay: "-4s" }}
/>
{/* Mid / center-right: subtle lavender */}
<div
className="absolute top-1/3 right-0 w-[600px] h-[600px] rounded-full bg-[#c4b5fd] opacity-28 blur-[100px] animate-blob-float"
style={{ animationDelay: "-8s" }}
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[550px] h-[550px] rounded-full bg-[#a78bfa] opacity-18 blur-[90px] animate-blob-float"
style={{ animationDelay: "-12s" }}
/>
{/* Bottom-right: soft green / teal */}
<div
className="absolute -bottom-40 -right-20 h-[600px] w-[600px] rounded-full bg-[#5eead4] opacity-25 blur-[100px] animate-blob-float"
style={{ animationDelay: "-2s" }}
/>
<div
className="absolute bottom-0 right-1/3 h-[450px] w-[450px] rounded-full bg-[#6ee7b7] opacity-20 blur-[80px] animate-blob-float"
style={{ animationDelay: "-6s" }}
/>
</div>
);
}

View File

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

View File

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

24
components/demo-cta.tsx Normal file
View File

@ -0,0 +1,24 @@
import Link from "next/link";
export function DemoCta() {
return (
<section className="mx-auto mt-10 max-w-6xl px-6">
<div className="rounded-3xl border border-border bg-secondary/30 backdrop-blur-sm p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">Book a demo</p>
<h2 className="mt-3 text-2xl font-semibold text-foreground">
See LedgerOne in a live walkthrough.
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Talk through your workflow and learn how to keep a complete audit trail.
</p>
</div>
<Link href="/book-demo" className="btn-primary inline-flex shrink-0">
Book a demo
</Link>
</div>
</div>
</section>
);
}

View File

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

View File

@ -0,0 +1,35 @@
import { defaultFaqs } from "../data/faq";
type FaqSectionProps = {
title?: string;
subtitle?: string;
limit?: number;
};
export function FaqSection({ title, subtitle, limit }: FaqSectionProps) {
const items = limit ? defaultFaqs.slice(0, limit) : defaultFaqs;
return (
<section className="mx-auto mt-10 max-w-6xl px-6">
<div className="rounded-3xl border border-border bg-background/80 backdrop-blur-sm p-6 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">FAQ</p>
<h2 className="mt-3 text-2xl font-semibold text-foreground">
{title ?? "Common questions, clear answers."}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{subtitle ??
"Find pricing, account, export, and security answers for LedgerOne."}
</p>
<ol className="mt-6 space-y-5 text-sm text-muted-foreground">
{items.map((item, index) => (
<li key={item.question} className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-foreground">
{index + 1}. {item.question}
</p>
<p className="text-muted-foreground leading-relaxed">{item.answer}</p>
</li>
))}
</ol>
</div>
</section>
);
}

View File

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

View File

@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type LinkTokenData = { linkToken?: string };
type ExportData = { status?: string; url?: string; csv?: string };
async function postJson<T>(path: string) {
const res = await fetch(path, {
method: "POST"
});
if (!res.ok) {
throw new Error("Request failed");
}
const payload = (await res.json()) as ApiResponse<T>;
return payload.data;
}
async function getJson<T>(path: string) {
const res = await fetch(path);
if (!res.ok) {
throw new Error("Request failed");
}
const payload = (await res.json()) as ApiResponse<T>;
return payload.data;
}
export function HeroActions() {
const [status, setStatus] = useState<string>("");
const onStart = async () => {
setStatus("Requesting a secure link token...");
try {
const data = await postJson<LinkTokenData>("/api/accounts/link");
if (data.linkToken) {
setStatus(`Link token ready: ${data.linkToken}`);
} else {
setStatus("Link token requested.");
}
} catch {
setStatus("Unable to request link token.");
}
};
const onViewExport = async () => {
setStatus("Preparing export sample...");
try {
const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
const data = await getJson<ExportData>(`/api/exports/csv${query}`);
if (data.csv) {
const blob = new Blob([data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus("Export sample opened.");
} else {
setStatus("Export sample ready.");
}
} catch {
setStatus("Unable to fetch export sample.");
}
};
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-4">
<button
type="button"
onClick={onStart}
className="btn-primary inline-flex items-center gap-2 px-6 py-3 transition-all hover:-translate-y-0.5 active:translate-y-0"
>
<svg className="h-4 w-4 text-white/95" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" 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>
Start a private ledger
</button>
<button
type="button"
onClick={onViewExport}
className="btn-secondary inline-flex items-center gap-2 px-6 py-3"
>
<svg className="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
View export sample
</button>
</div>
{status ? <p className="text-xs text-muted-foreground">{status}</p> : null}
</div>
);
}

View File

@ -0,0 +1,30 @@
import Link from "next/link";
export function LandingCta() {
return (
<section className="relative overflow-hidden py-12 lg:py-16">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top,_rgba(79,70,229,0.18),_transparent_60%),radial-gradient(circle_at_bottom,_rgba(45,212,191,0.2),_transparent_50%)]" />
<div className="mx-auto max-w-3xl px-6 text-center lg:px-8">
<h2 className="heading-section mb-4">
Start Making Smarter Money Decisions Today
</h2>
<p className="body-lead mb-8">
No spreadsheets. No guesswork. Just clarity. Connect your accounts, see your future, and
get a real-time &quot;safe to spend&quot; number.
</p>
<div className="flex flex-wrap items-center justify-center gap-4">
<Link href="/register" className="btn-primary">
Get started free
</Link>
<Link href="/pricing" className="btn-secondary">
View pricing
</Link>
</div>
<p className="mt-4 text-xs text-muted-foreground">
No credit card required. Cancel anytime. Your data stays encrypted at rest and in transit.
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,167 @@
"use client";
"use client";
import Image from "next/image";
import { motion } from "framer-motion";
const sectionVariants = {
hidden: { opacity: 1, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
},
};
export function LandingFeatures() {
return (
<motion.section
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.25 }}
variants={sectionVariants}
className="py-16 lg:py-20"
>
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-12">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Not Just Tracking. Thinking.
</h2>
<p className="mt-4 text-lg text-muted-foreground">
LedgerOne turns your raw transaction firehose into AI-native contextwhat&apos;s
happening, what&apos;s coming, and what you should do about it.
</p>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* AI Financial Insights */}
<motion.div
whileHover={{ y: -6, scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="group rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 p-6 shadow-glass shadow-[0_0_60px_rgba(34,197,94,0.25)]"
>
<div className="mb-5 flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-400">
<span className="text-xs font-semibold">AI</span>
</div>
<h3 className="mb-2 text-lg font-semibold text-foreground">AI Financial Insights</h3>
<p className="mb-5 text-sm text-muted-foreground">
Get plain-language explanations of what changed, why it matters, and what we recommend
you do next.
</p>
<div className="overflow-hidden rounded-2xl border border-border/80 bg-background/80 text-left text-xs shadow-inner">
{/* Preview of AI chat responding (image placeholder for now) */}
<div className="relative aspect-video w-full overflow-hidden">
<Image
src="/images/feature-reports.png"
alt="AI financial insights preview"
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/15 to-transparent" />
{/* AI prediction label */}
<div className="pointer-events-none absolute left-3 top-3 inline-flex items-center gap-2 rounded-full bg-slate-950/80 px-2 py-1 text-[10px] text-emerald-200 ring-1 ring-emerald-400/60">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-pulse" />
<span>AI prediction · High confidence</span>
</div>
</div>
</div>
</motion.div>
{/* Predictive Cash Flow */}
<motion.div
whileHover={{ y: -6, scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="group rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 p-6 shadow-glass shadow-[0_0_60px_rgba(56,189,248,0.22)]"
>
<div className="mb-5 flex h-10 w-10 items-center justify-center rounded-2xl bg-sky-500/10 text-sky-400">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M4 19.5V10M4 10L8 6M4 10L0 6" />
<path d="M12 19.5V4.5M12 4.5L16 8.5M12 4.5L8 8.5" />
<path d="M20 4.5V14M20 14L16 18M20 14L24 18" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-foreground">Predictive Cash Flow</h3>
<p className="mb-5 text-sm text-muted-foreground">
See your balance 30 days from nowbefore it happens. Every bill, subscription, and
paycheck automatically mapped.
</p>
<div className="relative overflow-hidden rounded-2xl border border-border/80 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-slate-300">
<span>Projected net balance</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] text-emerald-300">
94% on track
</span>
</div>
{/* Cashflow line preview (image placeholder for now) */}
<div className="relative aspect-video w-full overflow-hidden rounded-xl">
<Image
src="/images/feature-cashflow.png"
alt="Predictive cash flow graph"
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Dynamic highlight where the curve dips */}
<div className="pointer-events-none absolute bottom-3 right-3 inline-flex items-center gap-1 rounded-full bg-slate-950/80 px-2 py-1 text-[10px] text-amber-200 ring-1 ring-amber-400/60">
<span className="h-1.5 w-1.5 rounded-full bg-amber-300 animate-pulse" />
<span>Risk window · next 7 days</span>
</div>
</div>
</div>
</motion.div>
{/* Smart Budgeting */}
<motion.div
whileHover={{ y: -6, scale: 1.02 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="group rounded-3xl border border-border/60 bg-gradient-to-b from-background/90 via-background to-background/60 p-6 shadow-glass shadow-[0_0_60px_rgba(139,92,246,0.25)]"
>
<div className="mb-5 flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/10 text-violet-400">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 1v22M5 5h14a2 2 0 012 2v4H3V7a2 2 0 012-2Z" />
<path d="M3 13h18v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4Z" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-foreground">Smart Budgeting</h3>
<p className="mb-5 text-sm text-muted-foreground">
Budgets that re-balance themselves based on your behaviorplus a real-time &quot;safe
to spend&quot; number.
</p>
<div className="overflow-hidden rounded-2xl border border-border/80 bg-background/80 text-xs">
{/* Progress rings + safe-to-spend preview (image placeholder for now) */}
<div className="relative aspect-video w-full">
<Image
src="/images/feature-reports.png"
alt="Smart budgeting preview"
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
{/* Safe-to-spend label */}
<div className="pointer-events-none absolute left-3 bottom-3 inline-flex items-center gap-2 rounded-full bg-slate-950/75 px-2 py-1 text-[10px] text-emerald-200 ring-1 ring-emerald-400/60">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-pulse" />
<span>Safe to spend · Updated live</span>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</motion.section>
);
}

View File

@ -0,0 +1,105 @@
"use client";
"use client";
import { motion } from "framer-motion";
const futureVariants = {
hidden: { opacity: 1, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const },
},
};
export function LandingFuture() {
return (
<motion.section
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.35 }}
variants={futureVariants}
className="bg-secondary/15 py-16 lg:py-20"
>
<div className="mx-auto max-w-6xl px-6 lg:px-8">
<div className="mb-8 max-w-3xl space-y-4">
<h2 className="text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
See Your Financial Future
</h2>
<p className="text-base text-muted-foreground sm:text-lg">
LedgerOne maps every recurring bill, subscription, and paycheck onto a single, living
timelineso you can see exactly when your balance bends.
</p>
</div>
<div className="overflow-hidden rounded-3xl border border-border/80 bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 p-5 shadow-[0_30px_120px_rgba(15,23,42,0.9)]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-300">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
<span>Projected balance · next 30 days</span>
</div>
<div className="flex items-center gap-3 text-[11px]">
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-emerald-400" />
<span>Balance</span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-sky-400" />
<span>Income</span>
</div>
<div className="flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-rose-400" />
<span>Bills</span>
</div>
</div>
</div>
<div className="relative w-full overflow-hidden rounded-2xl bg-slate-900">
{/* Cinematic future-balance preview (image placeholder for now) */}
<div className="relative aspect-video w-full">
<div className="absolute inset-0">
<img
src="/images/feature-reports.png"
alt="Predicted balance over 30 days"
className="h-full w-full object-cover"
/>
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent" />
</div>
{/* Overlay events + warning label on top of the video */}
<div className="pointer-events-none absolute inset-4 flex items-end justify-between text-[10px] text-slate-200">
<div className="flex flex-col items-center gap-1">
<div className="h-12 w-px bg-rose-400/70" />
<span className="rounded-full bg-rose-500/20 px-2 py-0.5 text-rose-200">
Rent · -$2,400
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="h-16 w-px bg-emerald-400/80" />
<span className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-emerald-200">
Payroll · +$6,800
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="h-10 w-px bg-sky-400/80" />
<span className="rounded-full bg-sky-500/20 px-2 py-0.5 text-sky-200">
Subscriptions · -$480
</span>
</div>
</div>
<div className="pointer-events-none absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-400/60 shadow-[0_0_40px_rgba(251,191,36,0.55)]">
<span className="h-1.5 w-1.5 rounded-full bg-amber-300 animate-pulse" />
<span>
Your balance drops here projected to dip below{" "}
<span className="font-semibold">$1,000</span> on the 24th.
</span>
</div>
</div>
</div>
</div>
</motion.section>
);
}

175
components/landing-hero.tsx Normal file
View File

@ -0,0 +1,175 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { motion } from "framer-motion";
import { HeroActions } from "./hero-actions";
const heroVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.8,
ease: [0.16, 1, 0.3, 1] as const,
when: "beforeChildren",
staggerChildren: 0.08,
},
},
};
const heroChild = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0 },
};
export function LandingHero() {
const [range, setRange] = useState<"30" | "90">("30");
return (
<motion.section
initial="hidden"
animate="visible"
variants={heroVariants}
className="relative overflow-hidden pt-16 pb-12 lg:pt-20 lg:pb-16"
>
<div className="relative z-10 mx-auto flex max-w-6xl flex-col gap-12 px-6 lg:flex-row lg:items-center lg:px-8">
{/* Left: Copy */}
<div className="max-w-xl">
<motion.div variants={heroChild} className="space-y-8">
<motion.div variants={heroChild} className="badge-pill">
<span className="badge-pill-dot" />
<span className="uppercase tracking-[0.18em] text-[10px] text-emerald-500">
AI-native · Live predictions
</span>
</motion.div>
<motion.div variants={heroChild} className="space-y-5">
<h1 className="heading-hero">
Know Your Money{" "}
<span className="heading-hero-accent">Before You Spend It.</span>
</h1>
<p className="body-lead">
AI-powered insights, forecasts, and real-time financial controlwithout spreadsheets.
LedgerOne watches every account, predicts risk, and tells you what&apos;s safe to
spend before you click &quot;checkout&quot;.
</p>
</motion.div>
<motion.div variants={heroChild} className="flex flex-wrap items-center gap-4">
<motion.div whileHover={{ y: -2, scale: 1.01 }} whileTap={{ scale: 0.98 }}>
<Link href="/register" className="btn-primary">
Get started in 2 minutes
</Link>
</motion.div>
<motion.div whileHover={{ y: -1, scale: 1.01 }} whileTap={{ scale: 0.98 }}>
<Link href="/book-demo" className="btn-secondary">
Watch product walkthrough
</Link>
</motion.div>
</motion.div>
<motion.div variants={heroChild} className="feature-bullets">
<div className="feature-bullet">
<span className="feature-bullet-dot" />
<span>Instant Plaid connections</span>
</div>
<div className="feature-bullet">
<span className="feature-bullet-dot animate-pulse" />
<span>Bank-grade security</span>
</div>
<div className="feature-bullet">
<span className="feature-bullet-dot" />
<span>14-day free trial</span>
</div>
</motion.div>
<motion.div variants={heroChild} className="mt-4">
<HeroActions />
</motion.div>
</motion.div>
</div>
{/* Right: single focused product video with glow + AI insight */}
<motion.div
variants={heroChild}
className="relative mx-auto flex w-full max-w-xl items-center justify-center lg:max-w-xl"
>
{/* Depth: gradient glow behind video */}
<motion.div
className="pointer-events-none absolute -inset-10 rounded-[36px] bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-green-400/20 blur-3xl"
animate={{ opacity: [0.4, 0.6, 0.4] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Primary product video card */}
<motion.div
className="relative z-10 w-full overflow-hidden rounded-2xl border border-white/10 shadow-2xl"
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1.04, y: 0 }}
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
whileHover={{ scale: 1.08 }}
>
<div className="relative aspect-[16/9] w-full">
<video
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
preload="none"
poster="/images/dashboard-hero.png"
>
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent" />
{/* Forecast toggle inside video */}
<div className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full bg-slate-950/80 px-2 py-1 text-[10px] text-slate-100 ring-1 ring-emerald-400/60 backdrop-blur-md">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Cashflow outlook
</span>
<div className="inline-flex items-center gap-1 rounded-full bg-white/5 p-0.5">
<button
type="button"
onClick={() => setRange("30")}
className={`px-2 py-0.5 rounded-full transition-colors ${
range === "30" ? "bg-emerald-400/25 text-emerald-100" : "text-slate-200"
}`}
>
30d
</button>
<button
type="button"
onClick={() => setRange("90")}
className={`px-2 py-0.5 rounded-full transition-colors ${
range === "90" ? "bg-emerald-400/25 text-emerald-100" : "text-slate-200"
}`}
>
90d
</button>
</div>
</div>
{/* Floating AI insight card */}
<motion.div
className="absolute right-4 top-4 mt-10 w-64 rounded-xl border border-emerald-300/50 bg-white/80 px-4 py-2 text-xs text-slate-900 shadow-lg backdrop-blur-md dark:border-emerald-400/60 dark:bg-slate-900/90 dark:text-slate-50"
animate={{ y: [-2, 2, -2] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut", delay: 0.4 }}
>
<p className="text-[11px] leading-relaxed">
You&apos;re likely to overspend{" "}
<span className="font-semibold text-emerald-600 dark:text-emerald-300">$120</span>{" "}
this week based on upcoming bills and historical spending.
</p>
</motion.div>
</div>
</motion.div>
</motion.div>
</div>
</motion.section>
);
}

32
components/logo.tsx Normal file
View File

@ -0,0 +1,32 @@
import Link from "next/link";
type LogoProps = {
href?: string;
className?: string;
showWordmark?: boolean;
};
/** Shared logo (mark + optional wordmark). Use in header, footer, auth. */
export function Logo({ href = "/", className = "", showWordmark = true }: LogoProps) {
const content = (
<>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white shadow-[0_12px_30px_var(--gradient-glow)] transition-transform group-hover:scale-105"
style={{ background: "linear-gradient(to right, var(--gradient-start), var(--gradient-mid), var(--gradient-end))" }}>
<span className="font-bold text-sm">L1</span>
</div>
{showWordmark && (
<span className="text-lg font-bold tracking-tight text-foreground">LedgerOne</span>
)}
</>
);
if (href) {
return (
<Link href={href} className={`flex items-center gap-2 group ${className}`}>
{content}
</Link>
);
}
return <div className={`flex items-center gap-2 ${className}`}>{content}</div>;
}

Some files were not shown because too many files have changed in this diff Show More