@oaklandzoo/ostup 0.9.1 → 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.
- package/README.md +7 -0
- package/package.json +1 -1
- package/scripts/verify-profile.sh +46 -0
- package/src/brief/profile-router.mjs +63 -28
- package/templates/START_HERE.md +1 -1
- package/templates/profiles/blog/README.md +45 -40
- package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
- package/templates/profiles/blog/app/page.tsx +30 -0
- package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
- package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
- package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
- package/templates/profiles/blog/content/posts/intro.mdx +14 -0
- package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
- package/templates/profiles/blog/lib/posts.ts +43 -0
- package/templates/profiles/blog/next.config.mjs.additions +9 -0
- package/templates/profiles/blog/package.json.additions +6 -0
- package/templates/profiles/booking/.env.example.additions +3 -11
- package/templates/profiles/booking/README.md +26 -21
- package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
- package/templates/profiles/booking/app/page.tsx +38 -0
- package/templates/profiles/booking/components/BookingForm.tsx +130 -0
- package/templates/profiles/booking/components/Hero.tsx +20 -0
- package/templates/profiles/booking/components/ServiceList.tsx +19 -0
- package/templates/profiles/booking/lib/resend.ts +33 -0
- package/templates/profiles/booking/lib/storage.ts +44 -0
- package/templates/profiles/booking/package.json.additions +5 -0
- package/templates/profiles/booking/section-prompts.md +10 -8
- package/templates/profiles/lead-gen/.env.example.additions +2 -2
- package/templates/profiles/lead-gen/README.md +35 -27
- package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
- package/templates/profiles/lead-gen/app/layout.tsx +29 -0
- package/templates/profiles/lead-gen/app/page.tsx +79 -0
- package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
- package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
- package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
- package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
- package/templates/profiles/lead-gen/lib/resend.ts +33 -0
- package/templates/profiles/lead-gen/package.json.additions +5 -0
- package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
- package/templates/profiles/saas-dashboard/README.md +43 -36
- package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
- package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
- package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
- package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
- package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
- package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
- package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
- package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
- package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
- package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
- package/templates/profiles/saas-dashboard/middleware.ts +19 -0
- package/templates/profiles/saas-dashboard/package.json.additions +9 -0
|
@@ -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
|
+
“Replace this with a real testimonial from a client.”
|
|
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
|
+
“Another testimonial. Specific results beat adjectives.”
|
|
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
|
+
}
|
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
# --- saas-dashboard profile additions ---
|
|
2
|
-
#
|
|
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
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
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
|
-
|
|
32
|
-
POST /api/
|
|
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
|
-
-
|
|
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,
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
export default function SignUpPage() {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
const [email, setEmail] = useState('');
|
|
9
|
+
const [password, setPassword] = useState('');
|
|
10
|
+
const [name, setName] = useState('');
|
|
11
|
+
const [error, setError] = useState('');
|
|
12
|
+
const [pending, setPending] = useState(false);
|
|
13
|
+
|
|
14
|
+
async function onSubmit(e: React.FormEvent) {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setPending(true);
|
|
17
|
+
setError('');
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch('/api/auth/sign-up/email', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ email, password, name }),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
26
|
+
throw new Error(data.error || 'Sign-up failed');
|
|
27
|
+
}
|
|
28
|
+
router.push('/dashboard');
|
|
29
|
+
router.refresh();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
setError(err instanceof Error ? err.message : 'Sign-up failed');
|
|
32
|
+
} finally {
|
|
33
|
+
setPending(false);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-12">
|
|
39
|
+
<h1 className="text-3xl font-semibold tracking-tight">Create account</h1>
|
|
40
|
+
<p className="mt-2 text-sm text-slate-600">Get started with {`{{DISPLAY_NAME}}`}.</p>
|
|
41
|
+
<form onSubmit={onSubmit} className="mt-8 space-y-4">
|
|
42
|
+
<label className="block">
|
|
43
|
+
<span className="text-sm font-medium text-slate-700">Display name</span>
|
|
44
|
+
<input
|
|
45
|
+
required
|
|
46
|
+
value={name}
|
|
47
|
+
onChange={(e) => setName(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">Email</span>
|
|
53
|
+
<input
|
|
54
|
+
required
|
|
55
|
+
type="email"
|
|
56
|
+
value={email}
|
|
57
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
58
|
+
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
|
|
59
|
+
/>
|
|
60
|
+
</label>
|
|
61
|
+
<label className="block">
|
|
62
|
+
<span className="text-sm font-medium text-slate-700">Password</span>
|
|
63
|
+
<input
|
|
64
|
+
required
|
|
65
|
+
type="password"
|
|
66
|
+
value={password}
|
|
67
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
68
|
+
minLength={8}
|
|
69
|
+
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-900">{error}</div>}
|
|
73
|
+
<button
|
|
74
|
+
type="submit"
|
|
75
|
+
disabled={pending}
|
|
76
|
+
className="w-full rounded-md bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-800 disabled:opacity-60"
|
|
77
|
+
>
|
|
78
|
+
{pending ? 'Creating...' : 'Create account'}
|
|
79
|
+
</button>
|
|
80
|
+
</form>
|
|
81
|
+
<p className="mt-6 text-sm text-slate-600">
|
|
82
|
+
Already have an account? <a className="font-medium text-slate-900 underline" href="/sign-in">Sign in</a>.
|
|
83
|
+
</p>
|
|
84
|
+
</main>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
|
|
6
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
|
|
9
|
+
async function onSignOut() {
|
|
10
|
+
await fetch('/api/auth/sign-out', { method: 'POST' });
|
|
11
|
+
router.push('/sign-in');
|
|
12
|
+
router.refresh();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="min-h-screen bg-slate-50">
|
|
17
|
+
<header className="border-b border-slate-200 bg-white">
|
|
18
|
+
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
|
19
|
+
<Link href="/dashboard" className="text-lg font-semibold tracking-tight">
|
|
20
|
+
{`{{DISPLAY_NAME}}`}
|
|
21
|
+
</Link>
|
|
22
|
+
<nav className="flex items-center gap-4 text-sm">
|
|
23
|
+
<Link href="/dashboard/settings" className="text-slate-600 hover:text-slate-900">Settings</Link>
|
|
24
|
+
<button
|
|
25
|
+
onClick={onSignOut}
|
|
26
|
+
className="rounded-md border border-slate-200 px-3 py-1 text-slate-700 hover:bg-slate-50"
|
|
27
|
+
>
|
|
28
|
+
Sign out
|
|
29
|
+
</button>
|
|
30
|
+
</nav>
|
|
31
|
+
</div>
|
|
32
|
+
</header>
|
|
33
|
+
<main className="mx-auto max-w-6xl px-6 py-10">{children}</main>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default function DashboardPage() {
|
|
2
|
+
return (
|
|
3
|
+
<div>
|
|
4
|
+
<h1 className="text-3xl font-semibold tracking-tight">Welcome</h1>
|
|
5
|
+
<p className="mt-2 text-slate-600">
|
|
6
|
+
This is your starter dashboard. Replace the welcome card below with the metrics, lists, or widgets that matter to your product.
|
|
7
|
+
</p>
|
|
8
|
+
<section className="mt-8 rounded-lg border border-slate-200 bg-white p-6">
|
|
9
|
+
<h2 className="text-lg font-semibold">Getting started</h2>
|
|
10
|
+
<ul className="mt-4 list-inside list-disc space-y-1 text-slate-700">
|
|
11
|
+
<li>Edit <code>app/dashboard/page.tsx</code> to add real content.</li>
|
|
12
|
+
<li>Edit <code>db/schema.sql</code> to add your domain entities.</li>
|
|
13
|
+
<li>Edit <code>app/dashboard/settings/page.tsx</code> to add settings fields.</li>
|
|
14
|
+
</ul>
|
|
15
|
+
</section>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|