@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/scripts/verify-profile.sh +46 -0
- package/src/bootstrap.mjs +146 -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
|
@@ -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 "
|
|
10
|
-
| Services | 3-8 service cards
|
|
11
|
-
| Service area | Map placeholder OR text list of cities / zip codes
|
|
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
|
|
14
|
-
| Contact form | Name,
|
|
15
|
-
| Footer |
|
|
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
|
|
31
|
+
## API contract
|
|
29
32
|
|
|
30
33
|
```
|
|
31
34
|
POST /api/contact
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
- Owner receives the lead by email at
|
|
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
|
+
“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
|
+
}
|