@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
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: What this blog covers
3
+ date: 2026-05-22
4
+ summary: The topics I plan to write about and why they matter.
5
+ author: {{OWNER_OR_CLIENT}}
6
+ ---
7
+
8
+ A few things I plan to write about here:
9
+
10
+ - The work behind the work. Not the highlight reel, the actual decisions.
11
+ - Small lessons that compound. The kind of thing you wish someone had told you a year ago.
12
+ - Reading notes, recommendations, and the occasional review.
13
+
14
+ I would rather publish ten honest posts than one perfect one, so expect mistakes. I will correct them in public when I find them.
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: Welcome to {{DISPLAY_NAME}}
3
+ date: 2026-05-23
4
+ summary: A short note on why this blog exists and what to expect.
5
+ author: {{OWNER_OR_CLIENT}}
6
+ ---
7
+
8
+ Welcome. This is the first post on {{DISPLAY_NAME}}.
9
+
10
+ I started this site to share what I learn and to give readers a single place to follow along. New posts will appear here regularly.
11
+
12
+ If you have a question or a suggestion, the contact link in the footer is the best way to reach me.
@@ -0,0 +1,43 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import matter from 'gray-matter';
5
+
6
+ const POSTS_DIR = join(process.cwd(), 'content', 'posts');
7
+
8
+ export type Post = {
9
+ slug: string;
10
+ title: string;
11
+ date: string;
12
+ summary: string;
13
+ author: string;
14
+ body: string;
15
+ };
16
+
17
+ async function readPostFile(filename: string): Promise<Post | null> {
18
+ if (!filename.endsWith('.mdx')) return null;
19
+ const slug = filename.replace(/\.mdx$/, '');
20
+ const raw = await readFile(join(POSTS_DIR, filename), 'utf8');
21
+ const { data, content } = matter(raw);
22
+ return {
23
+ slug,
24
+ title: String(data.title || slug),
25
+ date: String(data.date || new Date().toISOString()),
26
+ summary: String(data.summary || ''),
27
+ author: String(data.author || ''),
28
+ body: content,
29
+ };
30
+ }
31
+
32
+ export async function getAllPosts(): Promise<Post[]> {
33
+ if (!existsSync(POSTS_DIR)) return [];
34
+ const files = await readdir(POSTS_DIR);
35
+ const posts = (await Promise.all(files.map(readPostFile))).filter((p): p is Post => p !== null);
36
+ return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
37
+ }
38
+
39
+ export async function getPostBySlug(slug: string): Promise<Post | null> {
40
+ const filename = `${slug}.mdx`;
41
+ if (!existsSync(join(POSTS_DIR, filename))) return null;
42
+ return readPostFile(filename);
43
+ }
@@ -0,0 +1,9 @@
1
+ // MDX is handled at runtime via next-mdx-remote (see app/posts/[slug]/page.tsx).
2
+ // No next.config.mjs changes are required for the v1 blog overlay.
3
+ //
4
+ // If you later switch to compile-time MDX (@next/mdx) you will need to:
5
+ // 1. npm install @next/mdx @mdx-js/loader @mdx-js/react
6
+ // 2. Wrap your next.config.mjs export with createMDX({})
7
+ // 3. Add "mdx" to pageExtensions
8
+ //
9
+ // This file is shipped as a placeholder so the convention is visible.
@@ -0,0 +1,6 @@
1
+ {
2
+ "dependencies": {
3
+ "next-mdx-remote": "^5.0.0",
4
+ "gray-matter": "^4.0.3"
5
+ }
6
+ }
@@ -1,16 +1,8 @@
1
1
  # --- booking profile additions ---
2
- # Database for Booking entity (Neon recommended; any Postgres works)
3
- DATABASE_URL=
4
-
5
- # Email confirmation
2
+ # Email confirmation (guest + admin) via Resend
6
3
  RESEND_API_KEY=
7
- BOOKING_TO_EMAIL=
8
- BOOKING_FROM_EMAIL=noreply@example.com
9
-
10
- # Optional: Stripe deposit checkout (enable only if `payments` addon is on)
11
- STRIPE_SECRET_KEY=
12
- STRIPE_PUBLISHABLE_KEY=
13
- STRIPE_WEBHOOK_SECRET=
4
+ RESEND_FROM_EMAIL=noreply@example.com
5
+ ADMIN_EMAIL=
14
6
 
15
7
  # Public app URL (Vercel sets this for production)
16
8
  NEXT_PUBLIC_APP_URL=http://localhost:3000
@@ -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=