@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.
Files changed (52) hide show
  1. package/README.md +7 -0
  2. package/package.json +1 -1
  3. package/scripts/verify-profile.sh +46 -0
  4. package/src/brief/profile-router.mjs +63 -28
  5. package/templates/START_HERE.md +1 -1
  6. package/templates/profiles/blog/README.md +45 -40
  7. package/templates/profiles/blog/app/feed.xml/route.ts +41 -0
  8. package/templates/profiles/blog/app/page.tsx +30 -0
  9. package/templates/profiles/blog/app/posts/[slug]/page.tsx +51 -0
  10. package/templates/profiles/blog/app/sitemap.xml/route.ts +21 -0
  11. package/templates/profiles/blog/content/posts/getting-started.mdx +16 -0
  12. package/templates/profiles/blog/content/posts/intro.mdx +14 -0
  13. package/templates/profiles/blog/content/posts/welcome.mdx +12 -0
  14. package/templates/profiles/blog/lib/posts.ts +43 -0
  15. package/templates/profiles/blog/next.config.mjs.additions +9 -0
  16. package/templates/profiles/blog/package.json.additions +6 -0
  17. package/templates/profiles/booking/.env.example.additions +3 -11
  18. package/templates/profiles/booking/README.md +26 -21
  19. package/templates/profiles/booking/app/api/booking/request/route.ts +76 -0
  20. package/templates/profiles/booking/app/page.tsx +38 -0
  21. package/templates/profiles/booking/components/BookingForm.tsx +130 -0
  22. package/templates/profiles/booking/components/Hero.tsx +20 -0
  23. package/templates/profiles/booking/components/ServiceList.tsx +19 -0
  24. package/templates/profiles/booking/lib/resend.ts +33 -0
  25. package/templates/profiles/booking/lib/storage.ts +44 -0
  26. package/templates/profiles/booking/package.json.additions +5 -0
  27. package/templates/profiles/booking/section-prompts.md +10 -8
  28. package/templates/profiles/lead-gen/.env.example.additions +2 -2
  29. package/templates/profiles/lead-gen/README.md +35 -27
  30. package/templates/profiles/lead-gen/app/api/contact/route.ts +49 -0
  31. package/templates/profiles/lead-gen/app/layout.tsx +29 -0
  32. package/templates/profiles/lead-gen/app/page.tsx +79 -0
  33. package/templates/profiles/lead-gen/components/ContactForm.tsx +89 -0
  34. package/templates/profiles/lead-gen/components/FAQ.tsx +12 -0
  35. package/templates/profiles/lead-gen/components/Hero.tsx +20 -0
  36. package/templates/profiles/lead-gen/components/ServiceCard.tsx +8 -0
  37. package/templates/profiles/lead-gen/lib/resend.ts +33 -0
  38. package/templates/profiles/lead-gen/package.json.additions +5 -0
  39. package/templates/profiles/saas-dashboard/.env.example.additions +5 -16
  40. package/templates/profiles/saas-dashboard/README.md +43 -36
  41. package/templates/profiles/saas-dashboard/app/(auth)/sign-in/page.tsx +75 -0
  42. package/templates/profiles/saas-dashboard/app/(auth)/sign-up/page.tsx +86 -0
  43. package/templates/profiles/saas-dashboard/app/api/auth/[...all]/route.ts +4 -0
  44. package/templates/profiles/saas-dashboard/app/dashboard/layout.tsx +36 -0
  45. package/templates/profiles/saas-dashboard/app/dashboard/page.tsx +18 -0
  46. package/templates/profiles/saas-dashboard/app/dashboard/settings/page.tsx +60 -0
  47. package/templates/profiles/saas-dashboard/app/pricing/page.tsx +11 -0
  48. package/templates/profiles/saas-dashboard/db/schema.sql +13 -0
  49. package/templates/profiles/saas-dashboard/lib/auth.ts +15 -0
  50. package/templates/profiles/saas-dashboard/lib/db.ts +20 -0
  51. package/templates/profiles/saas-dashboard/middleware.ts +19 -0
  52. package/templates/profiles/saas-dashboard/package.json.additions +9 -0
@@ -1,6 +1,6 @@
1
1
  # Profile: booking
2
2
 
3
- > Appointment / reservation site. Availability request, booking form, optional Stripe deposit, email confirmations.
3
+ > Appointment / reservation site. Availability request, booking form, email confirmations.
4
4
 
5
5
  ## Day-one scope
6
6
 
@@ -9,17 +9,22 @@
9
9
  | Hero | Single value statement + "Check availability" or "Book now" CTA | yes |
10
10
  | Availability / Booking form | Date range or appointment slot picker + guest info | yes |
11
11
  | Rooms / Services | List of bookable units with descriptions and rates | yes |
12
- | Deposit checkout | Stripe Checkout for the deposit if `payments` addon is on | conditional |
13
12
  | Confirmation email | Auto-send on submission to both guest and admin | yes |
14
13
  | About / Amenities | Trust-building content from the brief | yes |
15
14
  | Footer | Contact, cancellation policy summary | yes |
16
15
 
17
- ## Wired infrastructure
16
+ ## Wired infrastructure (v1)
18
17
 
19
- - **Postgres** for the `Booking` entity (date_start, date_end, guest info, status, deposit_status).
18
+ - **JSON-file storage** at `data/bookings.json` for the `Booking` entity. Upgrade to Postgres in a Pro/Studio iteration.
20
19
  - **Resend** for guest + admin confirmation emails.
21
- - **Stripe Checkout** for deposit (only if `payments` addon is on per the brief).
22
- - Mobile-first calendar / date picker.
20
+ - Mobile-first date picker via `react-day-picker`.
21
+
22
+ ## Pro upgrades (not shipped in v1)
23
+
24
+ - Stripe Checkout for deposits.
25
+ - Postgres for booking persistence (replacing the JSON file).
26
+ - Admin booking manager UI.
27
+ - Calendar sync (Google Cal / Cal.com).
23
28
 
24
29
  ## Env additions
25
30
 
@@ -31,31 +36,31 @@ See `.env.example.additions`.
31
36
  POST /api/booking/request
32
37
  Body: { name, email, phone?, date_start, date_end, room_or_service?, notes? }
33
38
  Response: { ok, booking_id }
34
- Behavior: insert Booking with status='pending', email both parties.
35
-
36
- POST /api/booking/deposit
37
- Body: { booking_id }
38
- Response: { ok, checkout_url }
39
- Behavior: create Stripe Checkout session, return URL.
40
-
41
- POST /api/webhooks/stripe
42
- Behavior: on payment_intent.succeeded, mark booking deposit_status='paid'.
39
+ Behavior: append Booking to data/bookings.json with status='pending', email both parties.
43
40
  ```
44
41
 
45
42
  ## Hard rules
46
43
 
47
44
  - Date pickers MUST be mobile-friendly (no tiny native date inputs without testing).
48
45
  - Server-side validate the date range (no past dates, no end-before-start).
49
- - Confirmation emails go out immediately on form submit, even before deposit.
50
- - Deposit is OPTIONAL: if `payments` addon is off, skip Stripe entirely. Booking still goes through.
46
+ - Confirmation emails go out immediately on form submit.
51
47
  - Calendar availability check should be server-side; do not trust client.
52
- - If `Booking.status` is 'pending' for >48h with no deposit, mark as 'expired'.
48
+ - Bookings older than 48h with `status='pending'` should be considered expired (handled by a future cron, not v1).
53
49
 
54
50
  ## Acceptance
55
51
 
56
52
  - Visitor can request dates and submit.
57
- - Both visitor and admin receive emails within 30 seconds.
58
- - Deposit Checkout opens if enabled, completes round-trip to the booking record.
53
+ - Both visitor and admin receive emails within 30 seconds (assuming `RESEND_API_KEY` set).
59
54
  - Past dates rejected with clear error.
60
55
  - Hero CTA above the fold at 420x900.
61
- - Lighthouse mobile Performance >= 85 (acceptable lower than marketing because of date picker JS).
56
+ - Lighthouse mobile Performance >= 85 (lower than marketing because of date picker JS).
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, booking form, and footer render as expected.
@@ -0,0 +1,76 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { appendBooking } from '@/lib/storage';
3
+ import { sendEmail } from '@/lib/resend';
4
+
5
+ type Body = {
6
+ name?: string;
7
+ email?: string;
8
+ phone?: string;
9
+ date_start?: string;
10
+ date_end?: string;
11
+ notes?: string;
12
+ };
13
+
14
+ function isFutureDate(d: Date) {
15
+ const today = new Date();
16
+ today.setHours(0, 0, 0, 0);
17
+ return d.getTime() >= today.getTime();
18
+ }
19
+
20
+ export async function POST(req: Request) {
21
+ let body: Body;
22
+ try {
23
+ body = await req.json();
24
+ } catch {
25
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
26
+ }
27
+
28
+ const name = (body.name || '').trim();
29
+ const email = (body.email || '').trim();
30
+ const phone = (body.phone || '').trim();
31
+ const notes = (body.notes || '').trim();
32
+ const date_start = (body.date_start || '').trim();
33
+ const date_end = (body.date_end || '').trim();
34
+
35
+ if (!name || !email || !date_start || !date_end) {
36
+ return NextResponse.json({ error: 'name, email, date_start, and date_end are required' }, { status: 400 });
37
+ }
38
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
39
+ return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
40
+ }
41
+
42
+ const start = new Date(date_start);
43
+ const end = new Date(date_end);
44
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
45
+ return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
46
+ }
47
+ if (!isFutureDate(start)) {
48
+ return NextResponse.json({ error: 'date_start must not be in the past' }, { status: 400 });
49
+ }
50
+ if (end.getTime() <= start.getTime()) {
51
+ return NextResponse.json({ error: 'date_end must be after date_start' }, { status: 400 });
52
+ }
53
+
54
+ const booking = await appendBooking({ name, email, phone, date_start, date_end, notes, status: 'pending' });
55
+
56
+ const from = process.env.RESEND_FROM_EMAIL || 'noreply@example.com';
57
+ const admin = process.env.ADMIN_EMAIL;
58
+
59
+ await sendEmail({
60
+ to: email,
61
+ from,
62
+ subject: `Booking request received - {{DISPLAY_NAME}}`,
63
+ text: `Hi ${name},\n\nWe received your request for ${date_start} to ${date_end}. Booking ID: ${booking.id}.\nWe will confirm by email shortly.\n`,
64
+ });
65
+
66
+ if (admin) {
67
+ await sendEmail({
68
+ to: admin,
69
+ from,
70
+ subject: `New booking request: ${name} (${booking.id})`,
71
+ text: `Name: ${name}\nEmail: ${email}\nPhone: ${phone}\nDates: ${date_start} to ${date_end}\nNotes: ${notes}\nBooking ID: ${booking.id}\n`,
72
+ });
73
+ }
74
+
75
+ return NextResponse.json({ ok: true, booking_id: booking.id });
76
+ }
@@ -0,0 +1,38 @@
1
+ import Hero from '@/components/Hero';
2
+ import BookingForm from '@/components/BookingForm';
3
+ import ServiceList from '@/components/ServiceList';
4
+
5
+ export default function HomePage() {
6
+ return (
7
+ <main>
8
+ <Hero />
9
+
10
+ <section id="book" className="mx-auto max-w-4xl px-6 py-16">
11
+ <h2 className="text-3xl font-semibold tracking-tight">Check availability</h2>
12
+ <p className="mt-2 text-slate-600">Pick your dates and tell us about your stay. We confirm by email.</p>
13
+ <BookingForm />
14
+ </section>
15
+
16
+ <section className="bg-slate-50">
17
+ <div className="mx-auto max-w-6xl px-6 py-16">
18
+ <h2 className="text-3xl font-semibold tracking-tight">Rooms and services</h2>
19
+ <ServiceList />
20
+ </div>
21
+ </section>
22
+
23
+ <section className="mx-auto max-w-4xl px-6 py-16">
24
+ <h2 className="text-3xl font-semibold tracking-tight">About</h2>
25
+ <p className="mt-4 text-slate-700">
26
+ Replace this block with your story, amenities, and what makes a stay here different.
27
+ </p>
28
+ </section>
29
+
30
+ <footer className="border-t border-slate-200">
31
+ <div className="mx-auto max-w-6xl px-6 py-8 text-sm text-slate-500">
32
+ <p>{`{{DISPLAY_NAME}}`} (c) {new Date().getFullYear()}</p>
33
+ <p className="mt-1">Cancellation policy: edit this footer with your terms.</p>
34
+ </div>
35
+ </footer>
36
+ </main>
37
+ );
38
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { DayPicker, type DateRange } from 'react-day-picker';
5
+ import 'react-day-picker/dist/style.css';
6
+
7
+ type Status = 'idle' | 'sending' | 'sent' | 'error';
8
+
9
+ function toIso(d: Date | undefined): string {
10
+ if (!d) return '';
11
+ const yyyy = d.getFullYear();
12
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
13
+ const dd = String(d.getDate()).padStart(2, '0');
14
+ return `${yyyy}-${mm}-${dd}`;
15
+ }
16
+
17
+ export default function BookingForm() {
18
+ const [range, setRange] = useState<DateRange | undefined>();
19
+ const [name, setName] = useState('');
20
+ const [email, setEmail] = useState('');
21
+ const [phone, setPhone] = useState('');
22
+ const [notes, setNotes] = useState('');
23
+ const [status, setStatus] = useState<Status>('idle');
24
+ const [errorText, setErrorText] = useState('');
25
+ const [bookingId, setBookingId] = useState('');
26
+
27
+ async function onSubmit(e: React.FormEvent) {
28
+ e.preventDefault();
29
+ setStatus('sending');
30
+ setErrorText('');
31
+ try {
32
+ if (!range?.from || !range?.to) {
33
+ throw new Error('Please pick a start and end date.');
34
+ }
35
+ const res = await fetch('/api/booking/request', {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify({
39
+ name,
40
+ email,
41
+ phone,
42
+ notes,
43
+ date_start: toIso(range.from),
44
+ date_end: toIso(range.to),
45
+ }),
46
+ });
47
+ const data = (await res.json().catch(() => ({}))) as { ok?: boolean; booking_id?: string; error?: string };
48
+ if (!res.ok || !data.ok) {
49
+ throw new Error(data.error || 'Booking request failed');
50
+ }
51
+ setBookingId(data.booking_id || '');
52
+ setStatus('sent');
53
+ } catch (err) {
54
+ setStatus('error');
55
+ setErrorText(err instanceof Error ? err.message : 'Something went wrong');
56
+ }
57
+ }
58
+
59
+ if (status === 'sent') {
60
+ return (
61
+ <div className="mt-6 rounded-md bg-green-50 p-6 text-green-900">
62
+ <p className="font-semibold">Request received.</p>
63
+ <p className="mt-1">Your booking ID is {bookingId}. We will confirm by email.</p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <form onSubmit={onSubmit} className="mt-6 space-y-6">
70
+ <div>
71
+ <span className="text-sm font-medium text-slate-700">Dates</span>
72
+ <div className="mt-2 rounded-md border border-slate-200 p-3">
73
+ <DayPicker
74
+ mode="range"
75
+ selected={range}
76
+ onSelect={setRange}
77
+ disabled={{ before: new Date() }}
78
+ numberOfMonths={1}
79
+ />
80
+ </div>
81
+ </div>
82
+ <label className="block">
83
+ <span className="text-sm font-medium text-slate-700">Name</span>
84
+ <input
85
+ required
86
+ value={name}
87
+ onChange={(e) => setName(e.target.value)}
88
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
89
+ />
90
+ </label>
91
+ <label className="block">
92
+ <span className="text-sm font-medium text-slate-700">Email</span>
93
+ <input
94
+ required
95
+ type="email"
96
+ value={email}
97
+ onChange={(e) => setEmail(e.target.value)}
98
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
99
+ />
100
+ </label>
101
+ <label className="block">
102
+ <span className="text-sm font-medium text-slate-700">Phone (optional)</span>
103
+ <input
104
+ value={phone}
105
+ onChange={(e) => setPhone(e.target.value)}
106
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
107
+ />
108
+ </label>
109
+ <label className="block">
110
+ <span className="text-sm font-medium text-slate-700">Notes (optional)</span>
111
+ <textarea
112
+ value={notes}
113
+ onChange={(e) => setNotes(e.target.value)}
114
+ rows={3}
115
+ className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2"
116
+ />
117
+ </label>
118
+ {status === 'error' && (
119
+ <div className="rounded-md bg-red-50 p-3 text-sm text-red-900">{errorText}</div>
120
+ )}
121
+ <button
122
+ type="submit"
123
+ disabled={status === 'sending'}
124
+ className="rounded-md bg-slate-900 px-5 py-2 font-medium text-white hover:bg-slate-800 disabled:opacity-60"
125
+ >
126
+ {status === 'sending' ? 'Sending...' : 'Request booking'}
127
+ </button>
128
+ </form>
129
+ );
130
+ }
@@ -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="#book"
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
+ Check availability
15
+ </a>
16
+ </div>
17
+ </div>
18
+ </section>
19
+ );
20
+ }
@@ -0,0 +1,19 @@
1
+ const services = [
2
+ { name: 'Standard room', description: 'Comfortable double, ensuite bath.', rate: '$180 / night' },
3
+ { name: 'Suite', description: 'Larger room with sitting area.', rate: '$260 / night' },
4
+ { name: 'Family cabin', description: 'Sleeps up to four. Private deck.', rate: '$340 / night' },
5
+ ];
6
+
7
+ export default function ServiceList() {
8
+ return (
9
+ <div className="mt-8 grid gap-6 md:grid-cols-3">
10
+ {services.map((s) => (
11
+ <article key={s.name} className="rounded-lg border border-slate-200 bg-white p-6">
12
+ <h3 className="text-lg font-semibold">{s.name}</h3>
13
+ <p className="mt-2 text-slate-600">{s.description}</p>
14
+ <p className="mt-4 text-sm font-medium text-slate-900">{s.rate}</p>
15
+ </article>
16
+ ))}
17
+ </div>
18
+ );
19
+ }
@@ -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; booking email 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,44 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ const DATA_DIR = join(process.cwd(), 'data');
7
+ const BOOKINGS_FILE = join(DATA_DIR, 'bookings.json');
8
+
9
+ export type Booking = {
10
+ id: string;
11
+ name: string;
12
+ email: string;
13
+ phone?: string;
14
+ date_start: string;
15
+ date_end: string;
16
+ notes?: string;
17
+ status: 'pending' | 'confirmed' | 'cancelled';
18
+ created_at: string;
19
+ };
20
+
21
+ async function ensureFile(): Promise<void> {
22
+ if (!existsSync(BOOKINGS_FILE)) {
23
+ await mkdir(dirname(BOOKINGS_FILE), { recursive: true });
24
+ await writeFile(BOOKINGS_FILE, '[]\n', 'utf8');
25
+ }
26
+ }
27
+
28
+ export async function readBookings(): Promise<Booking[]> {
29
+ await ensureFile();
30
+ const raw = await readFile(BOOKINGS_FILE, 'utf8');
31
+ return JSON.parse(raw) as Booking[];
32
+ }
33
+
34
+ export async function appendBooking(input: Omit<Booking, 'id' | 'created_at'>): Promise<Booking> {
35
+ const bookings = await readBookings();
36
+ const booking: Booking = {
37
+ ...input,
38
+ id: randomUUID().slice(0, 8),
39
+ created_at: new Date().toISOString(),
40
+ };
41
+ bookings.push(booking);
42
+ await writeFile(BOOKINGS_FILE, JSON.stringify(bookings, null, 2) + '\n', 'utf8');
43
+ return booking;
44
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "dependencies": {
3
+ "react-day-picker": "^9.0.0"
4
+ }
5
+ }
@@ -6,11 +6,11 @@ Agent: build each section using the brief in `docs/brief.md`. Honor `must_avoid`
6
6
 
7
7
  - Headline (5-9 words) about the experience being booked.
8
8
  - Subhead: one sentence on what the visitor gets when they book.
9
- - Primary CTA: "Check availability" or "Book now" opens the booking form modal or scrolls to it.
9
+ - Primary CTA: "Check availability" or "Book now" - opens the booking form modal or scrolls to it.
10
10
 
11
11
  ## Availability / Booking form
12
12
 
13
- - Date range picker (or single-date for appointments). Mobile-friendly is mandatory.
13
+ - Date range picker (or single-date for appointments). Mobile-friendly is mandatory (the shipped `BookingForm.tsx` uses `react-day-picker`).
14
14
  - Guest info: name, email, phone, party size (if relevant), notes.
15
15
  - Validate server-side: no past dates, end after start, party size within room limits.
16
16
  - Loading state on submit. Success state with booking ID. Error state with clear message.
@@ -21,13 +21,9 @@ Agent: build each section using the brief in `docs/brief.md`. Honor `must_avoid`
21
21
  - Each unit: name, description (short), rate per night/hour, photo placeholder if `inputs/images/` has anything matching.
22
22
  - "Book this" CTA scrolls back to the booking form with the room pre-selected.
23
23
 
24
- ## Deposit checkout (conditional)
24
+ ## Deposit checkout
25
25
 
26
- - Only render if `payments` addon is in the brief.
27
- - After successful booking submission, present a "Pay deposit to confirm" CTA.
28
- - Clicking opens Stripe Checkout via `/api/booking/deposit`.
29
- - On Checkout success, return to a `/booking/[id]/confirmed` page.
30
- - Webhook `/api/webhooks/stripe` updates `deposit_status='paid'`.
26
+ **Pro upgrade. Not shipped in v1.** To enable Stripe deposit, see Pro tier docs and add `STRIPE_*` env vars + a `/api/booking/deposit` route + Stripe webhook handler. The shipped booking flow completes via email confirmation only.
31
27
 
32
28
  ## Confirmation emails
33
29
 
@@ -35,6 +31,12 @@ Agent: build each section using the brief in `docs/brief.md`. Honor `must_avoid`
35
31
  - Admin receives: full booking details + a link to the admin route (TBD v2).
36
32
  - Use Resend. Plain text + HTML versions.
37
33
 
34
+ ## Storage
35
+
36
+ - The shipped `lib/storage.ts` writes to `data/bookings.json`. Good for v1 / demo / single-server use.
37
+ - For production with concurrent writes, replace with Postgres. Schema sketch:
38
+ - `bookings(id, name, email, phone, date_start, date_end, status, notes, created_at)`
39
+
38
40
  ## About / Amenities
39
41
 
40
42
  - Pull from the brief's `must_have_sections`. Echo the brief's vibe.
@@ -1,8 +1,8 @@
1
1
  # --- lead-gen profile additions ---
2
- # Email notifications for contact-form submissions
2
+ # Email notifications for contact-form submissions via Resend
3
3
  RESEND_API_KEY=
4
+ RESEND_FROM_EMAIL=noreply@example.com
4
5
  CONTACT_TO_EMAIL=
5
- CONTACT_FROM_EMAIL=noreply@example.com
6
6
 
7
7
  # Optional: forward lead payloads to a CRM as JSON
8
8
  CRM_WEBHOOK_URL=
@@ -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
+ }