@oaklandzoo/ostup 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/scripts/verify-profile.sh +46 -0
  4. package/src/bootstrap.mjs +146 -0
  5. package/src/brief/profile-router.mjs +63 -28
  6. package/templates/START_HERE.md +1 -1
  7. package/templates/profiles/blog/README.md +45 -40
  8. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  9. package/templates/profiles/blog/app/page.tsx +30 -0
  10. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  11. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  12. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  13. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  14. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  15. package/templates/profiles/blog/lib/posts.ts +43 -0
  16. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  17. package/templates/profiles/blog/package.json.additions +6 -0
  18. package/templates/profiles/booking/.env.example.additions +3 -11
  19. package/templates/profiles/booking/README.md +26 -21
  20. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  21. package/templates/profiles/booking/app/page.tsx +38 -0
  22. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  23. package/templates/profiles/booking/components/Hero.tsx +20 -0
  24. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  25. package/templates/profiles/booking/lib/resend.ts +33 -0
  26. package/templates/profiles/booking/lib/storage.ts +44 -0
  27. package/templates/profiles/booking/package.json.additions +5 -0
  28. package/templates/profiles/booking/section-prompts.md +10 -8
  29. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  30. package/templates/profiles/lead-gen/README.md +35 -27
  31. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  32. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  33. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  34. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  35. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  36. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  37. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  38. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  39. package/templates/profiles/lead-gen/package.json.additions +5 -0
  40. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  41. package/templates/profiles/saas-dashboard/README.md +43 -36
  42. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  43. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  44. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  45. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  46. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  47. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  48. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  49. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  50. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  51. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  52. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  53. package/templates/profiles/saas-dashboard/package.json.additions +9 -0
@@ -2,45 +2,43 @@
2
2
 
3
3
  > Service-company site that captures contact requests. Resend for email, optional CRM webhook, local SEO basics.
4
4
 
5
+ ## What this profile ships (v1)
6
+
7
+ The overlay drops a working homepage and a `/api/contact` endpoint into your new project. After `vercel --prod` you have:
8
+
9
+ - A non-blank homepage at `/` with Hero + Services grid + Service-area + Testimonials + FAQ + Contact form + Footer.
10
+ - A POST `/api/contact` route that validates `{name, email, message}` and sends an email via Resend.
11
+ - Optional CRM webhook forwarding (env: `CRM_WEBHOOK_URL`).
12
+ - JSON-LD `LocalBusiness` schema injected via `app/layout.tsx`.
13
+ - Tailwind-utility styling. No design-system lock-in.
14
+
5
15
  ## Day-one scope
6
16
 
7
17
  | Section | Purpose | Required |
8
18
  |---|---|---|
9
- | Hero | Single value statement + primary CTA "Request a quote" or "Contact us" | yes |
10
- | Services | 3-8 service cards or list. Tap-to-call on mobile. | yes |
11
- | Service area | Map placeholder OR text list of cities / zip codes covered | yes |
19
+ | Hero | Single value statement + primary CTA "Get in touch" | yes |
20
+ | Services | 3-8 service cards | yes |
21
+ | Service area | Map placeholder OR text list of cities / zip codes | yes |
12
22
  | Testimonials | 2-4 quotes (placeholders OK if brief has none) | yes |
13
- | FAQ | 4-8 questions, accordion or stacked | yes |
14
- | Contact form | Name, phone, email, message. Posts to /api/contact. | yes |
15
- | Footer | Phone, email, license number if applicable, year | yes |
16
-
17
- ## Wired infrastructure
18
-
19
- - **Resend** for email notifications when a lead submits the form.
20
- - Optional **CRM webhook** (env: `CRM_WEBHOOK_URL`). If set, also POST the lead payload as JSON.
21
- - **JSON-LD LocalBusiness schema** in the head of every page.
22
- - Open Graph + Twitter metadata using `{{DISPLAY_NAME}}` and the brand summary.
23
+ | FAQ | 4-8 questions, `<details>`-based accordion | yes |
24
+ | Contact form | Name, email, message. Posts to `/api/contact`. | yes |
25
+ | Footer | Year + display name | yes |
23
26
 
24
27
  ## Env additions
25
28
 
26
29
  See `.env.example.additions` for the variables this profile expects.
27
30
 
28
- ## API contract for /api/contact
31
+ ## API contract
29
32
 
30
33
  ```
31
34
  POST /api/contact
32
- Content-Type: application/json
33
- Body: { name: string, email: string, phone?: string, message: string }
34
- Response: { ok: boolean, id?: string }
35
+ Body: { name, email, message }
36
+ Response: { ok: true } | { error }
37
+ Behavior: validates input, sends email via Resend to CONTACT_TO_EMAIL,
38
+ optionally POSTs the payload to CRM_WEBHOOK_URL.
35
39
  ```
36
40
 
37
- The route:
38
- 1. Validates required fields (name, email, message).
39
- 2. Sends an email via Resend to `CONTACT_TO_EMAIL` from `CONTACT_FROM_EMAIL`.
40
- 3. If `CRM_WEBHOOK_URL` is set, POSTs the lead payload there.
41
- 4. Returns 200 with `{ ok: true }` on success; 400 on validation; 500 on send failure.
42
-
43
- No persistent storage v1. Leads live in the operator's email inbox.
41
+ If `RESEND_API_KEY` is unset, the route logs a warning and returns `{ok: true, mock: true}` so local dev does not fail.
44
42
 
45
43
  ## Hard rules
46
44
 
@@ -51,8 +49,18 @@ No persistent storage v1. Leads live in the operator's email inbox.
51
49
 
52
50
  ## Acceptance
53
51
 
54
- - Visitor can submit the contact form, sees an inline success state.
55
- - Owner receives the lead by email at the address in `CONTACT_TO_EMAIL`.
56
- - JSON-LD LocalBusiness schema validates at https://search.google.com/test/rich-results.
52
+ - Visitor submits the contact form, sees an inline success state.
53
+ - Owner receives the lead by email at `CONTACT_TO_EMAIL`.
54
+ - JSON-LD `LocalBusiness` schema validates at https://search.google.com/test/rich-results.
57
55
  - Hero CTA above the fold at 420x900.
58
56
  - Lighthouse mobile Performance >= 90, Accessibility >= 95.
57
+
58
+ ## Visual verification
59
+
60
+ After deploy, run:
61
+
62
+ ```bash
63
+ bash scripts/screenshot.sh <your-vercel-url>
64
+ ```
65
+
66
+ Read the resulting PNG to confirm the hero, services grid, and contact form render as expected.
@@ -0,0 +1,49 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { sendEmail } from '@/lib/resend';
3
+
4
+ export async function POST(req: Request) {
5
+ let body: { name?: string; email?: string; message?: string };
6
+ try {
7
+ body = await req.json();
8
+ } catch {
9
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
10
+ }
11
+
12
+ const name = (body.name || '').trim();
13
+ const email = (body.email || '').trim();
14
+ const message = (body.message || '').trim();
15
+
16
+ if (!name || !email || !message) {
17
+ return NextResponse.json({ error: 'name, email, and message are required' }, { status: 400 });
18
+ }
19
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
20
+ return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
21
+ }
22
+
23
+ const to = process.env.CONTACT_TO_EMAIL;
24
+ const from = process.env.RESEND_FROM_EMAIL || 'noreply@example.com';
25
+
26
+ if (to) {
27
+ await sendEmail({
28
+ to,
29
+ from,
30
+ subject: `New inquiry from ${name}`,
31
+ text: `From: ${name} <${email}>\n\n${message}`,
32
+ });
33
+ }
34
+
35
+ const webhookUrl = process.env.CRM_WEBHOOK_URL;
36
+ if (webhookUrl) {
37
+ try {
38
+ await fetch(webhookUrl, {
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({ name, email, message, source: '{{PROJECT_NAME}}' }),
42
+ });
43
+ } catch {
44
+ // best-effort; do not fail the form if the CRM webhook is down
45
+ }
46
+ }
47
+
48
+ return NextResponse.json({ ok: true });
49
+ }
@@ -0,0 +1,29 @@
1
+ import type { Metadata } from 'next';
2
+ import './globals.css';
3
+
4
+ export const metadata: Metadata = {
5
+ title: '{{DISPLAY_NAME}}',
6
+ description: '{{PROJECT_PURPOSE_ONE_SENTENCE}}',
7
+ };
8
+
9
+ const localBusinessSchema = {
10
+ '@context': 'https://schema.org',
11
+ '@type': 'LocalBusiness',
12
+ name: '{{DISPLAY_NAME}}',
13
+ description: '{{PROJECT_PURPOSE_ONE_SENTENCE}}',
14
+ url: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
15
+ };
16
+
17
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
18
+ return (
19
+ <html lang="en">
20
+ <head>
21
+ <script
22
+ type="application/ld+json"
23
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessSchema) }}
24
+ />
25
+ </head>
26
+ <body className="bg-white text-slate-900 antialiased">{children}</body>
27
+ </html>
28
+ );
29
+ }
@@ -0,0 +1,79 @@
1
+ import Hero from '@/components/Hero';
2
+ import ServiceCard from '@/components/ServiceCard';
3
+ import ContactForm from '@/components/ContactForm';
4
+ import FAQ from '@/components/FAQ';
5
+
6
+ const services = [
7
+ { title: 'Service one', description: 'What you do best, in one sentence.' },
8
+ { title: 'Service two', description: 'A second offering customers ask for.' },
9
+ { title: 'Service three', description: 'The thing that wins repeat business.' },
10
+ ];
11
+
12
+ const faqs = [
13
+ { q: 'How fast do you respond?', a: 'We reply to every inquiry within one business day.' },
14
+ { q: 'What areas do you serve?', a: 'See the service-area block above for current coverage.' },
15
+ { q: 'How do I get a quote?', a: 'Fill out the contact form below and we will follow up with next steps.' },
16
+ ];
17
+
18
+ export default function HomePage() {
19
+ return (
20
+ <main>
21
+ <Hero />
22
+
23
+ <section className="mx-auto max-w-6xl px-6 py-16">
24
+ <h2 className="text-3xl font-semibold tracking-tight">What we do</h2>
25
+ <div className="mt-8 grid gap-6 md:grid-cols-3">
26
+ {services.map((s) => (
27
+ <ServiceCard key={s.title} title={s.title} description={s.description} />
28
+ ))}
29
+ </div>
30
+ </section>
31
+
32
+ <section className="bg-slate-50">
33
+ <div className="mx-auto max-w-6xl px-6 py-16">
34
+ <h2 className="text-3xl font-semibold tracking-tight">Service area</h2>
35
+ <p className="mt-4 text-slate-700">
36
+ Replace this block with the cities, regions, or coverage map from your brief.
37
+ </p>
38
+ </div>
39
+ </section>
40
+
41
+ <section className="mx-auto max-w-6xl px-6 py-16">
42
+ <h2 className="text-3xl font-semibold tracking-tight">Trusted by</h2>
43
+ <div className="mt-8 grid gap-6 md:grid-cols-2">
44
+ <blockquote className="rounded-lg border border-slate-200 p-6">
45
+ <p className="text-slate-800">
46
+ &ldquo;Replace this with a real testimonial from a client.&rdquo;
47
+ </p>
48
+ <footer className="mt-4 text-sm text-slate-500">Client name, role</footer>
49
+ </blockquote>
50
+ <blockquote className="rounded-lg border border-slate-200 p-6">
51
+ <p className="text-slate-800">
52
+ &ldquo;Another testimonial. Specific results beat adjectives.&rdquo;
53
+ </p>
54
+ <footer className="mt-4 text-sm text-slate-500">Client name, role</footer>
55
+ </blockquote>
56
+ </div>
57
+ </section>
58
+
59
+ <section className="bg-slate-50">
60
+ <div className="mx-auto max-w-6xl px-6 py-16">
61
+ <h2 className="text-3xl font-semibold tracking-tight">Frequently asked</h2>
62
+ <FAQ items={faqs} />
63
+ </div>
64
+ </section>
65
+
66
+ <section id="contact" className="mx-auto max-w-3xl px-6 py-16">
67
+ <h2 className="text-3xl font-semibold tracking-tight">Get in touch</h2>
68
+ <p className="mt-2 text-slate-600">Tell us about your project. We will reply within one business day.</p>
69
+ <ContactForm />
70
+ </section>
71
+
72
+ <footer className="border-t border-slate-200">
73
+ <div className="mx-auto max-w-6xl px-6 py-8 text-sm text-slate-500">
74
+ (c) {new Date().getFullYear()} {`{{DISPLAY_NAME}}`}. All rights reserved.
75
+ </div>
76
+ </footer>
77
+ </main>
78
+ );
79
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ type Status = 'idle' | 'sending' | 'sent' | 'error';
6
+
7
+ export default function ContactForm() {
8
+ const [name, setName] = useState('');
9
+ const [email, setEmail] = useState('');
10
+ const [message, setMessage] = useState('');
11
+ const [status, setStatus] = useState<Status>('idle');
12
+ const [errorText, setErrorText] = useState('');
13
+
14
+ async function onSubmit(e: React.FormEvent) {
15
+ e.preventDefault();
16
+ setStatus('sending');
17
+ setErrorText('');
18
+ try {
19
+ const res = await fetch('/api/contact', {
20
+ method: 'POST',
21
+ headers: { 'content-type': 'application/json' },
22
+ body: JSON.stringify({ name, email, message }),
23
+ });
24
+ if (!res.ok) {
25
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
26
+ throw new Error(data.error || 'Something went wrong');
27
+ }
28
+ setStatus('sent');
29
+ setName('');
30
+ setEmail('');
31
+ setMessage('');
32
+ } catch (err) {
33
+ setStatus('error');
34
+ setErrorText(err instanceof Error ? err.message : 'Something went wrong');
35
+ }
36
+ }
37
+
38
+ if (status === 'sent') {
39
+ return (
40
+ <div className="mt-6 rounded-md bg-green-50 p-4 text-green-900">
41
+ Thanks. We will be in touch shortly.
42
+ </div>
43
+ );
44
+ }
45
+
46
+ return (
47
+ <form onSubmit={onSubmit} className="mt-6 space-y-4">
48
+ <label className="block">
49
+ <span className="text-sm font-medium text-slate-700">Name</span>
50
+ <input
51
+ required
52
+ value={name}
53
+ onChange={(e) => setName(e.target.value)}
54
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
55
+ />
56
+ </label>
57
+ <label className="block">
58
+ <span className="text-sm font-medium text-slate-700">Email</span>
59
+ <input
60
+ required
61
+ type="email"
62
+ value={email}
63
+ onChange={(e) => setEmail(e.target.value)}
64
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
65
+ />
66
+ </label>
67
+ <label className="block">
68
+ <span className="text-sm font-medium text-slate-700">Message</span>
69
+ <textarea
70
+ required
71
+ value={message}
72
+ onChange={(e) => setMessage(e.target.value)}
73
+ rows={4}
74
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
75
+ />
76
+ </label>
77
+ {status === 'error' && (
78
+ <div className="rounded-md bg-red-50 p-3 text-sm text-red-900">{errorText}</div>
79
+ )}
80
+ <button
81
+ type="submit"
82
+ disabled={status === 'sending'}
83
+ className="rounded-md bg-slate-900 px-5 py-2 font-medium text-white hover:bg-slate-800 disabled:opacity-60"
84
+ >
85
+ {status === 'sending' ? 'Sending...' : 'Send message'}
86
+ </button>
87
+ </form>
88
+ );
89
+ }
@@ -0,0 +1,12 @@
1
+ export default function FAQ({ items }: { items: { q: string; a: string }[] }) {
2
+ return (
3
+ <div className="mt-8 divide-y divide-slate-200">
4
+ {items.map((item) => (
5
+ <details key={item.q} className="py-4">
6
+ <summary className="cursor-pointer text-lg font-medium">{item.q}</summary>
7
+ <p className="mt-2 text-slate-700">{item.a}</p>
8
+ </details>
9
+ ))}
10
+ </div>
11
+ );
12
+ }
@@ -0,0 +1,20 @@
1
+ export default function Hero() {
2
+ return (
3
+ <section className="bg-slate-900 text-white">
4
+ <div className="mx-auto max-w-6xl px-6 py-24">
5
+ <h1 className="text-4xl font-bold tracking-tight md:text-6xl">{`{{DISPLAY_NAME}}`}</h1>
6
+ <p className="mt-6 max-w-2xl text-lg text-slate-200">
7
+ {`{{PROJECT_PURPOSE_ONE_SENTENCE}}`}
8
+ </p>
9
+ <div className="mt-10">
10
+ <a
11
+ href="#contact"
12
+ className="inline-block rounded-md bg-white px-6 py-3 text-base font-medium text-slate-900 hover:bg-slate-100"
13
+ >
14
+ Get in touch
15
+ </a>
16
+ </div>
17
+ </div>
18
+ </section>
19
+ );
20
+ }
@@ -0,0 +1,8 @@
1
+ export default function ServiceCard({ title, description }: { title: string; description: string }) {
2
+ return (
3
+ <article className="rounded-lg border border-slate-200 p-6">
4
+ <h3 className="text-lg font-semibold">{title}</h3>
5
+ <p className="mt-2 text-slate-600">{description}</p>
6
+ </article>
7
+ );
8
+ }
@@ -0,0 +1,33 @@
1
+ type EmailArgs = {
2
+ to: string;
3
+ from: string;
4
+ subject: string;
5
+ text: string;
6
+ };
7
+
8
+ export async function sendEmail(args: EmailArgs): Promise<{ ok: boolean; mock?: boolean }> {
9
+ const key = process.env.RESEND_API_KEY;
10
+ if (!key) {
11
+ console.warn('RESEND_API_KEY missing; contact form is in mock mode. Email not sent.');
12
+ return { ok: true, mock: true };
13
+ }
14
+ try {
15
+ const res = await fetch('https://api.resend.com/emails', {
16
+ method: 'POST',
17
+ headers: {
18
+ authorization: `Bearer ${key}`,
19
+ 'content-type': 'application/json',
20
+ },
21
+ body: JSON.stringify(args),
22
+ });
23
+ if (!res.ok) {
24
+ const detail = await res.text().catch(() => '');
25
+ console.error(`Resend failed (${res.status}): ${detail}`);
26
+ return { ok: false };
27
+ }
28
+ return { ok: true };
29
+ } catch (err) {
30
+ console.error('Resend network error:', err);
31
+ return { ok: false };
32
+ }
33
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "resend": "^4.0.0"
4
+ }
5
+ }
@@ -1,21 +1,10 @@
1
1
  # --- saas-dashboard profile additions ---
2
- # Auth (Better Auth)
3
- BETTER_AUTH_SECRET=
4
- BETTER_AUTH_URL=http://localhost:3000
5
-
6
- # Database (Neon recommended for Postgres)
2
+ # Postgres connection string (Neon recommended; any Postgres works)
7
3
  DATABASE_URL=
8
4
 
9
- # Stripe subscriptions
10
- STRIPE_SECRET_KEY=
11
- STRIPE_PUBLISHABLE_KEY=
12
- STRIPE_WEBHOOK_SECRET=
13
- STRIPE_PRICE_ID_SOLO=
14
- STRIPE_PRICE_ID_TEAM=
5
+ # Better Auth secrets
6
+ BETTER_AUTH_SECRET=
7
+ BETTER_AUTH_URL=http://localhost:3000
15
8
 
16
- # Public app URL
9
+ # Public app URL (Vercel sets this for production)
17
10
  NEXT_PUBLIC_APP_URL=http://localhost:3000
18
-
19
- # Email (for verification + password reset)
20
- RESEND_API_KEY=
21
- AUTH_FROM_EMAIL=noreply@example.com
@@ -1,25 +1,34 @@
1
1
  # Profile: saas-dashboard
2
2
 
3
- > SaaS MVP with auth, subscription billing skeleton, dashboard shell, settings. Pick the workflow over feature depth.
3
+ > Single-user SaaS MVP shell with Better Auth, dashboard, settings, and middleware guard. Workflow over feature depth.
4
+
5
+ ## What this profile ships (v1)
6
+
7
+ The overlay drops auth pages, a guarded dashboard, settings, and a pricing stub into your new project. After `vercel --prod` (with `DATABASE_URL` + `BETTER_AUTH_SECRET` set) you have:
8
+
9
+ - `/sign-in` and `/sign-up` forms wired to Better Auth.
10
+ - `/dashboard` (and nested routes) protected by `middleware.ts`. Unauthenticated visitors redirect to `/sign-in`.
11
+ - `/dashboard/settings` for a single-user account (email + display name).
12
+ - `/pricing` stub ("Coming soon"). No Stripe wiring in v1.
13
+ - A single `users` table in `db/schema.sql`. NO teams, organizations, roles, invites, or memberships.
14
+
15
+ ## Pro upgrades (not shipped in v1)
16
+
17
+ - Stripe subscriptions and billing portal.
18
+ - Team / org / role / invite UI.
19
+ - Email verification + password reset flow.
20
+ - API keys / OAuth providers.
4
21
 
5
22
  ## Day-one scope
6
23
 
7
24
  | Section | Purpose | Required |
8
25
  |---|---|---|
9
- | Landing hero | Convert visitor to signup | yes |
10
- | Pricing | 1-3 tiers, monthly + annual toggle | yes |
11
- | Sign up / Log in | Better Auth flow (email+password or magic link) | yes |
12
- | Dashboard shell | Authenticated route, empty state with onboarding hint | yes |
13
- | Settings | Profile + billing pages | yes |
14
- | Billing | Stripe Customer Portal link OR a stub for "manage subscription" | yes |
15
- | Footer | Links, year, GitHub, status page link if any | yes |
16
-
17
- ## Wired infrastructure
18
-
19
- - **Better Auth** for authentication. Email+password v1; magic link v2.
20
- - **Postgres** for `User` + any product entities from the brief.
21
- - **Stripe** for subscriptions. Use Stripe Checkout for initial signup, Stripe Customer Portal for management.
22
- - **Middleware** at the route level: unauthenticated visitors to `/dashboard/*` redirect to `/login`.
26
+ | Sign-in / Sign-up | Better Auth email+password | yes |
27
+ | Dashboard shell | Authenticated layout with sign-out | yes |
28
+ | Dashboard home | Welcome card | yes |
29
+ | Settings | Email + display name | yes |
30
+ | Pricing stub | "Coming soon" placeholder | yes |
31
+ | Middleware guard | Redirect unauthenticated `/dashboard/*` to `/sign-in` | yes |
23
32
 
24
33
  ## Env additions
25
34
 
@@ -28,33 +37,31 @@ See `.env.example.additions`.
28
37
  ## API contracts
29
38
 
30
39
  ```
31
- POST /api/auth/signup (Better Auth handles)
32
- POST /api/auth/login (Better Auth handles)
33
- POST /api/auth/logout (Better Auth handles)
34
- GET /api/auth/session (Better Auth handles; returns user or 401)
35
-
36
- POST /api/billing/checkout Body: { plan: 'solo' | 'team' }
37
- Response: { url } (Stripe Checkout URL)
38
-
39
- GET /api/billing/portal Response: { url } (Stripe Customer Portal URL)
40
-
41
- POST /api/webhooks/stripe Handles subscription.created, .updated, .deleted
42
- Updates User.plan
40
+ ALL /api/auth/[...all] (Better Auth handler — handles sign-in/up/out, sessions)
41
+ POST /api/account/settings (your code; see app/dashboard/settings/page.tsx)
43
42
  ```
44
43
 
45
44
  ## Hard rules
46
45
 
47
46
  - Auth gate runs at middleware level. Never serve dashboard UI to unauthenticated requests, even briefly.
48
- - Session cookies are httpOnly, secure in production, sameSite=lax.
49
- - Stripe webhook signature verification is mandatory. Reject on bad sig.
50
- - Empty dashboard state has a clear "Get started" path. No dead-end empty UI.
47
+ - Session cookies are httpOnly, secure in production, sameSite=lax (Better Auth defaults).
51
48
  - All errors during auth flow are user-facing (not stack traces).
52
- - Settings Billing links to Stripe Customer Portal, not a custom billing UI v1.
49
+ - The build MUST succeed when `DATABASE_URL` is unset (build-time stub via `lib/db.ts`).
53
50
 
54
51
  ## Acceptance
55
52
 
56
- - New user can sign up, see empty dashboard, log out, log back in.
57
- - Stripe Checkout can be initiated; webhook updates user plan.
58
- - Visiting `/dashboard` unauthenticated redirects to `/login`.
59
- - Visiting `/login` while logged in redirects to `/dashboard`.
60
- - Lighthouse landing-page Performance >= 90; dashboard >= 80 (slightly lower acceptable due to auth code).
53
+ - New user can sign up, see empty dashboard, sign out, sign back in.
54
+ - Visiting `/dashboard` unauthenticated redirects to `/sign-in`.
55
+ - `db/schema.sql` contains exactly the `users` table; no team/role/invite tables.
56
+ - Lighthouse landing-page Performance >= 80 (slightly lower acceptable due to auth code).
57
+
58
+ ## Visual verification
59
+
60
+ After deploy, run:
61
+
62
+ ```bash
63
+ bash scripts/screenshot.sh <your-vercel-url>/sign-in
64
+ bash scripts/screenshot.sh <your-vercel-url>/dashboard # expect redirect to /sign-in
65
+ ```
66
+
67
+ Read the resulting PNGs to confirm the auth flow renders and the dashboard guard works.
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+
6
+ export default function SignInPage() {
7
+ const router = useRouter();
8
+ const [email, setEmail] = useState('');
9
+ const [password, setPassword] = useState('');
10
+ const [error, setError] = useState('');
11
+ const [pending, setPending] = useState(false);
12
+
13
+ async function onSubmit(e: React.FormEvent) {
14
+ e.preventDefault();
15
+ setPending(true);
16
+ setError('');
17
+ try {
18
+ const res = await fetch('/api/auth/sign-in/email', {
19
+ method: 'POST',
20
+ headers: { 'content-type': 'application/json' },
21
+ body: JSON.stringify({ email, password }),
22
+ });
23
+ if (!res.ok) {
24
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
25
+ throw new Error(data.error || 'Sign-in failed');
26
+ }
27
+ router.push('/dashboard');
28
+ router.refresh();
29
+ } catch (err) {
30
+ setError(err instanceof Error ? err.message : 'Sign-in failed');
31
+ } finally {
32
+ setPending(false);
33
+ }
34
+ }
35
+
36
+ return (
37
+ <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-12">
38
+ <h1 className="text-3xl font-semibold tracking-tight">Sign in</h1>
39
+ <p className="mt-2 text-sm text-slate-600">Welcome back to {`{{DISPLAY_NAME}}`}.</p>
40
+ <form onSubmit={onSubmit} className="mt-8 space-y-4">
41
+ <label className="block">
42
+ <span className="text-sm font-medium text-slate-700">Email</span>
43
+ <input
44
+ required
45
+ type="email"
46
+ value={email}
47
+ onChange={(e) => setEmail(e.target.value)}
48
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
49
+ />
50
+ </label>
51
+ <label className="block">
52
+ <span className="text-sm font-medium text-slate-700">Password</span>
53
+ <input
54
+ required
55
+ type="password"
56
+ value={password}
57
+ onChange={(e) => setPassword(e.target.value)}
58
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
59
+ />
60
+ </label>
61
+ {error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-900">{error}</div>}
62
+ <button
63
+ type="submit"
64
+ disabled={pending}
65
+ className="w-full rounded-md bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-800 disabled:opacity-60"
66
+ >
67
+ {pending ? 'Signing in...' : 'Sign in'}
68
+ </button>
69
+ </form>
70
+ <p className="mt-6 text-sm text-slate-600">
71
+ No account? <a className="font-medium text-slate-900 underline" href="/sign-up">Create one</a>.
72
+ </p>
73
+ </main>
74
+ );
75
+ }