@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
|
@@ -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.
|
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
# --- booking profile additions ---
|
|
2
|
-
#
|
|
3
|
-
DATABASE_URL=
|
|
4
|
-
|
|
5
|
-
# Email confirmation
|
|
2
|
+
# Email confirmation (guest + admin) via Resend
|
|
6
3
|
RESEND_API_KEY=
|
|
7
|
-
|
|
8
|
-
|
|
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,
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
22
|
-
|
|
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:
|
|
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
|
|
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
|
-
-
|
|
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 (
|
|
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
|
+
}
|
|
@@ -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"
|
|
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
|
|
24
|
+
## Deposit checkout
|
|
25
25
|
|
|
26
|
-
|
|
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=
|