implemented the udpated changes
This commit is contained in:
parent
fe6dcfd4f6
commit
cf6c4005dd
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 2: Production ─────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3052
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
6
app/api/2fa/disable/route.ts
Normal file
6
app/api/2fa/disable/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/disable");
|
||||
}
|
||||
6
app/api/2fa/enable/route.ts
Normal file
6
app/api/2fa/enable/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/enable");
|
||||
}
|
||||
6
app/api/2fa/generate/route.ts
Normal file
6
app/api/2fa/generate/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/generate");
|
||||
}
|
||||
@ -1,12 +1,6 @@
|
||||
export async function POST() {
|
||||
const res = await fetch("http://localhost:3051/api/accounts/link", {
|
||||
method: "POST"
|
||||
});
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link-token");
|
||||
}
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/accounts/manual`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/manual");
|
||||
}
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"
|
||||
const url = new URL(req.url);
|
||||
const query = url.searchParams.toString();
|
||||
const res = await fetch(`${baseUrl}/api/accounts${query ? `?${query}` : ""}`);
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts");
|
||||
}
|
||||
|
||||
6
app/api/auth/forgot-password/route.ts
Normal file
6
app/api/auth/forgot-password/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/forgot-password");
|
||||
}
|
||||
@ -1,16 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/login");
|
||||
}
|
||||
|
||||
6
app/api/auth/logout/route.ts
Normal file
6
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/logout");
|
||||
}
|
||||
6
app/api/auth/me/route.ts
Normal file
6
app/api/auth/me/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
@ -1,17 +1,10 @@
|
||||
export async function PATCH(req: Request) {
|
||||
const body = await req.text();
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/auth/profile`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/profile");
|
||||
}
|
||||
|
||||
6
app/api/auth/refresh/route.ts
Normal file
6
app/api/auth/refresh/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/refresh");
|
||||
}
|
||||
@ -1,26 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET() {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
message: "POST JSON { email, password } to /api/auth/register."
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/register");
|
||||
}
|
||||
|
||||
6
app/api/auth/reset-password/route.ts
Normal file
6
app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/reset-password");
|
||||
}
|
||||
6
app/api/auth/verify-email/route.ts
Normal file
6
app/api/auth/verify-email/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/verify-email");
|
||||
}
|
||||
@ -1,11 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const res = await fetch(`http://localhost:3051/api/exports/csv${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/csv");
|
||||
}
|
||||
|
||||
6
app/api/exports/sheets/route.ts
Normal file
6
app/api/exports/sheets/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/sheets");
|
||||
}
|
||||
6
app/api/google/connect/route.ts
Normal file
6
app/api/google/connect/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "google/connect");
|
||||
}
|
||||
6
app/api/google/disconnect/route.ts
Normal file
6
app/api/google/disconnect/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "google/disconnect");
|
||||
}
|
||||
6
app/api/google/exchange/route.ts
Normal file
6
app/api/google/exchange/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "google/exchange");
|
||||
}
|
||||
6
app/api/google/status/route.ts
Normal file
6
app/api/google/status/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "google/status");
|
||||
}
|
||||
@ -1,16 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/plaid/exchange`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/exchange");
|
||||
}
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
export async function POST() {
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/plaid/link-token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/link-token");
|
||||
}
|
||||
|
||||
@ -1,27 +1,10 @@
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const res = await fetch(`http://localhost:3051/api/rules${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
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: Request) {
|
||||
const body = await req.text();
|
||||
const res = await fetch("http://localhost:3051/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "rules");
|
||||
}
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const res = await fetch(`http://localhost:3051/api/rules/suggestions${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "rules/suggestions");
|
||||
}
|
||||
|
||||
6
app/api/stripe/checkout/route.ts
Normal file
6
app/api/stripe/checkout/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/checkout");
|
||||
}
|
||||
6
app/api/stripe/portal/route.ts
Normal file
6
app/api/stripe/portal/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/portal");
|
||||
}
|
||||
6
app/api/stripe/subscription/route.ts
Normal file
6
app/api/stripe/subscription/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/subscription");
|
||||
}
|
||||
@ -1,15 +1,9 @@
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
context: { params: { id: string } }
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
const res = await fetch(`http://localhost:3051/api/tax/returns/${context.params.id}/export`, {
|
||||
method: "POST"
|
||||
});
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
return proxyRequest(req, `tax/returns/${params.id}/export`);
|
||||
}
|
||||
|
||||
@ -1,27 +1,10 @@
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const res = await fetch(`http://localhost:3051/api/tax/returns${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
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: Request) {
|
||||
const body = await req.text();
|
||||
const res = await fetch("http://localhost:3051/api/tax/returns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "tax/returns");
|
||||
}
|
||||
|
||||
@ -1,27 +1,9 @@
|
||||
export async function PATCH(req: Request, context: { params: { id: string } }) {
|
||||
try {
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const payload = await req.text();
|
||||
const res = await fetch(`${baseUrl}/api/transactions/${context.params.id}/derived`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload
|
||||
});
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." }
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, `transactions/${params.id}/derived`);
|
||||
}
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/transactions/cashflow${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [],
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." }
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/cashflow");
|
||||
}
|
||||
|
||||
6
app/api/transactions/import/route.ts
Normal file
6
app/api/transactions/import/route.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import");
|
||||
}
|
||||
@ -1,27 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const payload = await req.text();
|
||||
const res = await fetch(`${baseUrl}/api/transactions/manual`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload
|
||||
});
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." }
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/manual");
|
||||
}
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/transactions/merchants${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [],
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." }
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/merchants");
|
||||
}
|
||||
|
||||
@ -1,23 +1,10 @@
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/transactions${url.search}`);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [],
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." }
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions");
|
||||
}
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
export async function GET(req: Request) {
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const url = new URL(req.url);
|
||||
const query = url.searchParams.toString();
|
||||
const res = await fetch(`${baseUrl}/api/transactions/summary${query ? `?${query}` : ""}`);
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/summary");
|
||||
}
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.text();
|
||||
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
const res = await fetch(`${baseUrl}/api/transactions/sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new Response(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json"
|
||||
}
|
||||
});
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "@/lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/sync");
|
||||
}
|
||||
|
||||
115
app/app/page.tsx
115
app/app/page.tsx
@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
@ -31,36 +32,14 @@ type MerchantInsight = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const recentTransactions = [
|
||||
{
|
||||
date: "Oct 24, 2023",
|
||||
description: "Whole Foods Market",
|
||||
category: "Groceries",
|
||||
account: "Chase Sapphire",
|
||||
amount: "-$142.30"
|
||||
},
|
||||
{
|
||||
date: "Oct 23, 2023",
|
||||
description: "Apple Subscription",
|
||||
category: "Services",
|
||||
account: "Apple Card",
|
||||
amount: "-$14.99"
|
||||
},
|
||||
{
|
||||
date: "Oct 22, 2023",
|
||||
description: "Stripe Payout",
|
||||
category: "Income",
|
||||
account: "Mercury Business",
|
||||
amount: "+$4,200.00"
|
||||
},
|
||||
{
|
||||
date: "Oct 22, 2023",
|
||||
description: "Shell Gasoline",
|
||||
category: "Transport",
|
||||
account: "Chase Sapphire",
|
||||
amount: "-$52.12"
|
||||
}
|
||||
];
|
||||
type TxRow = {
|
||||
id: string;
|
||||
date: string;
|
||||
description: string;
|
||||
category?: string | null;
|
||||
accountId?: string | null;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
// ---------- helpers ----------
|
||||
function formatMonthLabel(yyyyMm: string) {
|
||||
@ -520,27 +499,23 @@ export default function AppHomePage() {
|
||||
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
|
||||
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
|
||||
const [accountCount, setAccountCount] = useState<number | null>(null);
|
||||
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const query = `?user_id=${encodeURIComponent(userId)}`;
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>),
|
||||
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>),
|
||||
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>),
|
||||
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>)
|
||||
apiFetch<Summary>("/api/transactions/summary"),
|
||||
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
||||
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
||||
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
|
||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
|
||||
])
|
||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
|
||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
||||
if (!summaryRes.error) setSummary(summaryRes.data);
|
||||
if (!cashflowRes.error) setCashflow(cashflowRes.data);
|
||||
if (!merchantsRes.error) setMerchants(merchantsRes.data);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
|
||||
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
||||
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
|
||||
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoading(false));
|
||||
@ -743,26 +718,48 @@ export default function AppHomePage() {
|
||||
<th className="pb-3 pl-2">Date</th>
|
||||
<th className="pb-3">Description</th>
|
||||
<th className="pb-3">Category</th>
|
||||
<th className="pb-3">Account</th>
|
||||
<th className="pb-3 pr-2 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTransactions.map((tx) => (
|
||||
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<td className="py-3 pl-2 font-medium">{tx.date}</td>
|
||||
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
||||
<td className="py-3">
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
|
||||
{tx.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">{tx.account}</td>
|
||||
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}>
|
||||
{tx.amount}
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
<td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
|
||||
<td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
|
||||
<td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
|
||||
<td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : recentTxs.length ? (
|
||||
recentTxs.map((tx) => {
|
||||
const amt = Number.parseFloat(tx.amount ?? "0");
|
||||
const fmtAmt = formatCurrency(amt);
|
||||
const isIncome = amt >= 0;
|
||||
return (
|
||||
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
|
||||
<td className="py-3 text-foreground font-medium">{tx.description}</td>
|
||||
<td className="py-3">
|
||||
{tx.category ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
|
||||
{tx.category}
|
||||
</span>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className={`py-3 pr-2 text-right font-bold ${isIncome ? "text-primary" : "text-foreground"}`}>
|
||||
{fmtAmt}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||
No transactions yet. Connect a bank account to get started.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
85
app/auth/google/callback/page.tsx
Normal file
85
app/auth/google/callback/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export default function GoogleCallbackPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
setStatus("error");
|
||||
setMessage(error === "access_denied" ? "You declined Google access." : `Google returned an error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setStatus("error");
|
||||
setMessage("No authorization code received from Google.");
|
||||
return;
|
||||
}
|
||||
|
||||
apiFetch<{ connected: boolean; googleEmail: string }>("/api/google/exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}).then((res) => {
|
||||
if (res.error) {
|
||||
setStatus("error");
|
||||
setMessage(res.error.message ?? "Failed to connect Google account.");
|
||||
} else {
|
||||
setStatus("success");
|
||||
setMessage(`Connected as ${res.data?.googleEmail ?? "your Google account"}.`);
|
||||
setTimeout(() => router.replace("/exports"), 2000);
|
||||
}
|
||||
});
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="glass-panel rounded-2xl p-10 text-center max-w-sm w-full shadow-lg">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Connecting your Google account...</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">Google Connected!</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{message}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">Redirecting to Exports...</p>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">Connection Failed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{message}</p>
|
||||
<button
|
||||
onClick={() => router.replace("/exports")}
|
||||
className="mt-4 text-xs text-primary hover:underline"
|
||||
>
|
||||
Back to Exports
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ExportData = { status: string; csv?: string; rowCount?: number };
|
||||
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
|
||||
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
|
||||
|
||||
export default function ExportsPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [csvStatus, setCsvStatus] = useState("");
|
||||
const [sheetsStatus, setSheetsStatus] = useState("");
|
||||
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
|
||||
const [sheetsLoading, setSheetsLoading] = useState(false);
|
||||
const [datePreset, setDatePreset] = useState("custom");
|
||||
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
@ -21,14 +23,18 @@ export default function ExportsPage() {
|
||||
maxAmount: "",
|
||||
category: "",
|
||||
source: "",
|
||||
includeHidden: false
|
||||
includeHidden: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
|
||||
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
setDatePreset(preset);
|
||||
if (preset === "custom") {
|
||||
return;
|
||||
}
|
||||
if (preset === "custom") return;
|
||||
const now = new Date();
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
let start = new Date(end);
|
||||
@ -43,101 +49,104 @@ export default function ExportsPage() {
|
||||
start = new Date(end.getFullYear() - 1, 0, 1);
|
||||
end.setMonth(11, 31);
|
||||
}
|
||||
const format = (value: Date) => value.toISOString().slice(0, 10);
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
startDate: format(start),
|
||||
endDate: format(end)
|
||||
}));
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
|
||||
};
|
||||
|
||||
const onExport = async () => {
|
||||
setStatus("Generating export...");
|
||||
const userId = localStorage.getItem("ledgerone_user_id");
|
||||
const buildParams = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) {
|
||||
params.set("user_id", userId);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
params.set("start_date", filters.startDate);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
params.set("end_date", filters.endDate);
|
||||
}
|
||||
if (filters.minAmount) {
|
||||
params.set("min_amount", filters.minAmount);
|
||||
}
|
||||
if (filters.maxAmount) {
|
||||
params.set("max_amount", filters.maxAmount);
|
||||
}
|
||||
if (filters.category) {
|
||||
params.set("category", filters.category);
|
||||
}
|
||||
if (filters.source) {
|
||||
params.set("source", filters.source);
|
||||
}
|
||||
if (filters.includeHidden) {
|
||||
params.set("include_hidden", "true");
|
||||
}
|
||||
if (filters.startDate) params.set("start_date", filters.startDate);
|
||||
if (filters.endDate) params.set("end_date", filters.endDate);
|
||||
if (filters.minAmount) params.set("min_amount", filters.minAmount);
|
||||
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.source) params.set("source", filters.source);
|
||||
if (filters.includeHidden) params.set("include_hidden", "true");
|
||||
return params;
|
||||
};
|
||||
|
||||
const onExportCsv = async () => {
|
||||
setCsvStatus("Generating export...");
|
||||
const params = buildParams();
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
try {
|
||||
const res = await fetch(`/api/exports/csv${query}`);
|
||||
const payload = (await res.json()) as ApiResponse<ExportData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Export failed.");
|
||||
return;
|
||||
}
|
||||
if (payload.data.csv) {
|
||||
const blob = new Blob([payload.data.csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setStatus(`Export ready (${payload.data.rowCount ?? 0} rows).`);
|
||||
} else {
|
||||
setStatus("Export ready.");
|
||||
}
|
||||
} catch {
|
||||
setStatus("Export failed.");
|
||||
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
|
||||
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
|
||||
if (res.data?.csv) {
|
||||
const blob = new Blob([res.data.csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
|
||||
} else {
|
||||
setCsvStatus("Export ready.");
|
||||
}
|
||||
};
|
||||
|
||||
const onConnectGoogle = async () => {
|
||||
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
|
||||
if (res.error) {
|
||||
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
|
||||
return;
|
||||
}
|
||||
if (res.data?.authUrl) {
|
||||
window.location.href = res.data.authUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnectGoogle = async () => {
|
||||
setDisconnecting(true);
|
||||
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
|
||||
setDisconnecting(false);
|
||||
if (!res.error) {
|
||||
setGoogleStatus({ connected: false });
|
||||
setSheetsStatus("Google account disconnected.");
|
||||
setSheetsUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportSheets = async () => {
|
||||
setSheetsLoading(true);
|
||||
setSheetsStatus("Creating Google Sheet...");
|
||||
setSheetsUrl(null);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (filters.startDate) body.startDate = filters.startDate;
|
||||
if (filters.endDate) body.endDate = filters.endDate;
|
||||
if (filters.minAmount) body.minAmount = filters.minAmount;
|
||||
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
|
||||
if (filters.category) body.category = filters.category;
|
||||
if (filters.includeHidden) body.includeHidden = true;
|
||||
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setSheetsLoading(false);
|
||||
if (res.error) {
|
||||
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
|
||||
return;
|
||||
}
|
||||
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
|
||||
if (url) {
|
||||
setSheetsUrl(url);
|
||||
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
|
||||
} else {
|
||||
setSheetsStatus("Sheet created.");
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Exports"
|
||||
subtitle="Generate CSV datasets with raw and derived fields."
|
||||
>
|
||||
<div className="glass-panel p-8 rounded-2xl shadow-sm">
|
||||
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
|
||||
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Start date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, startDate: event.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
disabled={datePreset !== "custom"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">End date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, endDate: event.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
disabled={datePreset !== "custom"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Date range</label>
|
||||
<select
|
||||
value={datePreset}
|
||||
onChange={(event) => applyPreset(event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
>
|
||||
<label className={labelCls}>Date range</label>
|
||||
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="this_month">This month</option>
|
||||
<option value="last_month">Last month</option>
|
||||
@ -146,96 +155,114 @@ export default function ExportsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Category contains</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.category}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, category: event.target.value }))
|
||||
}
|
||||
placeholder="Dining, Payroll, Utilities"
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<label className={labelCls}>Start date</label>
|
||||
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Min amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.minAmount}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, minAmount: event.target.value }))
|
||||
}
|
||||
placeholder="0.00"
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<label className={labelCls}>End date</label>
|
||||
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Max amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.maxAmount}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, maxAmount: event.target.value }))
|
||||
}
|
||||
placeholder="10000.00"
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<label className={labelCls}>Category contains</label>
|
||||
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Source contains</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.source}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, source: event.target.value }))
|
||||
}
|
||||
placeholder="plaid"
|
||||
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
|
||||
/>
|
||||
<label className={labelCls}>Min amount ($)</label>
|
||||
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Max amount ($)</label>
|
||||
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||
Include hidden transactions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.includeHidden}
|
||||
onChange={(event) =>
|
||||
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
|
||||
}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
Include hidden transactions
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
className="rounded-full bg-primary px-6 py-3 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Generate CSV export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
category: "",
|
||||
source: "",
|
||||
includeHidden: false
|
||||
})
|
||||
}
|
||||
className="rounded-full border border-border bg-background px-6 py-3 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
{/* Export cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* CSV */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Download CSV</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportCsv}
|
||||
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* Google Sheets */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
|
||||
{googleStatus?.connected ? (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{googleStatus?.connected ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onExportSheets}
|
||||
disabled={sheetsLoading}
|
||||
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDisconnectGoogle}
|
||||
disabled={disconnecting}
|
||||
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
|
||||
>
|
||||
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConnectGoogle}
|
||||
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
|
||||
>
|
||||
Connect Google Account
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
|
||||
{sheetsUrl && (
|
||||
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
|
||||
Open Sheet →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
115
app/forgot-password/page.tsx
Normal file
115
app/forgot-password/page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Sending reset link...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<{ message: string }>;
|
||||
if (!res.ok && payload.error) {
|
||||
setStatus(payload.error.message ?? "Something went wrong.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
|
||||
} catch {
|
||||
setStatus("Something went wrong. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
{sent ? (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-foreground font-medium">{status}</p>
|
||||
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
|
||||
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
We'll send a reset link to this address if it has an account.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Send reset link
|
||||
</button>
|
||||
{isError && status && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
|
||||
<p className="text-sm text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { ContactSection } from "../../components/contact-section";
|
||||
import { DemoCta } from "../../components/demo-cta";
|
||||
import { FaqSection } from "../../components/faq-section";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { PageSchema } from "../../components/page-schema";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
import { storeAuthTokens } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
@ -18,20 +16,137 @@ type ApiResponse<T> = {
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type AuthData = { user: { id: string; email: string }; token: string };
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next") ?? "/app";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpToken, setTotpToken] = useState("");
|
||||
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Signing in...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Login failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.data.requiresTwoFactor) {
|
||||
setRequiresTwoFactor(true);
|
||||
setStatus("Enter the code from your authenticator app.");
|
||||
return;
|
||||
}
|
||||
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus(`Welcome back, ${payload.data.user.email}`);
|
||||
router.push(nextPath);
|
||||
} catch {
|
||||
setStatus("Login failed. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password" name="password" type="password" autoComplete="current-password" required
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||
Authenticator Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
|
||||
placeholder="6-digit code" autoComplete="one-time-code" required
|
||||
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\D/g, ""))}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
{requiresTwoFactor ? "Verify" : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
|
||||
<p className="text-sm font-medium text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const schema = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "LedgerOne Login",
|
||||
description: "Sign in to LedgerOne to access your audit-ready ledger.",
|
||||
url: `${siteInfo.url}/login`
|
||||
url: `${siteInfo.url}/login`,
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
@ -39,41 +154,16 @@ export default function LoginPage() {
|
||||
mainEntity: defaultFaqs.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||
}))
|
||||
}
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Signing in...");
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Login failed.");
|
||||
return;
|
||||
}
|
||||
setStatus(`Welcome back, ${payload.data.user.email}`);
|
||||
localStorage.setItem("ledgerone_token", payload.data.token);
|
||||
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
|
||||
router.push("/app");
|
||||
} catch {
|
||||
setStatus("Login failed.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
@ -90,69 +180,14 @@ export default function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteFooter />
|
||||
<PageSchema schema={schema} />
|
||||
</div>
|
||||
|
||||
@ -3,14 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, FormEvent } from "react";
|
||||
import { ContactSection } from "../../components/contact-section";
|
||||
import { DemoCta } from "../../components/demo-cta";
|
||||
import { FaqSection } from "../../components/faq-section";
|
||||
import { PageSchema } from "../../components/page-schema";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
import { storeAuthTokens } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
@ -18,20 +16,27 @@ type ApiResponse<T> = {
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type AuthData = { user: { id: string; email: string }; token: string };
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const schema = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "LedgerOne Create Account",
|
||||
description: "Create a LedgerOne account and start with two free accounts.",
|
||||
url: `${siteInfo.url}/register`
|
||||
url: `${siteInfo.url}/register`,
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
@ -39,41 +44,45 @@ export default function RegisterPage() {
|
||||
mainEntity: defaultFaqs.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer }
|
||||
}))
|
||||
}
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Creating account...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Registration failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus(`Welcome, ${payload.data.user.email}`);
|
||||
localStorage.setItem("ledgerone_token", payload.data.token);
|
||||
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
|
||||
router.push("/login");
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus("Account created! Please verify your email.");
|
||||
router.push("/app");
|
||||
} catch {
|
||||
setStatus("Registration failed.");
|
||||
setStatus("Registration failed. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
@ -90,7 +99,6 @@ export default function RegisterPage() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
@ -100,43 +108,30 @@ export default function RegisterPage() {
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
id="password" name="password" type="password" autoComplete="new-password" required
|
||||
minLength={8}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<input
|
||||
id="terms"
|
||||
name="terms"
|
||||
type="checkbox"
|
||||
required
|
||||
id="terms" name="terms" type="checkbox" required
|
||||
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
|
||||
onChange={(e) => {
|
||||
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
|
||||
@ -144,34 +139,28 @@ export default function RegisterPage() {
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-muted-foreground">
|
||||
I agree to the <Link href="/terms" className="text-primary hover:underline">Terms of Service</Link> and <Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
|
||||
I agree to the{" "}
|
||||
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="submit-btn"
|
||||
type="submit"
|
||||
disabled
|
||||
id="submit-btn" type="submit" disabled
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{status && (
|
||||
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
|
||||
<p className="text-sm font-medium text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteFooter />
|
||||
<PageSchema schema={schema} />
|
||||
</div>
|
||||
|
||||
147
app/reset-password/page.tsx
Normal file
147
app/reset-password/page.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setStatus("Passwords do not match.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setStatus("Password must be at least 8 characters.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
setStatus("Missing reset token. Please use the link from your email.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Resetting password...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<{ message: string }>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Password reset successfully! Redirecting to sign in...");
|
||||
setTimeout(() => router.push("/login"), 2000);
|
||||
} catch {
|
||||
setStatus("Something went wrong. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
|
||||
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
New Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="confirm" name="confirm" type="password" autoComplete="new-password" required
|
||||
value={confirm} onChange={(e) => setConfirm(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
{status && (
|
||||
<div className={`rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
|
||||
<p className="text-sm text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
app/settings/2fa/page.tsx
Normal file
220
app/settings/2fa/page.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
|
||||
type UserData = { user: { twoFactorEnabled: boolean } };
|
||||
|
||||
export default function TwoFAPage() {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [qrCode, setQrCode] = useState<string>("");
|
||||
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<UserData["user"]>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data) {
|
||||
// me returns { user: {...} }
|
||||
const data = res.data as unknown as UserData;
|
||||
setEnabled(data.user?.twoFactorEnabled ?? false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setStatus("Generating QR code...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setQrCode(res.data.qrCode);
|
||||
setOtpAuthUrl(res.data.otpAuthUrl);
|
||||
setStep("scan");
|
||||
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
|
||||
};
|
||||
|
||||
const handleEnable = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token || token.length !== 6) {
|
||||
setStatus("Please enter the 6-digit code.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Verifying...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Verification failed. Try again.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setEnabled(true);
|
||||
setStep("done");
|
||||
setStatus("Two-factor authentication is now active.");
|
||||
};
|
||||
|
||||
const handleDisable = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token || token.length !== 6) {
|
||||
setStatus("Please enter the 6-digit code to confirm.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Disabling 2FA...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
setToken("");
|
||||
setStep("idle");
|
||||
setStatus("Two-factor authentication has been disabled.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
|
||||
<div className="max-w-lg">
|
||||
<div className="glass-panel rounded-2xl p-8">
|
||||
{enabled === null ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : enabled ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">2FA is Active</p>
|
||||
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
To disable two-factor authentication, enter the current code from your authenticator app.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleDisable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
|
||||
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
|
||||
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : step === "idle" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
|
||||
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Enable Two-Factor Auth
|
||||
</button>
|
||||
</>
|
||||
) : step === "scan" ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Open your authenticator app and scan the code below, or enter the key manually.
|
||||
</p>
|
||||
{qrCode && (
|
||||
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
|
||||
</div>
|
||||
)}
|
||||
{otpAuthUrl && (
|
||||
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
|
||||
)}
|
||||
<form onSubmit={handleEnable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
|
||||
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
|
||||
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
|
||||
required autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Verify and Enable
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-foreground">2FA Enabled Successfully</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -5,19 +5,24 @@ const settingsItems = [
|
||||
{
|
||||
title: "Profile",
|
||||
description: "Update company details, contact info, and onboarding fields.",
|
||||
href: "/settings/profile"
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
title: "Two-Factor Auth",
|
||||
description: "Add a TOTP authenticator app for extra security.",
|
||||
href: "/settings/2fa",
|
||||
},
|
||||
{
|
||||
title: "Subscription",
|
||||
description: "View plan details, upgrade options, and billing cadence.",
|
||||
href: "/settings/subscription"
|
||||
}
|
||||
href: "/settings/subscription",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{settingsItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
|
||||
@ -1,3 +1,149 @@
|
||||
import ProfilePage from "../../profile/page";
|
||||
"use client";
|
||||
|
||||
export default ProfilePage;
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ProfileData = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
companyName?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [addressLine1, setAddressLine1] = useState("");
|
||||
const [addressLine2, setAddressLine2] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ProfileData>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data) {
|
||||
const u = (res.data as unknown as ProfileData).user;
|
||||
if (u) {
|
||||
setFullName(u.fullName ?? "");
|
||||
setPhone(u.phone ?? "");
|
||||
setCompanyName(u.companyName ?? "");
|
||||
setAddressLine1(u.addressLine1 ?? "");
|
||||
setAddressLine2(u.addressLine2 ?? "");
|
||||
setCity(u.city ?? "");
|
||||
setState(u.state ?? "");
|
||||
setPostalCode(u.postalCode ?? "");
|
||||
setCountry(u.country ?? "");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Saving profile...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<ProfileData>("/api/auth/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
fullName: fullName || undefined,
|
||||
phone: phone || undefined,
|
||||
companyName: companyName || undefined,
|
||||
addressLine1: addressLine1 || undefined,
|
||||
addressLine2: addressLine2 || undefined,
|
||||
city: city || undefined,
|
||||
state: state || undefined,
|
||||
postalCode: postalCode || undefined,
|
||||
country: country || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Profile update failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Profile saved successfully.");
|
||||
};
|
||||
|
||||
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
|
||||
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
||||
<div className="max-w-2xl">
|
||||
<div className="glass-panel p-8 rounded-2xl">
|
||||
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
These details appear on tax exports and CSV reports.
|
||||
</p>
|
||||
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Full name</label>
|
||||
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Phone</label>
|
||||
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Company</label>
|
||||
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Address line 1</label>
|
||||
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Address line 2</label>
|
||||
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>City</label>
|
||||
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>State</label>
|
||||
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Postal code</label>
|
||||
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Country</label>
|
||||
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{status && (
|
||||
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,91 +1,164 @@
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
"use client";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
cadence: "forever",
|
||||
highlight: "Connect up to 2 accounts",
|
||||
features: [
|
||||
"2 connected accounts",
|
||||
"30-day transaction history",
|
||||
"Basic exports",
|
||||
"Email support"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Pro Monthly",
|
||||
price: "$9",
|
||||
cadence: "per month",
|
||||
highlight: "Unlimited connected accounts",
|
||||
features: [
|
||||
"Unlimited accounts",
|
||||
"12-month history",
|
||||
"Advanced exports + rules",
|
||||
"Priority support"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Pro Annual",
|
||||
price: "$90",
|
||||
cadence: "per year",
|
||||
highlight: "Two months free",
|
||||
features: [
|
||||
"Unlimited accounts",
|
||||
"12-month history",
|
||||
"Advanced exports + rules",
|
||||
"Priority support"
|
||||
]
|
||||
}
|
||||
];
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
billingCycleAnchor?: number;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "Free",
|
||||
pro: "Pro",
|
||||
elite: "Elite",
|
||||
};
|
||||
|
||||
const PLAN_DESCRIPTIONS: Record<string, string> = {
|
||||
free: "Up to 2 accounts, basic CSV export, 30-day history.",
|
||||
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
|
||||
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
|
||||
};
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
return (
|
||||
<AppShell title="Subscription" subtitle="Choose a plan that fits your team.">
|
||||
<div className="app-card p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">Current plan</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Free Plan</h2>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
Free includes up to two connected accounts. Upgrade any time to unlock
|
||||
unlimited accounts and advanced automation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-button rounded-full px-5 py-2 text-xs font-semibold"
|
||||
>
|
||||
Start free trial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
const [sub, setSub] = useState<SubscriptionData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionStatus, setActionStatus] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.name} className="app-card p-6">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-muted">{plan.name}</p>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">{plan.price}</span>
|
||||
<span className="text-xs text-muted">{plan.cadence}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted">{plan.highlight}</p>
|
||||
<ul className="mt-4 space-y-2 text-xs text-muted">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
useEffect(() => {
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error) setSub(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: string) => {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to checkout...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
|
||||
cancelUrl: `${appUrl}/settings/subscription`,
|
||||
}),
|
||||
});
|
||||
setActionLoading(false);
|
||||
if (res.error || !res.data?.url) {
|
||||
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
|
||||
return;
|
||||
}
|
||||
window.location.href = res.data.url;
|
||||
};
|
||||
|
||||
const handlePortal = async () => {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to billing portal...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
|
||||
});
|
||||
setActionLoading(false);
|
||||
if (res.error || !res.data?.url) {
|
||||
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
|
||||
return;
|
||||
}
|
||||
window.location.href = res.data.url;
|
||||
};
|
||||
|
||||
const currentPlan = sub?.plan ?? "free";
|
||||
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
|
||||
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
|
||||
|
||||
return (
|
||||
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Current plan card */}
|
||||
<div className="glass-panel rounded-2xl p-8">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
|
||||
{loading ? (
|
||||
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
|
||||
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
|
||||
{sub.status}
|
||||
</span>
|
||||
)}
|
||||
{(sub?.status === "active" || currentPlan !== "free") && (
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
|
||||
{sub?.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && currentPlan !== "free" && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full rounded-full border border-ink/10 bg-white/5 px-4 py-2 text-xs font-semibold text-ink"
|
||||
onClick={handlePortal}
|
||||
disabled={actionLoading}
|
||||
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
|
||||
>
|
||||
{plan.name === "Free" ? "Current plan" : "Upgrade"}
|
||||
Manage Billing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upgrade options */}
|
||||
{currentPlan === "free" && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["pro", "elite"] as const).map((plan) => (
|
||||
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan)}
|
||||
disabled={actionLoading}
|
||||
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to {PLAN_LABELS[plan]}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPlan === "pro" && (
|
||||
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground">Elite</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade("elite")}
|
||||
disabled={actionLoading}
|
||||
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to Elite
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{actionStatus && (
|
||||
<p className="text-sm text-muted-foreground">{actionStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
118
app/verify-email/page.tsx
Normal file
118
app/verify-email/page.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setMessage("No verification token provided.");
|
||||
return;
|
||||
}
|
||||
fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
|
||||
.then((payload) => {
|
||||
if (payload.error) {
|
||||
setStatus("error");
|
||||
setMessage(payload.error.message ?? "Verification failed.");
|
||||
} else {
|
||||
setStatus("success");
|
||||
setMessage(payload.data?.message ?? "Email verified successfully.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("error");
|
||||
setMessage("Something went wrong. Please try again.");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">Email Verified!</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">Verification Failed</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Email Verification
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
|
||||
import { apiFetch, clearAuth, getStoredUser } from "@/lib/api";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/app", label: "Dashboard" },
|
||||
@ -12,28 +13,107 @@ const navItems = [
|
||||
{ href: "/rules", label: "Rules" },
|
||||
{ href: "/exports", label: "Exports" },
|
||||
{ href: "/tax", label: "Tax" },
|
||||
{ href: "/settings", label: "Settings" }
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
emailVerified?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function initials(user: User): string {
|
||||
if (user.fullName) {
|
||||
const parts = user.fullName.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return user.email.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function displayName(user: User): string {
|
||||
return user.fullName?.trim() || user.email;
|
||||
}
|
||||
|
||||
export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(getStoredUser<User>());
|
||||
const [planLabel, setPlanLabel] = useState("Free Plan");
|
||||
|
||||
const onLogout = () => {
|
||||
localStorage.removeItem("ledgerone_token");
|
||||
localStorage.removeItem("ledgerone_user_id");
|
||||
useEffect(() => {
|
||||
// Fetch latest user profile
|
||||
apiFetch<{ user: User }>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data?.user) {
|
||||
setUser(res.data.user);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Fetch subscription plan
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data?.plan) {
|
||||
const p = res.data.plan;
|
||||
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const onLogout = async () => {
|
||||
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
} catch {}
|
||||
clearAuth();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const NavLinks = () => (
|
||||
<>
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
||||
{/* Sidebar */}
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -48,32 +128,65 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
<div className="px-2 py-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||
</div>
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${isActive
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{/* Icons could be added here */}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<NavLinks />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto p-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">
|
||||
AC
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{user ? initials(user) : "?"}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-foreground truncate">Alex Chen</p>
|
||||
<p className="text-xs text-muted-foreground truncate">Pro Plan</p>
|
||||
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar drawer */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden ${
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-1 flex-1">
|
||||
<div className="px-2 py-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||
</div>
|
||||
<NavLinks />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{user ? initials(user) : "?"}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -81,31 +194,46 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-6 lg:px-8">
|
||||
<div>
|
||||
{/* Breadcrumbs or Title */}
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
className="lg:hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
<div className="relative hidden md:block">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CurrencyToggle />
|
||||
<MoodToggle />
|
||||
<button onClick={onLogout} className="text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">
|
||||
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
115
lib/api.ts
Normal file
115
lib/api.ts
Normal file
@ -0,0 +1,115 @@
|
||||
// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "ledgerone_token";
|
||||
const REFRESH_KEY = "ledgerone_refresh_token";
|
||||
const USER_KEY = "ledgerone_user";
|
||||
|
||||
export function getStoredToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function getStoredUser<T = unknown>(): T | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthTokens(data: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: unknown;
|
||||
}): void {
|
||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
|
||||
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
document.cookie = "ledgerone_auth=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
const payload = (await res.json()) as ApiResponse<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>;
|
||||
if (payload.error || !payload.data?.accessToken) {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
|
||||
return payload.data.accessToken;
|
||||
} catch {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
if (
|
||||
options.body &&
|
||||
typeof options.body === "string" &&
|
||||
!headers["Content-Type"]
|
||||
) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
let res = await fetch(path, { ...options, headers });
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (res.status === 401) {
|
||||
const newToken = await tryRefresh();
|
||||
if (newToken) {
|
||||
headers["Authorization"] = `Bearer ${newToken}`;
|
||||
res = await fetch(path, { ...options, headers });
|
||||
} else {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return {
|
||||
data: null as T,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Session expired. Please sign in again." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return res.json() as Promise<ApiResponse<T>>;
|
||||
}
|
||||
73
lib/backend.ts
Normal file
73
lib/backend.ts
Normal file
@ -0,0 +1,73 @@
|
||||
// Server-side proxy helper for Next.js API routes.
|
||||
// Forwards requests to the NestJS backend, including the Bearer token.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
|
||||
export function getBackendUrl(path: string): string {
|
||||
return `${BASE_URL}/api/${path.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
interface ProxyOptions {
|
||||
method?: string;
|
||||
body?: BodyInit | null;
|
||||
extraHeaders?: Record<string, string>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function proxyRequest(
|
||||
req: NextRequest,
|
||||
backendPath: string,
|
||||
options: ProxyOptions = {}
|
||||
): Promise<NextResponse> {
|
||||
const url = new URL(req.url);
|
||||
const search = options.search !== undefined ? options.search : url.search;
|
||||
const targetUrl = `${getBackendUrl(backendPath)}${search}`;
|
||||
|
||||
const method = options.method ?? req.method;
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
|
||||
const headers: Record<string, string> = { ...options.extraHeaders };
|
||||
if (auth) headers["Authorization"] = auth;
|
||||
|
||||
let body: BodyInit | null | undefined = undefined;
|
||||
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
if (options.body !== undefined) {
|
||||
body = options.body;
|
||||
if (contentType) headers["Content-Type"] = contentType;
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
body = await req.formData();
|
||||
// Do not set Content-Type — fetch sets it with boundary automatically
|
||||
} else {
|
||||
body = await req.text();
|
||||
if (contentType) headers["Content-Type"] = contentType;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body ?? undefined,
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new NextResponse(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." },
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
middleware.ts
Normal file
38
middleware.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const PROTECTED_PREFIXES = [
|
||||
"/app",
|
||||
"/transactions",
|
||||
"/rules",
|
||||
"/exports",
|
||||
"/tax",
|
||||
"/settings",
|
||||
"/profile",
|
||||
];
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
const isProtected = PROTECTED_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||
);
|
||||
|
||||
if (!isProtected) return NextResponse.next();
|
||||
|
||||
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
|
||||
const authCookie = req.cookies.get("ledgerone_auth");
|
||||
if (!authCookie?.value) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Run on all routes except Next.js internals and static files
|
||||
"/((?!_next/static|_next/image|favicon\.ico|api/).*)",
|
||||
],
|
||||
};
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@ -1433,6 +1433,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@ -21,7 +21,10 @@
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
240
write-frontend-1-lib.mjs
Normal file
240
write-frontend-1-lib.mjs
Normal file
@ -0,0 +1,240 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("lib", { recursive: true });
|
||||
|
||||
// ─── lib/api.ts ─────────────────────────────────────────────────────────────
|
||||
writeFileSync("lib/api.ts", `// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "ledgerone_token";
|
||||
const REFRESH_KEY = "ledgerone_refresh_token";
|
||||
const USER_KEY = "ledgerone_user";
|
||||
|
||||
export function getStoredToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function getStoredUser<T = unknown>(): T | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthTokens(data: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: unknown;
|
||||
}): void {
|
||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
|
||||
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
document.cookie = "ledgerone_auth=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
const payload = (await res.json()) as ApiResponse<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>;
|
||||
if (payload.error || !payload.data?.accessToken) {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
|
||||
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
|
||||
return payload.data.accessToken;
|
||||
} catch {
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = \`Bearer \${token}\`;
|
||||
if (
|
||||
options.body &&
|
||||
typeof options.body === "string" &&
|
||||
!headers["Content-Type"]
|
||||
) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
let res = await fetch(path, { ...options, headers });
|
||||
|
||||
// Auto-refresh on 401
|
||||
if (res.status === 401) {
|
||||
const newToken = await tryRefresh();
|
||||
if (newToken) {
|
||||
headers["Authorization"] = \`Bearer \${newToken}\`;
|
||||
res = await fetch(path, { ...options, headers });
|
||||
} else {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return {
|
||||
data: null as T,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Session expired. Please sign in again." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return res.json() as Promise<ApiResponse<T>>;
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── lib/backend.ts ──────────────────────────────────────────────────────────
|
||||
writeFileSync("lib/backend.ts", `// Server-side proxy helper for Next.js API routes.
|
||||
// Forwards requests to the NestJS backend, including the Bearer token.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
|
||||
|
||||
export function getBackendUrl(path: string): string {
|
||||
return \`\${BASE_URL}/api/\${path.replace(/^\\//, "")}\`;
|
||||
}
|
||||
|
||||
interface ProxyOptions {
|
||||
method?: string;
|
||||
body?: BodyInit | null;
|
||||
extraHeaders?: Record<string, string>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function proxyRequest(
|
||||
req: NextRequest,
|
||||
backendPath: string,
|
||||
options: ProxyOptions = {}
|
||||
): Promise<NextResponse> {
|
||||
const url = new URL(req.url);
|
||||
const search = options.search !== undefined ? options.search : url.search;
|
||||
const targetUrl = \`\${getBackendUrl(backendPath)}\${search}\`;
|
||||
|
||||
const method = options.method ?? req.method;
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
|
||||
const headers: Record<string, string> = { ...options.extraHeaders };
|
||||
if (auth) headers["Authorization"] = auth;
|
||||
|
||||
let body: BodyInit | null | undefined = undefined;
|
||||
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
if (options.body !== undefined) {
|
||||
body = options.body;
|
||||
if (contentType) headers["Content-Type"] = contentType;
|
||||
} else if (contentType.includes("multipart/form-data")) {
|
||||
body = await req.formData();
|
||||
// Do not set Content-Type — fetch sets it with boundary automatically
|
||||
} else {
|
||||
body = await req.text();
|
||||
if (contentType) headers["Content-Type"] = contentType;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body ?? undefined,
|
||||
});
|
||||
const payload = await res.text();
|
||||
return new NextResponse(payload, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
meta: { timestamp: new Date().toISOString(), version: "v1" },
|
||||
error: { message: "Backend unavailable." },
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── middleware.ts ───────────────────────────────────────────────────────────
|
||||
writeFileSync("middleware.ts", `import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const PROTECTED_PREFIXES = [
|
||||
"/app",
|
||||
"/transactions",
|
||||
"/rules",
|
||||
"/exports",
|
||||
"/tax",
|
||||
"/settings",
|
||||
"/profile",
|
||||
];
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
const isProtected = PROTECTED_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(\`\${prefix}/\`)
|
||||
);
|
||||
|
||||
if (!isProtected) return NextResponse.next();
|
||||
|
||||
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
|
||||
const authCookie = req.cookies.get("ledgerone_auth");
|
||||
if (!authCookie?.value) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Run on all routes except Next.js internals and static files
|
||||
"/((?!_next/static|_next/image|favicon\\.ico|api/).*)",
|
||||
],
|
||||
};
|
||||
`);
|
||||
|
||||
console.log("✅ lib/api.ts, lib/backend.ts, middleware.ts written");
|
||||
764
write-frontend-2-auth.mjs
Normal file
764
write-frontend-2-auth.mjs
Normal file
@ -0,0 +1,764 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
// ─── Updated login/page.tsx ──────────────────────────────────────────────────
|
||||
writeFileSync("app/login/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { PageSchema } from "../../components/page-schema";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
import { storeAuthTokens } from "../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
requiresTwoFactor?: boolean;
|
||||
};
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next") ?? "/app";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpToken, setTotpToken] = useState("");
|
||||
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Signing in...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Login failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.data.requiresTwoFactor) {
|
||||
setRequiresTwoFactor(true);
|
||||
setStatus("Enter the code from your authenticator app.");
|
||||
return;
|
||||
}
|
||||
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus(\`Welcome back, \${payload.data.user.email}\`);
|
||||
router.push(nextPath);
|
||||
} catch {
|
||||
setStatus("Login failed. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password" name="password" type="password" autoComplete="current-password" required
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresTwoFactor && (
|
||||
<div>
|
||||
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
|
||||
Authenticator Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
|
||||
placeholder="6-digit code" autoComplete="one-time-code" required
|
||||
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\\D/g, ""))}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
{requiresTwoFactor ? "Verify" : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
|
||||
<p className="text-sm font-medium text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const schema = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "LedgerOne Login",
|
||||
description: "Sign in to LedgerOne to access your audit-ready ledger.",
|
||||
url: \`\${siteInfo.url}/login\`,
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: defaultFaqs.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Or{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
start your 14-day free trial
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
<PageSchema schema={schema} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── Updated register/page.tsx ───────────────────────────────────────────────
|
||||
writeFileSync("app/register/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, FormEvent } from "react";
|
||||
import { PageSchema } from "../../components/page-schema";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
import { defaultFaqs } from "../../data/faq";
|
||||
import { siteInfo } from "../../data/site";
|
||||
import { storeAuthTokens } from "../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type AuthData = {
|
||||
user: { id: string; email: string; fullName?: string };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const schema = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
name: "LedgerOne Create Account",
|
||||
description: "Create a LedgerOne account and start with two free accounts.",
|
||||
url: \`\${siteInfo.url}/register\`,
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: defaultFaqs.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Creating account...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<AuthData>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Registration failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
storeAuthTokens({
|
||||
accessToken: payload.data.accessToken,
|
||||
refreshToken: payload.data.refreshToken,
|
||||
user: payload.data.user,
|
||||
});
|
||||
setStatus("Account created! Please verify your email.");
|
||||
router.push("/app");
|
||||
} catch {
|
||||
setStatus("Registration failed. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password" name="password" type="password" autoComplete="new-password" required
|
||||
minLength={8}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<input
|
||||
id="terms" name="terms" type="checkbox" required
|
||||
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
|
||||
onChange={(e) => {
|
||||
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
|
||||
if (btn) btn.disabled = !e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-muted-foreground">
|
||||
I agree to the{" "}
|
||||
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
id="submit-btn" type="submit" disabled
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{status && (
|
||||
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
|
||||
<p className="text-sm font-medium text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
<PageSchema schema={schema} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── New forgot-password/page.tsx ────────────────────────────────────────────
|
||||
mkdirSync("app/forgot-password", { recursive: true });
|
||||
writeFileSync("app/forgot-password/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Sending reset link...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<{ message: string }>;
|
||||
if (!res.ok && payload.error) {
|
||||
setStatus(payload.error.message ?? "Something went wrong.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
|
||||
} catch {
|
||||
setStatus("Something went wrong. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
{sent ? (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-foreground font-medium">{status}</p>
|
||||
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
|
||||
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
We'll send a reset link to this address if it has an account.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email" name="email" type="email" autoComplete="email" required
|
||||
value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Send reset link
|
||||
</button>
|
||||
{isError && status && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
|
||||
<p className="text-sm text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── New verify-email/page.tsx ───────────────────────────────────────────────
|
||||
mkdirSync("app/verify-email", { recursive: true });
|
||||
writeFileSync("app/verify-email/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setMessage("No verification token provided.");
|
||||
return;
|
||||
}
|
||||
fetch(\`/api/auth/verify-email?token=\${encodeURIComponent(token)}\`)
|
||||
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
|
||||
.then((payload) => {
|
||||
if (payload.error) {
|
||||
setStatus("error");
|
||||
setMessage(payload.error.message ?? "Verification failed.");
|
||||
} else {
|
||||
setStatus("success");
|
||||
setMessage(payload.data?.message ?? "Email verified successfully.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("error");
|
||||
setMessage("Something went wrong. Please try again.");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">Email Verified!</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">Verification Failed</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Email Verification
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── New reset-password/page.tsx ─────────────────────────────────────────────
|
||||
mkdirSync("app/reset-password", { recursive: true });
|
||||
writeFileSync("app/reset-password/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { SiteFooter } from "../../components/site-footer";
|
||||
import { SiteHeader } from "../../components/site-header";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setStatus("Passwords do not match.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setStatus("Password must be at least 8 characters.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
setStatus("Missing reset token. Please use the link from your email.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Resetting password...");
|
||||
setIsError(false);
|
||||
try {
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<{ message: string }>;
|
||||
if (!res.ok || payload.error) {
|
||||
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Password reset successfully! Redirecting to sign in...");
|
||||
setTimeout(() => router.push("/login"), 2000);
|
||||
} catch {
|
||||
setStatus("Something went wrong. Please try again.");
|
||||
setIsError(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
|
||||
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
New Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="confirm" name="confirm" type="password" autoComplete="new-password" required
|
||||
value={confirm} onChange={(e) => setConfirm(e.target.value)}
|
||||
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
{status && (
|
||||
<div className={\`rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
|
||||
<p className="text-sm text-center">{status}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans text-foreground">
|
||||
<SiteHeader />
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
|
||||
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
|
||||
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ login, register, forgot-password, verify-email, reset-password pages written");
|
||||
370
write-frontend-3-proxies.mjs
Normal file
370
write-frontend-3-proxies.mjs
Normal file
@ -0,0 +1,370 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
// Helper: write a simple proxy route file
|
||||
function writeProxy(path, content) {
|
||||
mkdirSync(path.replace(/\/[^/]+$/, ""), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
// Reusable proxy template using proxyRequest
|
||||
const proxyTemplate = (methods, backendPath, opts = "") => {
|
||||
const lines = [];
|
||||
lines.push(`import { NextRequest } from "next/server";`);
|
||||
lines.push(`import { proxyRequest } from "../../../../lib/backend";`);
|
||||
lines.push(``);
|
||||
for (const m of methods) {
|
||||
lines.push(`export async function ${m}(req: NextRequest) {`);
|
||||
lines.push(` return proxyRequest(req, "${backendPath}"${opts ? `, ${opts}` : ""});`);
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
// ─── Updated existing proxies ────────────────────────────────────────────────
|
||||
|
||||
// auth/login — public, no bearer needed
|
||||
writeProxy("app/api/auth/login/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/login");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/register — public
|
||||
writeProxy("app/api/auth/register/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/register");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/profile — needs bearer
|
||||
writeProxy("app/api/auth/profile/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/profile");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/route.ts
|
||||
writeProxy("app/api/transactions/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/summary
|
||||
writeProxy("app/api/transactions/summary/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/summary");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/cashflow
|
||||
writeProxy("app/api/transactions/cashflow/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/cashflow");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/merchants
|
||||
writeProxy("app/api/transactions/merchants/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/merchants");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/sync
|
||||
writeProxy("app/api/transactions/sync/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/sync");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/manual
|
||||
writeProxy("app/api/transactions/manual/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/manual");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/[id]/derived
|
||||
writeProxy("app/api/transactions/[id]/derived/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../../lib/backend";
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, \`transactions/\${params.id}/derived\`);
|
||||
}
|
||||
`);
|
||||
|
||||
// accounts/route.ts
|
||||
writeProxy("app/api/accounts/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts");
|
||||
}
|
||||
`);
|
||||
|
||||
// accounts/link
|
||||
writeProxy("app/api/accounts/link/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/link-token");
|
||||
}
|
||||
`);
|
||||
|
||||
// accounts/manual
|
||||
writeProxy("app/api/accounts/manual/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "accounts/manual");
|
||||
}
|
||||
`);
|
||||
|
||||
// rules/route.ts
|
||||
writeProxy("app/api/rules/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "rules");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "rules");
|
||||
}
|
||||
`);
|
||||
|
||||
// rules/suggestions
|
||||
writeProxy("app/api/rules/suggestions/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "rules/suggestions");
|
||||
}
|
||||
`);
|
||||
|
||||
// exports/csv
|
||||
writeProxy("app/api/exports/csv/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/csv");
|
||||
}
|
||||
`);
|
||||
|
||||
// tax/returns
|
||||
writeProxy("app/api/tax/returns/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "tax/returns");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "tax/returns");
|
||||
}
|
||||
`);
|
||||
|
||||
// tax/returns/[id]/export
|
||||
writeProxy("app/api/tax/returns/[id]/export/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../../lib/backend";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
return proxyRequest(req, \`tax/returns/\${params.id}/export\`);
|
||||
}
|
||||
`);
|
||||
|
||||
// plaid/link-token
|
||||
writeProxy("app/api/plaid/link-token/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/link-token");
|
||||
}
|
||||
`);
|
||||
|
||||
// plaid/exchange
|
||||
writeProxy("app/api/plaid/exchange/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "plaid/exchange");
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── New proxy routes ────────────────────────────────────────────────────────
|
||||
|
||||
// auth/refresh
|
||||
mkdirSync("app/api/auth/refresh", { recursive: true });
|
||||
writeFileSync("app/api/auth/refresh/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/refresh");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/logout
|
||||
mkdirSync("app/api/auth/logout", { recursive: true });
|
||||
writeFileSync("app/api/auth/logout/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/logout");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/me
|
||||
mkdirSync("app/api/auth/me", { recursive: true });
|
||||
writeFileSync("app/api/auth/me/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/me");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/verify-email
|
||||
mkdirSync("app/api/auth/verify-email", { recursive: true });
|
||||
writeFileSync("app/api/auth/verify-email/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/verify-email");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/forgot-password
|
||||
mkdirSync("app/api/auth/forgot-password", { recursive: true });
|
||||
writeFileSync("app/api/auth/forgot-password/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/forgot-password");
|
||||
}
|
||||
`);
|
||||
|
||||
// auth/reset-password
|
||||
mkdirSync("app/api/auth/reset-password", { recursive: true });
|
||||
writeFileSync("app/api/auth/reset-password/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "auth/reset-password");
|
||||
}
|
||||
`);
|
||||
|
||||
// 2fa/generate
|
||||
mkdirSync("app/api/2fa/generate", { recursive: true });
|
||||
writeFileSync("app/api/2fa/generate/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/generate");
|
||||
}
|
||||
`);
|
||||
|
||||
// 2fa/enable
|
||||
mkdirSync("app/api/2fa/enable", { recursive: true });
|
||||
writeFileSync("app/api/2fa/enable/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/enable");
|
||||
}
|
||||
`);
|
||||
|
||||
// 2fa/disable
|
||||
mkdirSync("app/api/2fa/disable", { recursive: true });
|
||||
writeFileSync("app/api/2fa/disable/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return proxyRequest(req, "2fa/disable");
|
||||
}
|
||||
`);
|
||||
|
||||
// stripe/subscription
|
||||
mkdirSync("app/api/stripe/subscription", { recursive: true });
|
||||
writeFileSync("app/api/stripe/subscription/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/subscription");
|
||||
}
|
||||
`);
|
||||
|
||||
// stripe/checkout
|
||||
mkdirSync("app/api/stripe/checkout", { recursive: true });
|
||||
writeFileSync("app/api/stripe/checkout/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/checkout");
|
||||
}
|
||||
`);
|
||||
|
||||
// stripe/portal
|
||||
mkdirSync("app/api/stripe/portal", { recursive: true });
|
||||
writeFileSync("app/api/stripe/portal/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "stripe/portal");
|
||||
}
|
||||
`);
|
||||
|
||||
// transactions/import (multipart CSV upload)
|
||||
mkdirSync("app/api/transactions/import", { recursive: true });
|
||||
writeFileSync("app/api/transactions/import/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "transactions/import");
|
||||
}
|
||||
`);
|
||||
|
||||
// exports/sheets
|
||||
mkdirSync("app/api/exports/sheets", { recursive: true });
|
||||
writeFileSync("app/api/exports/sheets/route.ts", `import { NextRequest } from "next/server";
|
||||
import { proxyRequest } from "../../../../../lib/backend";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyRequest(req, "exports/sheets");
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ All API proxy routes written (existing updated + new created)");
|
||||
249
write-frontend-4-appshell.mjs
Normal file
249
write-frontend-4-appshell.mjs
Normal file
@ -0,0 +1,249 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
writeFileSync("components/app-shell.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
|
||||
import { apiFetch, clearAuth, getStoredUser } from "../lib/api";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/app", label: "Dashboard" },
|
||||
{ href: "/app/connect", label: "Accounts" },
|
||||
{ href: "/transactions", label: "Transactions" },
|
||||
{ href: "/rules", label: "Rules" },
|
||||
{ href: "/exports", label: "Exports" },
|
||||
{ href: "/tax", label: "Tax" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
emailVerified?: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function initials(user: User): string {
|
||||
if (user.fullName) {
|
||||
const parts = user.fullName.trim().split(/\\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return user.email.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function displayName(user: User): string {
|
||||
return user.fullName?.trim() || user.email;
|
||||
}
|
||||
|
||||
export function AppShell({ title, subtitle, children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(getStoredUser<User>());
|
||||
const [planLabel, setPlanLabel] = useState("Free Plan");
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch latest user profile
|
||||
apiFetch<{ user: User }>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data?.user) {
|
||||
setUser(res.data.user);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Fetch subscription plan
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data?.plan) {
|
||||
const p = res.data.plan;
|
||||
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const onLogout = async () => {
|
||||
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
} catch {}
|
||||
clearAuth();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const NavLinks = () => (
|
||||
<>
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={\`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors \${
|
||||
isActive
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||
}\`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="px-2 py-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||
</div>
|
||||
<NavLinks />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto p-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{user ? initials(user) : "?"}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar drawer */}
|
||||
<aside
|
||||
className={\`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden \${
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}\`}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
|
||||
L1
|
||||
</div>
|
||||
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-1 flex-1">
|
||||
<div className="px-2 py-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
|
||||
</div>
|
||||
<NavLinks />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{user ? initials(user) : "?"}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
className="lg:hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 lg:gap-4">
|
||||
<div className="relative hidden md:block">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
|
||||
/>
|
||||
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CurrencyToggle />
|
||||
<MoodToggle />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ AppShell updated with real user data and mobile nav");
|
||||
594
write-frontend-6-settings.mjs
Normal file
594
write-frontend-6-settings.mjs
Normal file
@ -0,0 +1,594 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
// ─── Update settings/page.tsx to include 2FA link ────────────────────────────
|
||||
writeFileSync("app/settings/page.tsx", `import Link from "next/link";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
|
||||
const settingsItems = [
|
||||
{
|
||||
title: "Profile",
|
||||
description: "Update company details, contact info, and onboarding fields.",
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
title: "Two-Factor Auth",
|
||||
description: "Add a TOTP authenticator app for extra security.",
|
||||
href: "/settings/2fa",
|
||||
},
|
||||
{
|
||||
title: "Subscription",
|
||||
description: "View plan details, upgrade options, and billing cadence.",
|
||||
href: "/settings/subscription",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{settingsItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="glass-panel p-6 rounded-2xl shadow-sm transition-all hover:-translate-y-1 hover:border-primary/50 group"
|
||||
>
|
||||
<p className="text-lg font-bold text-foreground group-hover:text-primary transition-colors">{item.title}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
|
||||
<span className="mt-4 inline-flex rounded-full border border-border bg-secondary/50 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Open
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── settings/2fa/page.tsx ───────────────────────────────────────────────────
|
||||
mkdirSync("app/settings/2fa", { recursive: true });
|
||||
writeFileSync("app/settings/2fa/page.tsx", `"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
|
||||
type UserData = { user: { twoFactorEnabled: boolean } };
|
||||
|
||||
export default function TwoFAPage() {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [qrCode, setQrCode] = useState<string>("");
|
||||
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<UserData["user"]>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data) {
|
||||
// me returns { user: {...} }
|
||||
const data = res.data as unknown as UserData;
|
||||
setEnabled(data.user?.twoFactorEnabled ?? false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setStatus("Generating QR code...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setQrCode(res.data.qrCode);
|
||||
setOtpAuthUrl(res.data.otpAuthUrl);
|
||||
setStep("scan");
|
||||
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
|
||||
};
|
||||
|
||||
const handleEnable = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token || token.length !== 6) {
|
||||
setStatus("Please enter the 6-digit code.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Verifying...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Verification failed. Try again.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setEnabled(true);
|
||||
setStep("done");
|
||||
setStatus("Two-factor authentication is now active.");
|
||||
};
|
||||
|
||||
const handleDisable = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!token || token.length !== 6) {
|
||||
setStatus("Please enter the 6-digit code to confirm.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Disabling 2FA...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
setToken("");
|
||||
setStep("idle");
|
||||
setStatus("Two-factor authentication has been disabled.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
|
||||
<div className="max-w-lg">
|
||||
<div className="glass-panel rounded-2xl p-8">
|
||||
{enabled === null ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : enabled ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">2FA is Active</p>
|
||||
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
To disable two-factor authentication, enter the current code from your authenticator app.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleDisable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
|
||||
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
|
||||
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : step === "idle" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
|
||||
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Enable Two-Factor Auth
|
||||
</button>
|
||||
</>
|
||||
) : step === "scan" ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Open your authenticator app and scan the code below, or enter the key manually.
|
||||
</p>
|
||||
{qrCode && (
|
||||
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
|
||||
</div>
|
||||
)}
|
||||
{otpAuthUrl && (
|
||||
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
|
||||
)}
|
||||
<form onSubmit={handleEnable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
|
||||
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
|
||||
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
|
||||
required autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Verify and Enable
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-foreground">2FA Enabled Successfully</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── settings/subscription/page.tsx ─────────────────────────────────────────
|
||||
mkdirSync("app/settings/subscription", { recursive: true });
|
||||
writeFileSync("app/settings/subscription/page.tsx", `"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
plan?: string;
|
||||
status?: string;
|
||||
billingCycleAnchor?: number;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "Free",
|
||||
pro: "Pro",
|
||||
elite: "Elite",
|
||||
};
|
||||
|
||||
const PLAN_DESCRIPTIONS: Record<string, string> = {
|
||||
free: "Up to 2 accounts, basic CSV export, 30-day history.",
|
||||
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
|
||||
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
|
||||
};
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [sub, setSub] = useState<SubscriptionData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionStatus, setActionStatus] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<SubscriptionData>("/api/stripe/subscription")
|
||||
.then((res) => {
|
||||
if (!res.error) setSub(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = async (plan: string) => {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to checkout...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
successUrl: \`\${appUrl}/settings/subscription?upgraded=1\`,
|
||||
cancelUrl: \`\${appUrl}/settings/subscription\`,
|
||||
}),
|
||||
});
|
||||
setActionLoading(false);
|
||||
if (res.error || !res.data?.url) {
|
||||
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
|
||||
return;
|
||||
}
|
||||
window.location.href = res.data.url;
|
||||
};
|
||||
|
||||
const handlePortal = async () => {
|
||||
setActionLoading(true);
|
||||
setActionStatus("Redirecting to billing portal...");
|
||||
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ returnUrl: \`\${appUrl}/settings/subscription\` }),
|
||||
});
|
||||
setActionLoading(false);
|
||||
if (res.error || !res.data?.url) {
|
||||
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
|
||||
return;
|
||||
}
|
||||
window.location.href = res.data.url;
|
||||
};
|
||||
|
||||
const currentPlan = sub?.plan ?? "free";
|
||||
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
|
||||
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
|
||||
|
||||
return (
|
||||
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Current plan card */}
|
||||
<div className="glass-panel rounded-2xl p-8">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
|
||||
{loading ? (
|
||||
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
|
||||
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
|
||||
{sub.status}
|
||||
</span>
|
||||
)}
|
||||
{(sub?.status === "active" || currentPlan !== "free") && (
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
|
||||
{sub?.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && currentPlan !== "free" && (
|
||||
<button
|
||||
onClick={handlePortal}
|
||||
disabled={actionLoading}
|
||||
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
|
||||
>
|
||||
Manage Billing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upgrade options */}
|
||||
{currentPlan === "free" && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["pro", "elite"] as const).map((plan) => (
|
||||
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan)}
|
||||
disabled={actionLoading}
|
||||
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to {PLAN_LABELS[plan]}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPlan === "pro" && (
|
||||
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
|
||||
<p className="text-lg font-bold text-foreground">Elite</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
|
||||
<button
|
||||
onClick={() => handleUpgrade("elite")}
|
||||
disabled={actionLoading}
|
||||
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
Upgrade to Elite
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionStatus && (
|
||||
<p className="text-sm text-muted-foreground">{actionStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── settings/profile/page.tsx (fix stale class names) ───────────────────────
|
||||
mkdirSync("app/settings/profile", { recursive: true });
|
||||
writeFileSync("app/settings/profile/page.tsx", `"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "../../../components/app-shell";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
|
||||
type ProfileData = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
phone?: string | null;
|
||||
companyName?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [addressLine1, setAddressLine1] = useState("");
|
||||
const [addressLine2, setAddressLine2] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [postalCode, setPostalCode] = useState("");
|
||||
const [country, setCountry] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ProfileData>("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (!res.error && res.data) {
|
||||
const u = (res.data as unknown as ProfileData).user;
|
||||
if (u) {
|
||||
setFullName(u.fullName ?? "");
|
||||
setPhone(u.phone ?? "");
|
||||
setCompanyName(u.companyName ?? "");
|
||||
setAddressLine1(u.addressLine1 ?? "");
|
||||
setAddressLine2(u.addressLine2 ?? "");
|
||||
setCity(u.city ?? "");
|
||||
setState(u.state ?? "");
|
||||
setPostalCode(u.postalCode ?? "");
|
||||
setCountry(u.country ?? "");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setStatus("Saving profile...");
|
||||
setIsError(false);
|
||||
const res = await apiFetch<ProfileData>("/api/auth/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
fullName: fullName || undefined,
|
||||
phone: phone || undefined,
|
||||
companyName: companyName || undefined,
|
||||
addressLine1: addressLine1 || undefined,
|
||||
addressLine2: addressLine2 || undefined,
|
||||
city: city || undefined,
|
||||
state: state || undefined,
|
||||
postalCode: postalCode || undefined,
|
||||
country: country || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Profile update failed.");
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setStatus("Profile saved successfully.");
|
||||
};
|
||||
|
||||
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
|
||||
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
|
||||
<div className="max-w-2xl">
|
||||
<div className="glass-panel p-8 rounded-2xl">
|
||||
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
These details appear on tax exports and CSV reports.
|
||||
</p>
|
||||
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Full name</label>
|
||||
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Phone</label>
|
||||
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Company</label>
|
||||
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Address line 1</label>
|
||||
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Address line 2</label>
|
||||
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>City</label>
|
||||
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>State</label>
|
||||
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Postal code</label>
|
||||
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Country</label>
|
||||
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{status && (
|
||||
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ settings/page.tsx, settings/2fa/page.tsx, settings/subscription/page.tsx, settings/profile/page.tsx written");
|
||||
517
write-frontend-7-transactions.mjs
Normal file
517
write-frontend-7-transactions.mjs
Normal file
@ -0,0 +1,517 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
writeFileSync("app/transactions/page.tsx", `"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch, getStoredToken } from "../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type TransactionRow = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
amount: string;
|
||||
category?: string | null;
|
||||
note?: string | null;
|
||||
status?: string;
|
||||
hidden?: boolean;
|
||||
date: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type Account = {
|
||||
id: string;
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string | null;
|
||||
};
|
||||
|
||||
type ImportResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
||||
const [status, setStatus] = useState("Loading transactions...");
|
||||
const [summary, setSummary] = useState<{
|
||||
total: string; count: number; income?: string; expense?: string; net?: string;
|
||||
} | null>(null);
|
||||
const [datePreset, setDatePreset] = useState("this_month");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [autoSync, setAutoSync] = useState(true);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState("");
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [manualForm, setManualForm] = useState({
|
||||
accountId: "",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
description: "",
|
||||
amount: "",
|
||||
category: "",
|
||||
note: "",
|
||||
});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false });
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: "", endDate: "", minAmount: "", maxAmount: "",
|
||||
category: "", source: "", search: "", includeHidden: false,
|
||||
});
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
setDatePreset(preset);
|
||||
if (preset === "custom") return;
|
||||
const now = new Date();
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
let start = new Date(end);
|
||||
if (preset === "this_month") {
|
||||
start = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
} else if (preset === "last_month") {
|
||||
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
|
||||
end.setDate(0);
|
||||
} else if (preset === "last_6_months") {
|
||||
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
|
||||
} else if (preset === "last_year") {
|
||||
start = new Date(end.getFullYear() - 1, 0, 1);
|
||||
end.setMonth(11, 31);
|
||||
}
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
|
||||
};
|
||||
|
||||
const buildQuery = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("start_date", filters.startDate);
|
||||
if (filters.endDate) params.set("end_date", filters.endDate);
|
||||
if (filters.minAmount) params.set("min_amount", filters.minAmount);
|
||||
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.source) params.set("source", filters.source);
|
||||
if (filters.search) params.set("search", filters.search);
|
||||
if (filters.includeHidden) params.set("include_hidden", "true");
|
||||
return params.toString() ? \`?\${params.toString()}\` : "";
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const query = buildQuery();
|
||||
const res = await apiFetch<TransactionRow[]>(\`/api/transactions\${query}\`);
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Unable to load transactions.");
|
||||
return;
|
||||
}
|
||||
setRows(res.data ?? []);
|
||||
setStatus((res.data ?? []).length ? "" : "No transactions yet.");
|
||||
};
|
||||
|
||||
const loadAccounts = async () => {
|
||||
const res = await apiFetch<Account[]>("/api/accounts");
|
||||
if (!res.error) setAccounts(res.data ?? []);
|
||||
};
|
||||
|
||||
const loadSummary = async () => {
|
||||
const query = buildQuery();
|
||||
const res = await apiFetch<{ total: string; count: number }>(\`/api/transactions/summary\${query}\`);
|
||||
if (!res.error) setSummary(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyPreset("this_month");
|
||||
load();
|
||||
loadSummary();
|
||||
loadAccounts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSync) return;
|
||||
const id = setInterval(() => { onSync(); }, 5 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoSync, filters.startDate, filters.endDate]);
|
||||
|
||||
const onSync = async () => {
|
||||
if (isSyncing) return;
|
||||
setIsSyncing(true);
|
||||
setStatus("Syncing transactions...");
|
||||
const res = await apiFetch<unknown>("/api/transactions/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }),
|
||||
});
|
||||
if (res.error) {
|
||||
setStatus(res.error.message ?? "Sync failed.");
|
||||
setIsSyncing(false);
|
||||
return;
|
||||
}
|
||||
setStatus("Sync complete.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
const onImportCsv = async (file: File) => {
|
||||
setImportLoading(true);
|
||||
setImportStatus("Uploading...");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const token = getStoredToken();
|
||||
try {
|
||||
const res = await fetch("/api/transactions/import", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: \`Bearer \${token}\` } : {},
|
||||
body: formData,
|
||||
});
|
||||
const payload = (await res.json()) as ApiResponse<ImportResult>;
|
||||
if (!res.ok || payload.error) {
|
||||
setImportStatus(payload.error?.message ?? "Import failed.");
|
||||
setImportLoading(false);
|
||||
return;
|
||||
}
|
||||
const r = payload.data;
|
||||
setImportStatus(\`Imported \${r.imported} transaction\${r.imported === 1 ? "" : "s"}, skipped \${r.skipped} duplicate\${r.skipped === 1 ? "" : "s"}.\`);
|
||||
await load();
|
||||
await loadSummary();
|
||||
} catch {
|
||||
setImportStatus("Import failed. Please try again.");
|
||||
}
|
||||
setImportLoading(false);
|
||||
};
|
||||
|
||||
const formatAmount = (value: string) => {
|
||||
const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, ""));
|
||||
if (Number.isNaN(numeric)) return { display: value, tone: "text-foreground" };
|
||||
return {
|
||||
display: numeric < 0 ? \`-\$\${Math.abs(numeric).toFixed(2)}\` : \`\$\${numeric.toFixed(2)}\`,
|
||||
tone: numeric < 0 ? "text-foreground" : "text-primary font-bold",
|
||||
};
|
||||
};
|
||||
|
||||
const onManualCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const amount = Number.parseFloat(manualForm.amount);
|
||||
if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; }
|
||||
setStatus("Saving manual transaction...");
|
||||
const res = await apiFetch<unknown>("/api/transactions/manual", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
accountId: manualForm.accountId || undefined,
|
||||
date: manualForm.date,
|
||||
description: manualForm.description,
|
||||
amount,
|
||||
category: manualForm.category || undefined,
|
||||
note: manualForm.note || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
|
||||
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" }));
|
||||
setShowManual(false);
|
||||
setStatus("Manual transaction saved.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
const startEdit = (row: TransactionRow) => {
|
||||
setEditingId(row.id);
|
||||
setEditForm({ category: row.category ?? "", note: row.note ?? "", hidden: Boolean(row.hidden) });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId) return;
|
||||
setStatus("Saving edits...");
|
||||
const res = await apiFetch<unknown>(\`/api/transactions/\${editingId}/derived\`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
userCategory: editForm.category || undefined,
|
||||
userNotes: editForm.note || undefined,
|
||||
isHidden: editForm.hidden,
|
||||
}),
|
||||
});
|
||||
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
|
||||
setEditingId(null);
|
||||
setStatus("Transaction updated.");
|
||||
await load();
|
||||
await loadSummary();
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm mb-6">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
|
||||
</span>
|
||||
|
||||
<button onClick={onSync} className="ml-auto px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors">
|
||||
{isSyncing ? "Syncing..." : "Sync"}
|
||||
</button>
|
||||
<button onClick={() => setAutoSync((prev) => !prev)} className={\`px-3 py-2 rounded-lg border text-sm font-medium transition-colors \${autoSync ? "bg-primary/10 border-primary/30 text-primary" : "bg-background border-border text-foreground hover:bg-secondary"}\`}>
|
||||
Auto {autoSync ? "On" : "Off"}
|
||||
</button>
|
||||
<button onClick={() => setShowManual((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
||||
{showManual ? "Hide manual" : "Add manual"}
|
||||
</button>
|
||||
<button onClick={() => { setShowImport((prev) => !prev); setImportStatus(""); }} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
||||
{showImport ? "Hide import" : "Import CSV"}
|
||||
</button>
|
||||
<Link href={\`/exports\${buildQuery()}\`} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
||||
Export
|
||||
</Link>
|
||||
<button onClick={() => setShowFilters((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
||||
{showFilters ? "Hide filters" : "Filters"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CSV Import panel */}
|
||||
{showImport && (
|
||||
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
||||
<p className="text-sm font-bold text-foreground mb-1">Import CSV</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.
|
||||
</p>
|
||||
<div
|
||||
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.name.endsWith(".csv")) onImportCsv(file);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImportCsv(f); }}
|
||||
/>
|
||||
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
{importLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Uploading...</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Drop a CSV file here or <span className="text-primary font-medium">click to browse</span></p>
|
||||
)}
|
||||
</div>
|
||||
{importStatus && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual transaction form */}
|
||||
{showManual && (
|
||||
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
||||
<p className="text-sm font-bold text-foreground mb-4">Add Manual Transaction</p>
|
||||
<form onSubmit={onManualCreate} className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelCls}>Date</label>
|
||||
<input type="date" value={manualForm.date} onChange={(e) => setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Description</label>
|
||||
<input type="text" value={manualForm.description} onChange={(e) => setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Amount (negative = expense)</label>
|
||||
<input type="number" step="0.01" value={manualForm.amount} onChange={(e) => setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Category</label>
|
||||
<input type="text" value={manualForm.category} onChange={(e) => setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Account</label>
|
||||
<select value={manualForm.accountId} onChange={(e) => setManualForm((p) => ({ ...p, accountId: e.target.value }))} className={inputCls}>
|
||||
<option value="">— No account —</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.institutionName} {a.mask ? \`••\${a.mask}\` : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Note</label>
|
||||
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div className="md:col-span-3 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
|
||||
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelCls}>Date range</label>
|
||||
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
|
||||
<option value="this_month">This month</option>
|
||||
<option value="last_month">Last month</option>
|
||||
<option value="last_6_months">Last 6 months</option>
|
||||
<option value="last_year">Last year</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Start date</label>
|
||||
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>End date</label>
|
||||
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Search</label>
|
||||
<input type="text" value={filters.search} onChange={(e) => setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Category</label>
|
||||
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Min amount</label>
|
||||
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Max amount</label>
|
||||
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||
Include hidden
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button onClick={() => { load(); loadSummary(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="mb-6 grid gap-3 md:grid-cols-3">
|
||||
{[
|
||||
{ label: "Total", value: \`\$\${Math.abs(Number.parseFloat(summary.total ?? "0")).toFixed(2)}\`, sub: \`\${summary.count} transactions\` },
|
||||
{ label: "Income", value: \`+\$\${Math.abs(Number.parseFloat(summary.income ?? "0")).toFixed(2)}\`, sub: "Credits" },
|
||||
{ label: "Expenses", value: \`-\$\${Math.abs(Number.parseFloat(summary.expense ?? "0")).toFixed(2)}\`, sub: "Debits" },
|
||||
].map((c) => (
|
||||
<div key={c.label} className="glass-panel rounded-xl p-4 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{c.label}</p>
|
||||
<p className="mt-2 text-xl font-bold text-foreground">{c.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{c.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction table */}
|
||||
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
|
||||
{status && (
|
||||
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-muted-foreground">
|
||||
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Description</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3 text-right">Amount</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) =>
|
||||
editingId === row.id ? (
|
||||
<tr key={row.id} className="border-b border-border bg-secondary/10">
|
||||
<td className="px-4 py-3" colSpan={2}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.category}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, category: e.target.value }))}
|
||||
placeholder="Category"
|
||||
className="w-28 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.note}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, note: e.target.value }))}
|
||||
placeholder="Note"
|
||||
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-xs text-foreground">
|
||||
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
|
||||
Hide
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3" colSpan={3}>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
|
||||
<button onClick={() => setEditingId(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={row.id} className={\`border-b border-border hover:bg-secondary/20 transition-colors \${row.hidden ? "opacity-50" : ""}\`}>
|
||||
<td className="px-4 py-3 font-medium whitespace-nowrap">
|
||||
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground font-medium max-w-[200px] truncate">
|
||||
{row.description ?? row.name ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.category ? (
|
||||
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">{row.category}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={\`px-4 py-3 text-right font-bold \${formatAmount(row.amount).tone}\`}>
|
||||
{formatAmount(row.amount).display}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{!rows.length && !status && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No transactions found. Try adjusting your filters or sync your accounts.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ transactions/page.tsx written with CSV import UI and apiFetch");
|
||||
221
write-frontend-8-exports.mjs
Normal file
221
write-frontend-8-exports.mjs
Normal file
@ -0,0 +1,221 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
writeFileSync("app/exports/page.tsx", `"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "../../components/app-shell";
|
||||
import { apiFetch } from "../../lib/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
meta: { timestamp: string; version: "v1" };
|
||||
error: null | { message: string; code?: string };
|
||||
};
|
||||
|
||||
type ExportData = { status: string; csv?: string; rowCount?: number };
|
||||
type SheetsData = { spreadsheetUrl?: string; spreadsheetId?: string; rowCount?: number };
|
||||
|
||||
export default function ExportsPage() {
|
||||
const [csvStatus, setCsvStatus] = useState("");
|
||||
const [sheetsStatus, setSheetsStatus] = useState("");
|
||||
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
|
||||
const [sheetsLoading, setSheetsLoading] = useState(false);
|
||||
const [datePreset, setDatePreset] = useState("custom");
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
category: "",
|
||||
source: "",
|
||||
includeHidden: false,
|
||||
});
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
setDatePreset(preset);
|
||||
if (preset === "custom") return;
|
||||
const now = new Date();
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
let start = new Date(end);
|
||||
if (preset === "this_month") {
|
||||
start = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
} else if (preset === "last_month") {
|
||||
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
|
||||
end.setDate(0);
|
||||
} else if (preset === "last_6_months") {
|
||||
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
|
||||
} else if (preset === "last_year") {
|
||||
start = new Date(end.getFullYear() - 1, 0, 1);
|
||||
end.setMonth(11, 31);
|
||||
}
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
|
||||
};
|
||||
|
||||
const buildParams = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("start_date", filters.startDate);
|
||||
if (filters.endDate) params.set("end_date", filters.endDate);
|
||||
if (filters.minAmount) params.set("min_amount", filters.minAmount);
|
||||
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.source) params.set("source", filters.source);
|
||||
if (filters.includeHidden) params.set("include_hidden", "true");
|
||||
return params;
|
||||
};
|
||||
|
||||
const onExportCsv = async () => {
|
||||
setCsvStatus("Generating export...");
|
||||
const params = buildParams();
|
||||
const query = params.toString() ? \`?\${params.toString()}\` : "";
|
||||
const res = await apiFetch<ExportData>(\`/api/exports/csv\${query}\`);
|
||||
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
|
||||
if (res.data.csv) {
|
||||
const blob = new Blob([res.data.csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = \`ledgerone-export-\${new Date().toISOString().slice(0, 10)}.csv\`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setCsvStatus(\`Export ready (\${res.data.rowCount ?? 0} rows) — file downloaded.\`);
|
||||
} else {
|
||||
setCsvStatus("Export ready.");
|
||||
}
|
||||
};
|
||||
|
||||
const onExportSheets = async () => {
|
||||
setSheetsLoading(true);
|
||||
setSheetsStatus("Creating Google Sheet...");
|
||||
setSheetsUrl(null);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (filters.startDate) body.startDate = filters.startDate;
|
||||
if (filters.endDate) body.endDate = filters.endDate;
|
||||
if (filters.minAmount) body.minAmount = filters.minAmount;
|
||||
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
|
||||
if (filters.category) body.category = filters.category;
|
||||
if (filters.includeHidden) body.includeHidden = true;
|
||||
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setSheetsLoading(false);
|
||||
if (res.error) {
|
||||
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
|
||||
return;
|
||||
}
|
||||
if (res.data.spreadsheetUrl) {
|
||||
setSheetsUrl(res.data.spreadsheetUrl);
|
||||
setSheetsStatus(\`Sheet created with \${res.data.rowCount ?? 0} rows.\`);
|
||||
} else {
|
||||
setSheetsStatus("Sheet created.");
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
||||
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
|
||||
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
|
||||
{/* Date preset selector */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelCls}>Date range</label>
|
||||
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="this_month">This month</option>
|
||||
<option value="last_month">Last month</option>
|
||||
<option value="last_6_months">Last 6 months</option>
|
||||
<option value="last_year">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Start date</label>
|
||||
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>End date</label>
|
||||
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Category contains</label>
|
||||
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Min amount ($)</label>
|
||||
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Max amount ($)</label>
|
||||
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
|
||||
</div>
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
||||
Include hidden transactions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Export actions */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* CSV export */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Download CSV</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportCsv}
|
||||
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
|
||||
</div>
|
||||
|
||||
{/* Google Sheets export */}
|
||||
<div className="rounded-xl border border-border bg-secondary/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Creates a new Google Sheet with a dated tab. Requires Google OAuth to be configured.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExportSheets}
|
||||
disabled={sheetsLoading}
|
||||
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
|
||||
</button>
|
||||
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
|
||||
{sheetsUrl && (
|
||||
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
|
||||
Open Sheet →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("✅ exports/page.tsx written with CSV download + Google Sheets export");
|
||||
Loading…
x
Reference in New Issue
Block a user