Appointment requests with availability windows and email confirmation.
An appointment-request flow: visitors pick a free slot inside the availability windows the owner sets in Studio, requests arrive as a queue in Studio, and confirming a request emails the visitor automatically. It's a request-and-confirm flow by design — the owner always has the final say, so no double bookings and no calendar sync required.
Availability lives in a global (weekly windows + slot length) the owner edits in Studio. A custom route computes free slots for a given date — windows minus already-confirmed bookings. Requests are drafts of a booking type; publishing a booking is confirming it, which both blocks the slot for everyone else and triggers the confirmation email via the afterPublish hook.
You are adding appointment booking to a Prelo project. Outbound email is required for confirmations — check the SMTP env vars `prelo doctor` reports and tell the user if they're missing.
1. **Availability global.** In `cms.config.js` under `globals:`:
```js
booking: {
fields: {
slotMinutes: { type: "number", required: true }, // e.g. 30
leadHours: { type: "number" }, // min notice, default 24
windows: { type: "list", of: {
day: { type: "select", options: ["mon","tue","wed","thu","fri","sat","sun"], required: true },
start: { type: "text", required: true }, // "09:00"
end: { type: "text", required: true }, // "17:00"
}},
},
},
```
Read it via `GET /api/globals/booking`; the owner edits it in Studio's globals.
2. **Booking type.**
```js
booking: {
label: "Bookings",
fields: {
when: { type: "text", required: true }, // ISO start, e.g. 2026-09-12T10:00
name: { type: "text", required: true },
email: { type: "text", required: true },
message: { type: "text" },
},
studio: { columns: ["when", "name"] },
},
```
Draft = requested. **Published = confirmed.** Deleted = declined (see step 6 for the decline email caveat).
3. **Slots route.** `"GET /booking/slots"` in `routes:` — query `?date=2026-09-12`:
- Load the global; find windows matching that weekday; slice into `slotMinutes` steps.
- Remove slots that collide with **published** bookings on that date (query the `content` table for type `booking`, status published, `when` starting with the date).
- Remove slots earlier than now + `leadHours` (default 24).
- Respond `{ date, slots: ["09:00","09:30",…] }`. Empty array is a valid answer.
4. **Request route.** `"POST /booking"`:
- Validate name/email/message; validate `when` is exactly one of the currently free slots for its date (recompute server-side — never trust the client's slot list). Honeypot `website`: silently accept and discard. Rate-limit 3/hour/IP via `x_booking_hits` created in `hooks.boot`.
- Insert as a **draft** booking (title = `{when} — {name}`). Requests do **not** block the slot until confirmed; if two people request the same slot, the owner confirms one and the other's slot validation would have passed — handle this by re-checking collisions in the confirmation hook (step 5) and refusing there is impossible (hooks can't veto), so instead: on `afterPublish`, if another published booking already holds the slot, email the owner a conflict warning instead of confirming the visitor. Keep this logic small but present.
- Optionally email the owner "New booking request" per new draft.
5. **Confirmation email.** In `cms.config.js` `hooks:`, add `afterPublish: (row, db) => …`. When `row.type === "booking"`, send the visitor a confirmation email: date/time formatted, site name, plus the owner's reply-to. This makes Studio's Publish button the entire confirmation workflow — no custom Studio work at all.
6. **Frontend.** A booking page/section: date picker (native `<input type="date">`, min = today) → fetches `/api/x/booking/slots?date=…` → renders slot buttons → form (name, email, message, honeypot) posts to `/api/x/booking`. States: "Requested — you'll get an email when it's confirmed" on success; slot-taken (`409` from the route) re-fetches slots and asks the visitor to pick again; inputs preserved on failure. For declines, note to the owner (README comment) that deleting a request sends nothing — decline by emailing the visitor directly, or ask the agent to add a `declined` flag flow later.
7. **Verify.** Set windows in Studio; request a slot; confirm it in Studio; check the email, the slot vanishing from `/slots`, and the double-request conflict path.The owner sets their week once in Studio globals, then lives in the Bookings list: publish to confirm (visitor gets the email, slot locks), leave as draft to think, delete to decline. Changing slotMinutes or windows reshapes future availability instantly.
Contact form with spam protection and email notifications, plus a submissions inbox in Studio.
Filterable gallery with lightbox, fed straight from the media library.
Signup form with double opt-in, delivering to Resend, Mailchimp or Buttondown.