Skip to content

Commit 8f3e112

Browse files
committed
Sprint 2: auth, upload, analysis view, dashboard, pricing
Auth (email magic-link via Supabase Auth): - /sign-in page with one-time link form - /auth/callback exchanges the code, ensures profile row, sends Resend welcome email on first sign-in - middleware.ts refreshes Supabase session and gates /upload + /dashboard to authed users (unauthed are redirected to /sign-in?next=...) Upload + analyze: - /api/upload-url issues a Supabase Storage signed upload URL scoped to the user's folder; validates mime + size before signing - /upload page: drag-drop file zone (PDF/JPG/PNG/WEBP/HEIC, 20 MB), locale selector, Turnstile widget (dev mode bypassed when no site key), three-stage progress (upload → analyze → redirect) - Client uploads to Storage, then POSTs storage_path to existing /api/analyze (already gated with quota / rate-limit / Turnstile) Result + history: - /a/[id] renders the analysis: type badge, summary, risks (severity badge per item), questions list, key terms, legal disclaimer - /dashboard lists last 50 documents with status pills and shows free quota remaining + reset date Pricing: - /pricing shows all four tiers (Free, Pro, Power, Lifetime) with feature lists. CTAs disabled with "Coming soon" until Polar wires up. UI primitives (hand-built, matching existing ink/paper/accent palette): - components/ui/{button,input,label,card,badge,skeleton,spinner}.tsx - cn() helper using clsx + tailwind-merge - Header (auth-aware nav) + Footer (Buy Me a Coffee links carried over) Email: - lib/email.ts wraps Resend with a best-effort welcome email template Deps added: @supabase/ssr (already), @vercel/analytics (already), clsx, tailwind-merge, resend, lucide-react. Tests: - lib/__tests__/utils.test.ts covers cn() class merging - 19 tests passing total (was 14) Build now ships routes: /, /[locale], /sign-in, /auth/callback, /upload, /a/[id], /dashboard, /pricing, plus the existing API routes. Local CI green: typecheck, lint, 19 tests, build (13 static pages). https://claude.ai/code/session_01W3qcAmw65K9Dagi9keNp8i
1 parent 8e0752d commit 8f3e112

28 files changed

Lines changed: 1511 additions & 0 deletions

app/a/[id]/page.tsx

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { notFound } from "next/navigation";
2+
import Link from "next/link";
3+
import { Header } from "@/components/header";
4+
import { Footer } from "@/components/footer";
5+
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card";
6+
import { Badge } from "@/components/ui/badge";
7+
import { supabaseServer } from "@/lib/supabase-server";
8+
import { AnalysisSchema } from "@/lib/ai/types";
9+
import type { z } from "zod";
10+
11+
type Risk = z.infer<typeof AnalysisSchema>["risks"][number];
12+
type KeyTerm = z.infer<typeof AnalysisSchema>["key_terms"][number];
13+
14+
const SEVERITY_LABEL: Record<Risk["severity"], string> = {
15+
low: "Low",
16+
medium: "Medium",
17+
high: "High",
18+
};
19+
20+
const DOC_TYPE_LABEL: Record<string, string> = {
21+
lease: "Lease / Rental",
22+
employment_contract: "Employment contract",
23+
nda: "Non-disclosure agreement",
24+
health_insurance: "Health insurance",
25+
auto_insurance: "Auto insurance",
26+
life_insurance: "Life insurance",
27+
medical_bill: "Medical bill",
28+
eob: "Explanation of benefits",
29+
tax_letter: "Tax letter",
30+
mortgage: "Mortgage",
31+
terms_of_service: "Terms of service",
32+
privacy_policy: "Privacy policy",
33+
other: "Document",
34+
};
35+
36+
export default async function AnalysisPage({ params }: { params: { id: string } }) {
37+
const sb = supabaseServer();
38+
const {
39+
data: { user },
40+
} = await sb.auth.getUser();
41+
if (!user) notFound();
42+
43+
const { data: doc } = await sb
44+
.from("documents")
45+
.select("id, status, detected_type, original_filename, mime_type, created_at")
46+
.eq("id", params.id)
47+
.maybeSingle();
48+
49+
if (!doc) notFound();
50+
51+
const { data: row } = await sb
52+
.from("analyses")
53+
.select("summary, risks, questions, key_terms, output_locale, model")
54+
.eq("document_id", params.id)
55+
.maybeSingle();
56+
57+
// Show a "still processing" state if the row isn't ready yet.
58+
if (!row) {
59+
return (
60+
<>
61+
<Header />
62+
<main className="mx-auto max-w-3xl px-6 py-12">
63+
<Card>
64+
<CardBody className="py-16 text-center">
65+
<p className="text-sm text-ink/60">
66+
{doc.status === "failed"
67+
? "This analysis could not be completed."
68+
: "We're still working on this. Refresh in a moment."}
69+
</p>
70+
<Link href="/dashboard" className="mt-4 inline-block text-sm text-accent hover:underline">
71+
Back to dashboard
72+
</Link>
73+
</CardBody>
74+
</Card>
75+
</main>
76+
<Footer />
77+
</>
78+
);
79+
}
80+
81+
const risks = (row.risks as Risk[] | null) ?? [];
82+
const questions = (row.questions as string[] | null) ?? [];
83+
const keyTerms = (row.key_terms as KeyTerm[] | null) ?? [];
84+
const docTypeLabel =
85+
DOC_TYPE_LABEL[doc.detected_type ?? "other"] ?? "Document";
86+
87+
return (
88+
<>
89+
<Header />
90+
<main className="mx-auto max-w-3xl space-y-6 px-6 py-12">
91+
<div className="flex flex-wrap items-end justify-between gap-3">
92+
<div>
93+
<Badge tone="accent">{docTypeLabel}</Badge>
94+
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
95+
{doc.original_filename ?? "Document analysis"}
96+
</h1>
97+
<p className="mt-1 text-sm text-ink/50">
98+
{new Date(doc.created_at).toLocaleString()} · {row.output_locale}
99+
</p>
100+
</div>
101+
<Link
102+
href="/upload"
103+
className="inline-flex h-10 items-center justify-center rounded-xl border border-ink/15 bg-white px-5 text-base font-medium transition hover:border-ink/30"
104+
>
105+
Analyze another
106+
</Link>
107+
</div>
108+
109+
<Card>
110+
<CardHeader>
111+
<CardTitle>Summary</CardTitle>
112+
</CardHeader>
113+
<CardBody className="prose prose-sm max-w-none whitespace-pre-line text-base leading-relaxed text-ink/85">
114+
{row.summary ?? "No summary available."}
115+
</CardBody>
116+
</Card>
117+
118+
{risks.length > 0 && (
119+
<Card>
120+
<CardHeader>
121+
<CardTitle>Risks flagged</CardTitle>
122+
</CardHeader>
123+
<CardBody>
124+
<ul className="space-y-4">
125+
{risks.map((r, i) => (
126+
<li key={i} className="border-l-2 border-ink/10 pl-4">
127+
<div className="flex flex-wrap items-center gap-2">
128+
<Badge tone={r.severity}>{SEVERITY_LABEL[r.severity]}</Badge>
129+
<span className="font-medium">{r.title}</span>
130+
</div>
131+
<p className="mt-1 text-sm leading-relaxed text-ink/75">{r.explanation}</p>
132+
</li>
133+
))}
134+
</ul>
135+
</CardBody>
136+
</Card>
137+
)}
138+
139+
{questions.length > 0 && (
140+
<Card>
141+
<CardHeader>
142+
<CardTitle>Questions to ask before you sign</CardTitle>
143+
</CardHeader>
144+
<CardBody>
145+
<ol className="list-decimal space-y-2 pl-5 text-sm leading-relaxed text-ink/85">
146+
{questions.map((q, i) => (
147+
<li key={i}>{q}</li>
148+
))}
149+
</ol>
150+
</CardBody>
151+
</Card>
152+
)}
153+
154+
{keyTerms.length > 0 && (
155+
<Card>
156+
<CardHeader>
157+
<CardTitle>Key terms in this document</CardTitle>
158+
</CardHeader>
159+
<CardBody>
160+
<dl className="space-y-3 text-sm">
161+
{keyTerms.map((kt, i) => (
162+
<div key={i}>
163+
<dt className="font-medium">{kt.term}</dt>
164+
<dd className="mt-0.5 text-ink/70">{kt.definition}</dd>
165+
</div>
166+
))}
167+
</dl>
168+
</CardBody>
169+
</Card>
170+
)}
171+
172+
<p className="rounded-xl border border-ink/10 bg-ink/[0.02] p-4 text-xs text-ink/60">
173+
PaperLens explains what your document says. It is <strong>not</strong> legal, medical,
174+
tax, or financial advice. For decisions that matter, talk to a qualified professional.
175+
</p>
176+
</main>
177+
<Footer />
178+
</>
179+
);
180+
}

app/api/upload-url/route.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from "next/server";
2+
import { z } from "zod";
3+
import { supabaseServer } from "@/lib/supabase-server";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
const ALLOWED_MIME = new Set([
9+
"application/pdf",
10+
"image/jpeg",
11+
"image/png",
12+
"image/webp",
13+
"image/heic",
14+
"image/heif",
15+
]);
16+
17+
const Body = z.object({
18+
filename: z.string().min(1).max(200),
19+
mime_type: z.string(),
20+
byte_size: z.number().int().positive().max(20 * 1024 * 1024),
21+
});
22+
23+
function safeName(name: string): string {
24+
return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
25+
}
26+
27+
export async function POST(req: Request) {
28+
const sb = supabaseServer();
29+
const {
30+
data: { user },
31+
} = await sb.auth.getUser();
32+
if (!user) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
33+
34+
let body: z.infer<typeof Body>;
35+
try {
36+
body = Body.parse(await req.json());
37+
} catch (err) {
38+
return NextResponse.json({ error: "bad_request", detail: (err as Error).message }, { status: 400 });
39+
}
40+
41+
if (!ALLOWED_MIME.has(body.mime_type)) {
42+
return NextResponse.json({ error: "unsupported_mime" }, { status: 400 });
43+
}
44+
45+
const path = `${user.id}/${Date.now()}-${safeName(body.filename)}`;
46+
const { data, error } = await sb.storage
47+
.from("documents")
48+
.createSignedUploadUrl(path);
49+
if (error || !data) {
50+
return NextResponse.json(
51+
{ error: "signed_url_failed", detail: error?.message },
52+
{ status: 500 },
53+
);
54+
}
55+
56+
return NextResponse.json({
57+
storage_path: path,
58+
signed_url: data.signedUrl,
59+
token: data.token,
60+
});
61+
}

app/auth/callback/route.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NextResponse } from "next/server";
2+
import { supabaseServer, supabaseAdmin } from "@/lib/supabase-server";
3+
import { sendWelcomeEmail } from "@/lib/email";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
export async function GET(req: Request) {
8+
const url = new URL(req.url);
9+
const code = url.searchParams.get("code");
10+
const next = url.searchParams.get("next") ?? "/upload";
11+
12+
if (!code) {
13+
return NextResponse.redirect(new URL("/sign-in?error=missing_code", url.origin));
14+
}
15+
16+
const sb = supabaseServer();
17+
const { error } = await sb.auth.exchangeCodeForSession(code);
18+
if (error) {
19+
return NextResponse.redirect(
20+
new URL(`/sign-in?error=${encodeURIComponent(error.message)}`, url.origin),
21+
);
22+
}
23+
24+
// Ensure a profile row exists for this user; send welcome on first sign-in.
25+
const {
26+
data: { user },
27+
} = await sb.auth.getUser();
28+
if (user) {
29+
const admin = supabaseAdmin();
30+
const { data: existing } = await admin
31+
.from("profiles")
32+
.select("id")
33+
.eq("id", user.id)
34+
.maybeSingle();
35+
if (!existing) {
36+
await admin.from("profiles").insert({
37+
id: user.id,
38+
email: user.email ?? null,
39+
});
40+
if (user.email) {
41+
await sendWelcomeEmail(user.email);
42+
}
43+
}
44+
}
45+
46+
return NextResponse.redirect(new URL(next, url.origin));
47+
}

0 commit comments

Comments
 (0)