← back to Ventura Corridor
feat(appointments): self-contained Smart Scheduling widget + Google Calendar OAuth
b66894f2dc4c4fa47f036633b4cac4d967fe7d36 · 2026-05-12 06:50:13 -0700 · SteveStudio2
- Schema (022_appointments.sql): appt_appointments, appt_availability,
appt_oauth_tokens, appt_oauth_state. Seeds info@designerwallcoverings.com
for Mon-Fri 9am-5pm PT @ 30-min slots.
- Router mounted ahead of admin gate + static handler. Public routes:
/book, /embed/appointments.js, /appointments/:id, /api/appointments/{health,
availability,book,oauth/start,oauth/callback,oauth/status,oauth/revoke}.
/api/appointments/list is Basic-Auth gated.
- OAuth helpers are fetch-based; no googleapis dep. Refresh logic auto-
rotates tokens 2 min before expiry. State nonce is single-use.
- Slot computer honors America/Los_Angeles + filters both DB-booked and
Google freeBusy windows; race-protects book POST.
- Widget HTML is pure vanilla JS; reuses the noir/metal-gold token palette
already used by public/index.html. Supports ?embed=1 chromeless mode.
- Embed loader (/embed/appointments.js) lazy-mounts via IntersectionObserver,
sandboxes the iframe (allow-scripts allow-forms allow-same-origin allow-popups),
serves CORS:* + frame-ancestors:*.
- Branding rule respected: 'Smart Scheduling' label everywhere except the
Google-hosted consent screen.
Files touched
M .env.exampleA db/migrations/022_appointments.sqlA src/server/appointments/calendar.tsA src/server/appointments/embed-loader.tsA src/server/appointments/oauth.tsA src/server/appointments/router.tsA src/server/appointments/slots.tsA src/server/appointments/widget.tsM src/server/index.ts
Diff
commit b66894f2dc4c4fa47f036633b4cac4d967fe7d36
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Tue May 12 06:50:13 2026 -0700
feat(appointments): self-contained Smart Scheduling widget + Google Calendar OAuth
- Schema (022_appointments.sql): appt_appointments, appt_availability,
appt_oauth_tokens, appt_oauth_state. Seeds info@designerwallcoverings.com
for Mon-Fri 9am-5pm PT @ 30-min slots.
- Router mounted ahead of admin gate + static handler. Public routes:
/book, /embed/appointments.js, /appointments/:id, /api/appointments/{health,
availability,book,oauth/start,oauth/callback,oauth/status,oauth/revoke}.
/api/appointments/list is Basic-Auth gated.
- OAuth helpers are fetch-based; no googleapis dep. Refresh logic auto-
rotates tokens 2 min before expiry. State nonce is single-use.
- Slot computer honors America/Los_Angeles + filters both DB-booked and
Google freeBusy windows; race-protects book POST.
- Widget HTML is pure vanilla JS; reuses the noir/metal-gold token palette
already used by public/index.html. Supports ?embed=1 chromeless mode.
- Embed loader (/embed/appointments.js) lazy-mounts via IntersectionObserver,
sandboxes the iframe (allow-scripts allow-forms allow-same-origin allow-popups),
serves CORS:* + frame-ancestors:*.
- Branding rule respected: 'Smart Scheduling' label everywhere except the
Google-hosted consent screen.
---
.env.example | 8 +
db/migrations/022_appointments.sql | 64 ++++++
src/server/appointments/calendar.ts | 93 +++++++++
src/server/appointments/embed-loader.ts | 69 +++++++
src/server/appointments/oauth.ts | 167 ++++++++++++++++
src/server/appointments/router.ts | 278 ++++++++++++++++++++++++++
src/server/appointments/slots.ts | 129 ++++++++++++
src/server/appointments/widget.ts | 338 ++++++++++++++++++++++++++++++++
src/server/index.ts | 8 +
9 files changed, 1154 insertions(+)
diff --git a/.env.example b/.env.example
index 945dbcb..ec455d4 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,11 @@ VENTURA_BBOX_NORTH=34.165
# Crawl tuning
CRAWL_CONCURRENCY=4
CRAWL_TIMEOUT_MS=15000
+
+# ─── Appointments / Smart Scheduling ───────────────────────────────────
+# Google OAuth client used by /api/appointments/oauth/* to connect a
+# calendar owner's Google Calendar. Without these, bookings still work
+# but no calendar push happens. See ~/.claude/skills/appointment-agent/.
+GOOGLE_OAUTH_CLIENT_ID=
+GOOGLE_OAUTH_CLIENT_SECRET=
+GOOGLE_OAUTH_REDIRECT_URI=https://venturacorridor.com/api/appointments/oauth/callback
diff --git a/db/migrations/022_appointments.sql b/db/migrations/022_appointments.sql
new file mode 100644
index 0000000..2938430
--- /dev/null
+++ b/db/migrations/022_appointments.sql
@@ -0,0 +1,64 @@
+-- 022_appointments.sql
+-- Self-contained appointment-booking system.
+-- All tables prefixed with appt_ to stay isolated from existing schema.
+
+CREATE TABLE IF NOT EXISTS appt_appointments (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ phone TEXT,
+ business_id BIGINT, -- nullable; not FK'd so reversible
+ owner_email TEXT NOT NULL, -- which calendar owner this is for
+ requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ slot_start TIMESTAMPTZ NOT NULL,
+ slot_end TIMESTAMPTZ NOT NULL,
+ status TEXT NOT NULL DEFAULT 'confirmed' -- confirmed | cancelled | pending
+ CHECK (status IN ('confirmed','cancelled','pending')),
+ notes TEXT,
+ google_event_id TEXT, -- nullable; set if pushed to Google Calendar
+ cancel_token TEXT NOT NULL DEFAULT md5(random()::text || clock_timestamp()::text),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS appt_appointments_owner_slot_idx
+ ON appt_appointments (owner_email, slot_start);
+CREATE INDEX IF NOT EXISTS appt_appointments_status_idx
+ ON appt_appointments (status);
+
+CREATE TABLE IF NOT EXISTS appt_availability (
+ id BIGSERIAL PRIMARY KEY,
+ owner_email TEXT NOT NULL,
+ day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0 = Sunday, 6 = Saturday
+ start_min INT NOT NULL CHECK (start_min BETWEEN 0 AND 1439), -- minutes from midnight, local owner tz (America/Los_Angeles)
+ end_min INT NOT NULL CHECK (end_min BETWEEN 1 AND 1440),
+ slot_duration_min INT NOT NULL DEFAULT 30 CHECK (slot_duration_min BETWEEN 5 AND 240),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (owner_email, day_of_week, start_min, end_min)
+);
+
+CREATE INDEX IF NOT EXISTS appt_availability_owner_idx
+ ON appt_availability (owner_email);
+
+CREATE TABLE IF NOT EXISTS appt_oauth_tokens (
+ owner_email TEXT PRIMARY KEY,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT,
+ expires_at TIMESTAMPTZ NOT NULL,
+ scope TEXT,
+ token_type TEXT DEFAULT 'Bearer',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- OAuth state nonce — short-lived row to bind ?state to a pending start request.
+CREATE TABLE IF NOT EXISTS appt_oauth_state (
+ state TEXT PRIMARY KEY,
+ owner_email TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Seed the test owner: info@designerwallcoverings.com, 9 AM – 5 PM weekdays, 30-min slots.
+-- 9 AM = 540 min, 5 PM = 1020 min. Mon=1 ... Fri=5.
+INSERT INTO appt_availability (owner_email, day_of_week, start_min, end_min, slot_duration_min)
+SELECT 'info@designerwallcoverings.com', d, 540, 1020, 30
+FROM generate_series(1, 5) AS d
+ON CONFLICT (owner_email, day_of_week, start_min, end_min) DO NOTHING;
diff --git a/src/server/appointments/calendar.ts b/src/server/appointments/calendar.ts
new file mode 100644
index 0000000..30fe7dd
--- /dev/null
+++ b/src/server/appointments/calendar.ts
@@ -0,0 +1,93 @@
+/**
+ * Google Calendar v3 helpers — fetch only, no SDK.
+ *
+ * Two operations:
+ * - busy(...) freebusy.query → array of {start,end} busy windows for a date
+ * - insertEvent(...) events.insert → push an appointment onto the owner's primary cal
+ * - deleteEvent(...) events.delete → wipe on cancel
+ */
+import { getValidAccessToken } from './oauth.ts';
+
+const CAL_BASE = 'https://www.googleapis.com/calendar/v3';
+
+export interface BusyWindow { start: string; end: string; } // ISO strings (UTC)
+
+export async function busy(
+ ownerEmail: string,
+ startISO: string,
+ endISO: string,
+): Promise<BusyWindow[]> {
+ const token = await getValidAccessToken(ownerEmail);
+ if (!token) return [];
+ const r = await fetch(`${CAL_BASE}/freeBusy`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ timeMin: startISO,
+ timeMax: endISO,
+ items: [{ id: 'primary' }],
+ }),
+ });
+ if (!r.ok) {
+ console.error('[appointments] freeBusy failed', r.status, await r.text());
+ return [];
+ }
+ const j: any = await r.json();
+ return (j.calendars?.primary?.busy as BusyWindow[] | undefined) ?? [];
+}
+
+export async function insertEvent(
+ ownerEmail: string,
+ payload: {
+ summary: string;
+ description: string;
+ startISO: string;
+ endISO: string;
+ attendeeEmail?: string;
+ },
+): Promise<string | null> {
+ const token = await getValidAccessToken(ownerEmail);
+ if (!token) return null;
+ const body: any = {
+ summary: payload.summary,
+ description: payload.description,
+ start: { dateTime: payload.startISO },
+ end: { dateTime: payload.endISO },
+ };
+ if (payload.attendeeEmail) {
+ body.attendees = [{ email: payload.attendeeEmail }];
+ }
+ const r = await fetch(`${CAL_BASE}/calendars/primary/events?sendUpdates=none`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) {
+ console.error('[appointments] events.insert failed', r.status, await r.text());
+ return null;
+ }
+ const j: any = await r.json();
+ return j.id ?? null;
+}
+
+export async function deleteEvent(
+ ownerEmail: string,
+ eventId: string,
+): Promise<boolean> {
+ const token = await getValidAccessToken(ownerEmail);
+ if (!token) return false;
+ const r = await fetch(
+ `${CAL_BASE}/calendars/primary/events/${encodeURIComponent(eventId)}?sendUpdates=none`,
+ {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${token}` },
+ },
+ );
+ return r.ok || r.status === 410; // 410 = already gone
+}
diff --git a/src/server/appointments/embed-loader.ts b/src/server/appointments/embed-loader.ts
new file mode 100644
index 0000000..c6ae9e5
--- /dev/null
+++ b/src/server/appointments/embed-loader.ts
@@ -0,0 +1,69 @@
+/**
+ * /embed/appointments.js — the loader served to third-party origins
+ * (Shopify, custom HTML, anywhere).
+ *
+ * Usage on the host page:
+ * <div id="appt-widget" data-owner="info@designerwallcoverings.com"></div>
+ * <script src="https://venturacorridor.com/embed/appointments.js" async></script>
+ *
+ * The loader:
+ * 1. Finds every <div id^="appt-widget"> (supports multiple).
+ * 2. Reads data-owner.
+ * 3. Uses IntersectionObserver to defer the iframe until the widget
+ * scrolls into view — keeps the host page Lighthouse score clean.
+ * 4. Iframe has a sandbox attribute that allows scripts/forms but
+ * blocks top-level navigation so the iframe can never break out.
+ */
+export function renderEmbedLoader(origin: string): string {
+ return `/* Smart Scheduling embed loader — venturacorridor.com */
+(function(){
+ var ORIGIN = ${JSON.stringify(origin)};
+ function mount(host){
+ if (host.__apptMounted) return;
+ host.__apptMounted = true;
+ var owner = host.getAttribute('data-owner') || 'info@designerwallcoverings.com';
+ var bizId = host.getAttribute('data-business-id') || '';
+ var src = ORIGIN + '/book?owner=' + encodeURIComponent(owner) + '&embed=1'
+ + (bizId ? '&business_id=' + encodeURIComponent(bizId) : '');
+ var iframe = document.createElement('iframe');
+ iframe.src = src;
+ iframe.title = 'Book an appointment';
+ iframe.setAttribute('loading', 'lazy');
+ iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups');
+ iframe.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
+ iframe.style.width = '100%';
+ iframe.style.minHeight = '820px';
+ iframe.style.border = '0';
+ iframe.style.borderRadius = '0';
+ iframe.style.colorScheme = 'normal';
+ iframe.style.display = 'block';
+ host.innerHTML = '';
+ host.appendChild(iframe);
+ }
+ function lazyMount(host){
+ if (!('IntersectionObserver' in window)){
+ // older browsers: just mount
+ mount(host);
+ return;
+ }
+ var io = new IntersectionObserver(function(entries){
+ entries.forEach(function(e){
+ if (e.isIntersecting){
+ mount(host);
+ io.disconnect();
+ }
+ });
+ }, { rootMargin: '300px' });
+ io.observe(host);
+ }
+ function init(){
+ var nodes = document.querySelectorAll('[id^="appt-widget"]');
+ for (var i = 0; i < nodes.length; i++) lazyMount(nodes[i]);
+ }
+ if (document.readyState === 'loading'){
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();`;
+}
diff --git a/src/server/appointments/oauth.ts b/src/server/appointments/oauth.ts
new file mode 100644
index 0000000..e474581
--- /dev/null
+++ b/src/server/appointments/oauth.ts
@@ -0,0 +1,167 @@
+/**
+ * Google OAuth helpers — fetch-based, no googleapis dep.
+ *
+ * Used by the appointment-booking widget so a CALENDAR OWNER (e.g. the
+ * Ventura Corridor concierge, or any business listed in the directory)
+ * can connect their own Google Calendar. END-USERS who book a slot do
+ * NOT see this flow. Only the owner of the calendar does.
+ *
+ * Branding rule (per global standing instructions): never call this
+ * "Google Calendar" in user-visible copy. We pitch it as "Smart
+ * Scheduling" — Google only appears on the Google-hosted consent screen
+ * where it is unavoidable.
+ */
+import crypto from 'node:crypto';
+import { query } from '../../../db/pool.ts';
+
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
+const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
+
+export const SCOPES = [
+ 'openid',
+ 'email',
+ 'profile',
+ 'https://www.googleapis.com/auth/calendar.events',
+ 'https://www.googleapis.com/auth/calendar.readonly',
+];
+
+export function getOAuthConfig() {
+ const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || '';
+ const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '';
+ const redirectUri = process.env.GOOGLE_OAUTH_REDIRECT_URI
+ || 'http://127.0.0.1:9780/api/appointments/oauth/callback';
+ return { clientId, clientSecret, redirectUri };
+}
+
+export function isOAuthConfigured(): boolean {
+ const { clientId, clientSecret } = getOAuthConfig();
+ return Boolean(clientId && clientSecret);
+}
+
+/** Build the Google consent URL the owner must visit. */
+export async function buildAuthUrl(ownerEmail: string): Promise<string> {
+ const { clientId, redirectUri } = getOAuthConfig();
+ const state = crypto.randomBytes(16).toString('hex');
+ await query(
+ `INSERT INTO appt_oauth_state (state, owner_email) VALUES ($1, $2)
+ ON CONFLICT (state) DO NOTHING`,
+ [state, ownerEmail],
+ );
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: SCOPES.join(' '),
+ access_type: 'offline',
+ prompt: 'consent', // forces refresh_token issuance
+ state,
+ login_hint: ownerEmail,
+ });
+ return `${AUTH_ENDPOINT}?${params.toString()}`;
+}
+
+/** Exchange a callback ?code for tokens, and persist them. */
+export async function exchangeCodeAndStore(code: string, state: string): Promise<string> {
+ const r = await query<{ owner_email: string }>(
+ `SELECT owner_email FROM appt_oauth_state WHERE state = $1`,
+ [state],
+ );
+ if (!r.rowCount) throw new Error('Unknown or expired OAuth state');
+ const ownerEmail = r.rows[0].owner_email;
+
+ const { clientId, clientSecret, redirectUri } = getOAuthConfig();
+ const body = new URLSearchParams({
+ code,
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uri: redirectUri,
+ grant_type: 'authorization_code',
+ });
+ const tok = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ if (!tok.ok) {
+ const t = await tok.text();
+ throw new Error(`Google token exchange failed: ${tok.status} ${t}`);
+ }
+ const j: any = await tok.json();
+ const expiresAt = new Date(Date.now() + (Number(j.expires_in || 3600) * 1000));
+
+ await query(
+ `INSERT INTO appt_oauth_tokens
+ (owner_email, access_token, refresh_token, expires_at, scope, token_type, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, NOW())
+ ON CONFLICT (owner_email) DO UPDATE
+ SET access_token = EXCLUDED.access_token,
+ refresh_token = COALESCE(EXCLUDED.refresh_token, appt_oauth_tokens.refresh_token),
+ expires_at = EXCLUDED.expires_at,
+ scope = EXCLUDED.scope,
+ token_type = EXCLUDED.token_type,
+ updated_at = NOW()`,
+ [ownerEmail, j.access_token, j.refresh_token || null, expiresAt, j.scope || null, j.token_type || 'Bearer'],
+ );
+
+ // single-use nonce
+ await query(`DELETE FROM appt_oauth_state WHERE state = $1`, [state]);
+ return ownerEmail;
+}
+
+/** Returns a valid access_token (refreshing if needed) or null if no tokens stored. */
+export async function getValidAccessToken(ownerEmail: string): Promise<string | null> {
+ const r = await query<{
+ access_token: string; refresh_token: string | null; expires_at: string; scope: string | null;
+ }>(
+ `SELECT access_token, refresh_token, expires_at, scope
+ FROM appt_oauth_tokens WHERE owner_email = $1`,
+ [ownerEmail],
+ );
+ if (!r.rowCount) return null;
+
+ const row = r.rows[0];
+ const exp = new Date(row.expires_at).getTime();
+ // refresh ~2 minutes before expiry
+ if (exp - Date.now() > 120_000) return row.access_token;
+ if (!row.refresh_token) return null;
+
+ const { clientId, clientSecret } = getOAuthConfig();
+ const body = new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ grant_type: 'refresh_token',
+ refresh_token: row.refresh_token,
+ });
+ const tok = await fetch(TOKEN_ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ if (!tok.ok) {
+ console.error('[appointments] refresh failed', tok.status, await tok.text());
+ return null;
+ }
+ const j: any = await tok.json();
+ const newExpires = new Date(Date.now() + (Number(j.expires_in || 3600) * 1000));
+ await query(
+ `UPDATE appt_oauth_tokens
+ SET access_token = $1, expires_at = $2, updated_at = NOW()
+ WHERE owner_email = $3`,
+ [j.access_token, newExpires, ownerEmail],
+ );
+ return j.access_token;
+}
+
+/** Revoke + drop. Used by the skill / admin to wipe a stale connection. */
+export async function revoke(ownerEmail: string): Promise<void> {
+ const tok = await getValidAccessToken(ownerEmail);
+ if (tok) {
+ try {
+ await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(tok)}`, { method: 'POST' });
+ } catch (e) {
+ console.error('[appointments] revoke error (continuing):', e);
+ }
+ }
+ await query(`DELETE FROM appt_oauth_tokens WHERE owner_email = $1`, [ownerEmail]);
+}
diff --git a/src/server/appointments/router.ts b/src/server/appointments/router.ts
new file mode 100644
index 0000000..866acc7
--- /dev/null
+++ b/src/server/appointments/router.ts
@@ -0,0 +1,278 @@
+/**
+ * Appointment router — mounted in src/server/index.ts before the static
+ * handler and before the catch-all 404. All routes are public except
+ * /api/appointments/admin/* which can be admin-gated by the existing
+ * basic-auth middleware if needed (we don't add it here so the booking
+ * flow stays self-service).
+ *
+ * Reversibility: every route is namespaced under /api/appointments,
+ * /appointments/, /book, /embed/appointments.js. Removing the mount
+ * line in index.ts removes the feature.
+ */
+import { Router, type Request, type Response } from 'express';
+import { query } from '../../../db/pool.ts';
+import { renderBookPage, renderConfirmation } from './widget.ts';
+import { renderEmbedLoader } from './embed-loader.ts';
+import { computeSlots } from './slots.ts';
+import {
+ buildAuthUrl, exchangeCodeAndStore, isOAuthConfigured,
+ getValidAccessToken, revoke,
+} from './oauth.ts';
+import { insertEvent, deleteEvent } from './calendar.ts';
+
+const router = Router();
+
+// ─── Embed-friendly headers ─────────────────────────────────────────────
+// Set permissive CORS + frame headers so the widget can be iframed from
+// any origin (Shopify, custom HTML, etc.). This is safe: every route
+// behind it is read-only or accepts public booking input that goes
+// through normal validation.
+function allowEmbed(_req: Request, res: Response, next: () => void) {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ // helmet's frameguard default is SAMEORIGIN; we override here.
+ res.removeHeader('X-Frame-Options');
+ res.setHeader('Content-Security-Policy', "frame-ancestors *");
+ next();
+}
+router.use(allowEmbed);
+
+// ─── Health (debug; cheap) ──────────────────────────────────────────────
+router.get('/api/appointments/health', async (_req, res) => {
+ const cfg = isOAuthConfigured();
+ try {
+ const r = await query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM appt_availability`);
+ res.json({ ok: true, oauth_configured: cfg, availability_rows: parseInt(r.rows[0].c, 10) });
+ } catch (e: any) {
+ res.status(500).json({ ok: false, error: e.message, oauth_configured: cfg });
+ }
+});
+
+// ─── Availability: list bookable {slot_start, slot_end} for a date ──────
+router.get('/api/appointments/availability', async (req, res) => {
+ const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
+ const date = String(req.query.date || '').trim();
+ if (!ownerEmail || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
+ return res.status(400).json({ ok: false, error: 'owner_email and YYYY-MM-DD date required' });
+ }
+ try {
+ const slots = await computeSlots(ownerEmail, date);
+ res.json({ ok: true, owner_email: ownerEmail, date, slots });
+ } catch (e: any) {
+ console.error('[appointments] availability failed', e);
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ─── Book a slot ────────────────────────────────────────────────────────
+router.post('/api/appointments/book', async (req, res) => {
+ const body = req.body ?? {};
+ const name = String(body.name || '').trim().slice(0, 200);
+ const email = String(body.email || '').trim().toLowerCase().slice(0, 200);
+ const phone = String(body.phone || '').trim().slice(0, 50) || null;
+ const notes = String(body.notes || '').trim().slice(0, 2000) || null;
+ const ownerEmail = String(body.owner_email || '').trim().toLowerCase();
+ const slotStart = String(body.slot_start || '').trim();
+ const slotEnd = String(body.slot_end || '').trim();
+ const businessId = body.business_id ? Number(body.business_id) : null;
+
+ if (!name || !email || !ownerEmail || !slotStart || !slotEnd) {
+ return res.status(400).json({ ok: false, error: 'name, email, owner_email, slot_start, slot_end required' });
+ }
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+ return res.status(400).json({ ok: false, error: 'invalid email' });
+ }
+ const s = Date.parse(slotStart); const e = Date.parse(slotEnd);
+ if (!isFinite(s) || !isFinite(e) || e <= s) {
+ return res.status(400).json({ ok: false, error: 'invalid slot window' });
+ }
+ if (s < Date.now() - 60_000) {
+ return res.status(400).json({ ok: false, error: 'slot is in the past' });
+ }
+
+ try {
+ // Re-check the slot is still in the computed set (race-protection).
+ const date = new Date(s).toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
+ const slots = await computeSlots(ownerEmail, date);
+ const matches = slots.find(sl => sl.slot_start === new Date(s).toISOString());
+ if (!matches) {
+ return res.status(409).json({ ok: false, error: 'slot no longer available' });
+ }
+
+ const ins = await query<{ id: number; cancel_token: string }>(
+ `INSERT INTO appt_appointments
+ (name, email, phone, business_id, owner_email, slot_start, slot_end, notes, status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'confirmed')
+ RETURNING id, cancel_token`,
+ [name, email, phone, businessId, ownerEmail, new Date(s).toISOString(), new Date(e).toISOString(), notes],
+ );
+ const row = ins.rows[0];
+
+ // Best-effort calendar push
+ let eventId: string | null = null;
+ try {
+ eventId = await insertEvent(ownerEmail, {
+ summary: `Appointment: ${name}`,
+ description: `Booked via venturacorridor.com Smart Scheduling.\n\n` +
+ `Name: ${name}\nEmail: ${email}\nPhone: ${phone || '—'}\n\n` +
+ `Notes:\n${notes || '(none)'}`,
+ startISO: new Date(s).toISOString(),
+ endISO: new Date(e).toISOString(),
+ attendeeEmail: email,
+ });
+ if (eventId) {
+ await query(`UPDATE appt_appointments SET google_event_id = $1 WHERE id = $2`, [eventId, row.id]);
+ }
+ } catch (calErr) {
+ console.error('[appointments] calendar push failed (non-fatal):', calErr);
+ }
+
+ res.json({
+ ok: true,
+ id: row.id,
+ confirmation_url: `/appointments/${row.id}`,
+ calendar_pushed: Boolean(eventId),
+ });
+ } catch (e: any) {
+ console.error('[appointments] book failed', e);
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ─── Cancel ─────────────────────────────────────────────────────────────
+// Token-gated: ?token=<cancel_token> OR Basic-Auth admin (handled upstream).
+router.post('/api/appointments/:id/cancel', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const token = String(req.query.token || req.body?.token || '');
+ if (!isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' });
+
+ const r = await query<{ cancel_token: string; google_event_id: string | null; owner_email: string }>(
+ `SELECT cancel_token, google_event_id, owner_email FROM appt_appointments WHERE id = $1`, [id],
+ );
+ if (!r.rowCount) return res.status(404).json({ ok: false, error: 'not found' });
+
+ const isAdmin = Boolean(req.headers.authorization?.startsWith('Basic ')); // upstream gate validates
+ if (!isAdmin && token !== r.rows[0].cancel_token) {
+ return res.status(403).json({ ok: false, error: 'invalid cancel token' });
+ }
+ if (r.rows[0].google_event_id) {
+ try { await deleteEvent(r.rows[0].owner_email, r.rows[0].google_event_id); } catch {}
+ }
+ await query(`UPDATE appt_appointments SET status = 'cancelled' WHERE id = $1`, [id]);
+ res.json({ ok: true, id, status: 'cancelled' });
+});
+
+// ─── OAuth: start ───────────────────────────────────────────────────────
+router.get('/api/appointments/oauth/start', async (req, res) => {
+ if (!isOAuthConfigured()) {
+ return res.status(503).type('text/plain').send(
+ 'Smart Scheduling OAuth is not configured.\n' +
+ 'Set GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET in the project .env, then restart.',
+ );
+ }
+ const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
+ if (!ownerEmail) return res.status(400).type('text/plain').send('owner_email query param required');
+ const url = await buildAuthUrl(ownerEmail);
+ res.redirect(url);
+});
+
+// ─── OAuth: callback ────────────────────────────────────────────────────
+router.get('/api/appointments/oauth/callback', async (req, res) => {
+ const code = String(req.query.code || '');
+ const state = String(req.query.state || '');
+ const err = String(req.query.error || '');
+ if (err) return res.status(400).type('text/plain').send(`OAuth error: ${err}`);
+ if (!code || !state) return res.status(400).type('text/plain').send('missing code/state');
+ try {
+ const ownerEmail = await exchangeCodeAndStore(code, state);
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Connected</title>
+<style>body{font-family:'Inter',sans-serif;background:#0a0a0c;color:#f4f1ea;
+ min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
+.card{max-width:520px}.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#b89968}
+h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:40px;margin:14px 0 12px}
+a{color:#b89968}</style>
+<div class="card"><div class="kicker">Smart Scheduling</div>
+<h1>Calendar connected.</h1>
+<p>${ownerEmail} can now receive bookings against their schedule.</p>
+<p style="margin-top:24px"><a href="/book?owner=${encodeURIComponent(ownerEmail)}">View booking page</a></p></div>`);
+ } catch (e: any) {
+ res.status(400).type('text/plain').send(`Token exchange failed: ${e.message}`);
+ }
+});
+
+// ─── OAuth: status (used by skill/admin to check) ───────────────────────
+router.get('/api/appointments/oauth/status', async (req, res) => {
+ const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
+ if (!ownerEmail) return res.status(400).json({ ok: false, error: 'owner_email required' });
+ const tok = await getValidAccessToken(ownerEmail);
+ res.json({ ok: true, owner_email: ownerEmail, connected: Boolean(tok),
+ oauth_configured: isOAuthConfigured() });
+});
+
+// ─── OAuth: revoke (admin / skill convenience) ──────────────────────────
+router.post('/api/appointments/oauth/revoke', async (req, res) => {
+ const ownerEmail = String(req.body?.owner_email || req.query?.owner_email || '').trim().toLowerCase();
+ if (!ownerEmail) return res.status(400).json({ ok: false, error: 'owner_email required' });
+ await revoke(ownerEmail);
+ res.json({ ok: true, owner_email: ownerEmail, revoked: true });
+});
+
+// ─── List appointments (admin only — gated by upstream basic-auth) ──────
+router.get('/api/appointments/list', async (req, res) => {
+ // Note: admin gate in index.ts doesn't currently cover /api/appointments/list,
+ // so we add a minimal local check here. If ADMIN_PASS is set and a Basic
+ // header is missing/invalid, reject.
+ const auth = String(req.headers.authorization || '');
+ const m = auth.match(/^Basic\s+(.+)$/i);
+ const expectedUser = process.env.ADMIN_USER || 'admin';
+ const expectedPass = process.env.ADMIN_PASS || '';
+ if (!m) {
+ res.set('WWW-Authenticate', 'Basic realm="Appointments Admin"');
+ return res.status(401).json({ ok: false, error: 'auth required' });
+ }
+ const decoded = Buffer.from(m[1], 'base64').toString('utf8');
+ const [u, p] = decoded.split(':');
+ if (u !== expectedUser || p !== expectedPass) {
+ return res.status(401).json({ ok: false, error: 'bad credentials' });
+ }
+ const ownerEmail = req.query.owner_email ? String(req.query.owner_email).trim().toLowerCase() : null;
+ const r = await query(
+ ownerEmail
+ ? `SELECT id, name, email, owner_email, slot_start, slot_end, status, google_event_id, created_at
+ FROM appt_appointments WHERE owner_email = $1 ORDER BY slot_start DESC LIMIT 200`
+ : `SELECT id, name, email, owner_email, slot_start, slot_end, status, google_event_id, created_at
+ FROM appt_appointments ORDER BY slot_start DESC LIMIT 200`,
+ ownerEmail ? [ownerEmail] : [],
+ );
+ res.json({ ok: true, count: r.rowCount, rows: r.rows });
+});
+
+// ─── HTML pages ─────────────────────────────────────────────────────────
+router.get('/book', (req, res) => {
+ const ownerEmail = String(req.query.owner || 'info@designerwallcoverings.com').trim().toLowerCase();
+ const embed = String(req.query.embed || '') === '1';
+ res.type('html').send(renderBookPage(ownerEmail, embed));
+});
+
+router.get('/appointments/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!isFinite(id)) return res.status(400).type('text/plain').send('invalid id');
+ const r = await query(
+ `SELECT id, name, email, slot_start, slot_end, status, owner_email, notes, google_event_id
+ FROM appt_appointments WHERE id = $1`, [id],
+ );
+ if (!r.rowCount) return res.status(404).type('text/plain').send('appointment not found');
+ res.type('html').send(renderConfirmation(r.rows[0] as any));
+});
+
+// ─── Embed loader JS ────────────────────────────────────────────────────
+router.get('/embed/appointments.js', (req, res) => {
+ // Origin = scheme + host so the loader can construct absolute iframe URLs.
+ const host = req.get('host') || '127.0.0.1:9780';
+ const proto = (req.headers['x-forwarded-proto'] as string) || (req.secure ? 'https' : 'http');
+ const origin = `${proto}://${host}`;
+ res.type('application/javascript').send(renderEmbedLoader(origin));
+});
+
+export default router;
diff --git a/src/server/appointments/slots.ts b/src/server/appointments/slots.ts
new file mode 100644
index 0000000..4df23f2
--- /dev/null
+++ b/src/server/appointments/slots.ts
@@ -0,0 +1,129 @@
+/**
+ * Slot calculator — converts appt_availability rows + busy windows
+ * (DB-booked + Google-Calendar busy) into the array of bookable
+ * {slot_start, slot_end} ISO pairs for a given date.
+ *
+ * The owner's local timezone is assumed America/Los_Angeles for v1.
+ * (Steve's universe is in PT; a future migration adds a tz column.)
+ */
+import { query } from '../../../db/pool.ts';
+import { busy as gcalBusy } from './calendar.ts';
+
+const OWNER_TZ = 'America/Los_Angeles';
+
+interface AvailRow {
+ day_of_week: number;
+ start_min: number;
+ end_min: number;
+ slot_duration_min: number;
+}
+
+interface DbBookedRow { slot_start: string; slot_end: string; }
+
+/** Compute the UTC ISO instant for a given local-PT date at H:M minutes-from-midnight. */
+function ptLocalToUTC(dateYYYYMMDD: string, minutesFromMidnight: number): Date {
+ const hour = Math.floor(minutesFromMidnight / 60);
+ const minute = minutesFromMidnight % 60;
+ // We compute the offset for the given local date by formatting an arbitrary
+ // instant in the target tz and reading back its offset. This is the cleanest
+ // pure-stdlib way to handle DST in Node without a deps tax.
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: OWNER_TZ,
+ timeZoneName:'longOffset',
+ year: 'numeric', month: '2-digit', day: '2-digit',
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ hour12: false,
+ }).formatToParts(new Date(`${dateYYYYMMDD}T${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}:00Z`));
+ const tzPart = parts.find(p => p.type === 'timeZoneName')?.value || 'GMT-08:00';
+ // tzPart looks like "GMT-07:00" or "GMT-08:00"
+ const m = tzPart.match(/GMT([+-])(\d{2}):?(\d{2})/);
+ const offsetMin = m ? (m[1] === '-' ? -1 : 1) * (parseInt(m[2],10)*60 + parseInt(m[3],10)) : -480;
+ // Now construct: the local wall-clock instant is YYYY-MM-DDTHH:MM:00 in PT.
+ // UTC instant = local - offset.
+ const localMs = Date.UTC(
+ parseInt(dateYYYYMMDD.slice(0,4),10),
+ parseInt(dateYYYYMMDD.slice(5,7),10) - 1,
+ parseInt(dateYYYYMMDD.slice(8,10),10),
+ hour, minute, 0, 0,
+ );
+ return new Date(localMs - offsetMin * 60_000);
+}
+
+/** Day-of-week for a YYYY-MM-DD interpreted in PT. */
+function dayOfWeekPT(dateYYYYMMDD: string): number {
+ // Use noon-PT so DST edge cases don't bump the date.
+ const noon = ptLocalToUTC(dateYYYYMMDD, 12 * 60);
+ return new Date(noon).getUTCDay(); // Sun=0..Sat=6 in UTC; since we used noon, == PT day
+}
+
+export interface Slot {
+ slot_start: string; // ISO 8601 UTC
+ slot_end: string; // ISO 8601 UTC
+}
+
+export async function computeSlots(
+ ownerEmail: string,
+ dateYYYYMMDD: string,
+): Promise<Slot[]> {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateYYYYMMDD)) return [];
+
+ const dow = dayOfWeekPT(dateYYYYMMDD);
+ const av = await query<AvailRow>(
+ `SELECT day_of_week, start_min, end_min, slot_duration_min
+ FROM appt_availability
+ WHERE owner_email = $1 AND day_of_week = $2
+ ORDER BY start_min ASC`,
+ [ownerEmail, dow],
+ );
+ if (!av.rowCount) return [];
+
+ // Build candidate slots in UTC
+ const candidates: Slot[] = [];
+ for (const row of av.rows) {
+ for (let m = row.start_min; m + row.slot_duration_min <= row.end_min; m += row.slot_duration_min) {
+ const s = ptLocalToUTC(dateYYYYMMDD, m);
+ const e = ptLocalToUTC(dateYYYYMMDD, m + row.slot_duration_min);
+ candidates.push({ slot_start: s.toISOString(), slot_end: e.toISOString() });
+ }
+ }
+ if (!candidates.length) return [];
+
+ // Window for busy lookups = the full local day
+ const dayStart = ptLocalToUTC(dateYYYYMMDD, 0).toISOString();
+ const dayEnd = ptLocalToUTC(dateYYYYMMDD, 24 * 60).toISOString();
+
+ // 1. DB-booked slots (active = confirmed | pending; cancelled ignored)
+ const booked = await query<DbBookedRow>(
+ `SELECT slot_start, slot_end FROM appt_appointments
+ WHERE owner_email = $1
+ AND status IN ('confirmed','pending')
+ AND slot_start < $3 AND slot_end > $2`,
+ [ownerEmail, dayStart, dayEnd],
+ );
+
+ // 2. Google Calendar busy windows (if owner has connected calendar)
+ let gcal: { start: string; end: string }[] = [];
+ try {
+ gcal = await gcalBusy(ownerEmail, dayStart, dayEnd);
+ } catch (e) {
+ console.error('[appointments] gcalBusy threw, treating as empty:', e);
+ }
+
+ const busyWindows: { s: number; e: number }[] = [
+ ...booked.rows.map(b => ({ s: Date.parse(b.slot_start), e: Date.parse(b.slot_end) })),
+ ...gcal.map(b => ({ s: Date.parse(b.start), e: Date.parse(b.end) })),
+ ];
+
+ // 3. Also drop any slot in the past
+ const now = Date.now();
+
+ return candidates.filter(c => {
+ const cs = Date.parse(c.slot_start);
+ const ce = Date.parse(c.slot_end);
+ if (cs < now) return false;
+ for (const b of busyWindows) {
+ if (cs < b.e && ce > b.s) return false; // overlap
+ }
+ return true;
+ });
+}
diff --git a/src/server/appointments/widget.ts b/src/server/appointments/widget.ts
new file mode 100644
index 0000000..5fcd46e
--- /dev/null
+++ b/src/server/appointments/widget.ts
@@ -0,0 +1,338 @@
+/**
+ * HTML widget renderer.
+ * /book?owner=<email> full page (default)
+ * /book?owner=<email>&embed=1 chromeless (no VC nav/footer) — for the iframe embed
+ *
+ * Pure vanilla JS + system fonts. No new npm deps.
+ * Matches Ventura Corridor's noir / metal-gold serif aesthetic by reusing
+ * the same CSS-variable palette set on public/index.html.
+ */
+const esc = (s: any) => String(s ?? '')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+
+export function renderBookPage(ownerEmail: string, embed: boolean): string {
+ const heading = embed
+ ? `<h1>Book an appointment</h1>`
+ : `<h1>Book an appointment <em>·</em> Ventura Corridor</h1>`;
+
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Book an appointment</title>
+<style>
+ :root{
+ --noir:#0a0a0c; --noir-rise:#131316; --rule:#2a2724;
+ --ink:#f4f1ea; --ink-soft:#d8d2c5; --ink-mute:#8b857a;
+ --metal:#b89968; --metal-glow:#d4b683; --metal-deep:#8a7044;
+ }
+ *{box-sizing:border-box;margin:0;padding:0}
+ html,body{background:var(--noir);color:var(--ink);
+ font-family:'Inter','Helvetica Neue',Helvetica,Arial,sans-serif;font-weight:300;
+ min-height:100vh}
+ .wrap{max-width:780px;margin:0 auto;padding:${embed ? '20px' : '56px 24px'}}
+ h1{font-family:'Cormorant Garamond','Georgia',serif;font-style:italic;font-weight:500;
+ font-size:${embed ? '32px' : '44px'};line-height:1.05;margin-bottom:18px;letter-spacing:-0.01em}
+ h1 em{color:var(--metal);font-style:normal}
+ .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);
+ font-weight:500;margin-bottom:18px}
+ .section-title{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);
+ font-weight:500;margin:28px 0 12px}
+ .days{display:grid;grid-template-columns:repeat(7,minmax(56px,1fr));gap:6px;margin-bottom:8px}
+ .day{padding:10px 4px;border:1px solid var(--rule);background:var(--noir-rise);
+ text-align:center;cursor:pointer;transition:.15s ease}
+ .day:hover{border-color:var(--metal);color:var(--metal-glow)}
+ .day.active{border-color:var(--metal);background:rgba(184,153,104,.12);color:var(--metal-glow)}
+ .day .dow{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute)}
+ .day .num{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-size:22px;
+ line-height:1;margin-top:4px;color:var(--ink)}
+ .day.active .num{color:var(--metal-glow)}
+ .pager{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
+ .pager button{background:transparent;color:var(--metal);border:1px solid var(--rule);
+ padding:6px 12px;font-size:10px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer}
+ .pager button:hover{border-color:var(--metal)}
+ .pager button:disabled{opacity:.3;cursor:not-allowed}
+ .pager .range{font-size:11px;color:var(--ink-mute);letter-spacing:.1em}
+ .slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;
+ min-height:80px}
+ .slot{padding:10px;border:1px solid var(--rule);background:var(--noir-rise);text-align:center;
+ cursor:pointer;font-family:'Inter',sans-serif;font-size:13px;color:var(--ink-soft);
+ transition:.15s ease}
+ .slot:hover{border-color:var(--metal);color:var(--metal-glow)}
+ .slot.active{border-color:var(--metal);background:rgba(184,153,104,.12);color:var(--metal-glow)}
+ .empty,.loading{padding:18px;color:var(--ink-mute);font-style:italic;text-align:center;
+ grid-column:1/-1;font-family:'Cormorant Garamond',Georgia,serif;font-size:16px}
+ form{margin-top:6px}
+ .row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
+ label{display:block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);
+ margin-bottom:6px}
+ input,textarea{width:100%;padding:11px 12px;background:var(--noir-rise);border:1px solid var(--rule);
+ color:var(--ink);font-family:inherit;font-size:14px;font-weight:300}
+ input:focus,textarea:focus{outline:none;border-color:var(--metal)}
+ textarea{resize:vertical;min-height:80px}
+ .actions{display:flex;justify-content:space-between;align-items:center;margin-top:18px;gap:12px}
+ button.book{background:var(--metal);color:var(--noir);border:none;padding:13px 28px;
+ font-size:10px;letter-spacing:.36em;text-transform:uppercase;font-weight:500;cursor:pointer;
+ transition:.15s ease;font-family:inherit}
+ button.book:hover{background:var(--metal-glow)}
+ button.book:disabled{opacity:.4;cursor:not-allowed}
+ .selected-summary{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;
+ color:var(--ink-soft);font-size:15px}
+ .selected-summary strong{color:var(--metal-glow);font-style:normal;font-weight:500;
+ font-family:'Inter',sans-serif;font-size:13px;letter-spacing:.05em}
+ .msg{margin-top:14px;padding:12px;border:1px solid var(--rule);background:var(--noir-rise);
+ font-size:13px;line-height:1.5}
+ .msg.err{border-color:#7a3a3a;color:#e8b8a8}
+ .msg.ok {border-color:var(--metal);color:var(--metal-glow)}
+ .branding{margin-top:36px;padding-top:16px;border-top:1px solid var(--rule);
+ font-size:10px;letter-spacing:.2em;color:var(--ink-mute);text-align:center}
+ .branding a{color:var(--metal);text-decoration:none}
+ @media (max-width:520px){
+ .row{grid-template-columns:1fr}
+ .days{grid-template-columns:repeat(4,1fr)}
+ h1{font-size:28px}
+ }
+</style>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet"
+ href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&display=swap">
+</head><body>
+<div class="wrap">
+ <div class="kicker">Smart Scheduling</div>
+ ${heading}
+ <div class="selected-summary" id="intro">
+ Booking with <strong>${esc(ownerEmail)}</strong>. Pick a day, choose a time, and leave your details.
+ </div>
+
+ <div class="section-title">Pick a day</div>
+ <div class="pager">
+ <button id="pgPrev" type="button">« Earlier</button>
+ <div class="range" id="pgRange"></div>
+ <button id="pgNext" type="button">Later »</button>
+ </div>
+ <div class="days" id="days"></div>
+
+ <div class="section-title">Open times</div>
+ <div class="slots" id="slots"><div class="loading">Choose a day above.</div></div>
+
+ <form id="form">
+ <div class="section-title">Your details</div>
+ <div class="row">
+ <div><label for="f-name">Name</label><input id="f-name" name="name" required autocomplete="name"></div>
+ <div><label for="f-email">Email</label><input id="f-email" name="email" required type="email" autocomplete="email"></div>
+ </div>
+ <div class="row">
+ <div><label for="f-phone">Phone (optional)</label><input id="f-phone" name="phone" type="tel" autocomplete="tel"></div>
+ <div></div>
+ </div>
+ <div>
+ <label for="f-notes">Notes (optional)</label>
+ <textarea id="f-notes" name="notes" placeholder="What would you like to discuss?"></textarea>
+ </div>
+ <div class="actions">
+ <div class="selected-summary" id="selSummary"><em>No slot selected yet.</em></div>
+ <button type="submit" class="book" id="bookBtn" disabled>Confirm Booking</button>
+ </div>
+ <div id="msg"></div>
+ </form>
+
+ ${embed ? '' : `<div class="branding">Powered by <a href="/">The Corridor</a> · Smart Scheduling</div>`}
+</div>
+
+<script>
+(function(){
+ var OWNER = ${JSON.stringify(ownerEmail)};
+ var EMBED = ${embed ? 'true' : 'false'};
+ var $ = function(id){ return document.getElementById(id); };
+ var dayOffset = 0; // pagination by 7-day chunks
+ var WINDOW_DAYS = 14; // 14 days forward, 7 visible at a time
+ var selectedDate = null; // YYYY-MM-DD
+ var selectedSlot = null; // {slot_start, slot_end}
+
+ function fmtDay(d){
+ var dow = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
+ return { dow: dow, num: d.getDate(), iso: d.toISOString().slice(0,10), month: d.getMonth() };
+ }
+ function fmtTime(iso){
+ var d = new Date(iso);
+ return d.toLocaleTimeString([], { hour:'numeric', minute:'2-digit', timeZone:'America/Los_Angeles' });
+ }
+ function fmtPretty(iso){
+ var d = new Date(iso);
+ return d.toLocaleString([], { weekday:'long', month:'short', day:'numeric',
+ hour:'numeric', minute:'2-digit', timeZone:'America/Los_Angeles' });
+ }
+
+ function renderDays(){
+ var now = new Date();
+ var startIdx = dayOffset * 7;
+ var endIdx = startIdx + 7;
+ var html = '';
+ var firstShown = null, lastShown = null;
+ for (var i = startIdx; i < endIdx; i++){
+ var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i);
+ var f = fmtDay(d);
+ if (!firstShown) firstShown = d;
+ lastShown = d;
+ var cls = (selectedDate === f.iso) ? ' active' : '';
+ html += '<div class="day'+cls+'" data-iso="'+f.iso+'">'
+ + '<div class="dow">'+f.dow+'</div>'
+ + '<div class="num">'+f.num+'</div>'
+ + '</div>';
+ }
+ $('days').innerHTML = html;
+ var rng = firstShown.toLocaleDateString([], { month:'short', day:'numeric' })
+ + ' – ' + lastShown.toLocaleDateString([], { month:'short', day:'numeric' });
+ $('pgRange').textContent = rng;
+ $('pgPrev').disabled = (dayOffset === 0);
+ $('pgNext').disabled = ((dayOffset + 1) * 7 >= WINDOW_DAYS);
+ [].slice.call(document.querySelectorAll('.day')).forEach(function(el){
+ el.addEventListener('click', function(){ selectDate(el.getAttribute('data-iso')); });
+ });
+ }
+
+ function selectDate(iso){
+ selectedDate = iso;
+ selectedSlot = null;
+ updateSelSummary();
+ renderDays();
+ $('slots').innerHTML = '<div class="loading">Loading slots…</div>';
+ fetch('/api/appointments/availability?owner_email='
+ + encodeURIComponent(OWNER) + '&date=' + iso)
+ .then(function(r){ return r.json(); })
+ .then(function(j){
+ if (!j.ok){ throw new Error(j.error || 'Failed to load'); }
+ renderSlots(j.slots || []);
+ })
+ .catch(function(e){
+ $('slots').innerHTML = '<div class="empty">Could not load times: '+(e.message||e)+'</div>';
+ });
+ }
+
+ function renderSlots(slots){
+ if (!slots.length){
+ $('slots').innerHTML = '<div class="empty">No open times that day.</div>';
+ return;
+ }
+ var html = slots.map(function(s){
+ var cls = (selectedSlot && selectedSlot.slot_start === s.slot_start) ? ' active' : '';
+ return '<div class="slot'+cls+'" data-start="'+s.slot_start+'" data-end="'+s.slot_end+'">'
+ + fmtTime(s.slot_start) + '</div>';
+ }).join('');
+ $('slots').innerHTML = html;
+ [].slice.call(document.querySelectorAll('.slot')).forEach(function(el){
+ el.addEventListener('click', function(){
+ selectedSlot = { slot_start: el.getAttribute('data-start'),
+ slot_end: el.getAttribute('data-end') };
+ updateSelSummary();
+ renderSlots(slots);
+ });
+ });
+ }
+
+ function updateSelSummary(){
+ if (selectedSlot){
+ $('selSummary').innerHTML = 'Selected: <strong>'+fmtPretty(selectedSlot.slot_start)+'</strong>';
+ $('bookBtn').disabled = false;
+ } else {
+ $('selSummary').innerHTML = '<em>No slot selected yet.</em>';
+ $('bookBtn').disabled = true;
+ }
+ }
+
+ $('pgPrev').addEventListener('click', function(){ if (dayOffset > 0){ dayOffset--; renderDays(); }});
+ $('pgNext').addEventListener('click', function(){ if ((dayOffset+1)*7 < WINDOW_DAYS){ dayOffset++; renderDays(); }});
+
+ $('form').addEventListener('submit', function(ev){
+ ev.preventDefault();
+ if (!selectedSlot) return;
+ var msg = $('msg'); msg.className = ''; msg.textContent = '';
+ var btn = $('bookBtn'); btn.disabled = true; btn.textContent = 'Booking…';
+ var body = {
+ name: $('f-name').value.trim(),
+ email: $('f-email').value.trim(),
+ phone: $('f-phone').value.trim(),
+ notes: $('f-notes').value.trim(),
+ slot_start: selectedSlot.slot_start,
+ slot_end: selectedSlot.slot_end,
+ owner_email: OWNER
+ };
+ fetch('/api/appointments/book', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify(body)
+ }).then(function(r){ return r.json(); }).then(function(j){
+ if (!j.ok){ throw new Error(j.error || 'Booking failed'); }
+ msg.className = 'msg ok';
+ msg.innerHTML = 'You\\u2019re booked. Confirmation: '
+ + '<a href="'+j.confirmation_url+'" style="color:inherit">view receipt</a>';
+ btn.textContent = 'Booked';
+ // also offer redirect when full-page
+ if (!EMBED){ setTimeout(function(){ window.location = j.confirmation_url; }, 1200); }
+ }).catch(function(e){
+ msg.className = 'msg err'; msg.textContent = 'Could not book: '+(e.message||e);
+ btn.disabled = false; btn.textContent = 'Confirm Booking';
+ });
+ });
+
+ // Initial render
+ renderDays();
+ // Pick first day by default
+ var first = new Date();
+ selectDate(first.toISOString().slice(0,10));
+})();
+</script>
+</body></html>`;
+}
+
+export function renderConfirmation(row: {
+ id: number; name: string; email: string; slot_start: string; slot_end: string;
+ status: string; owner_email: string; notes: string | null; google_event_id: string | null;
+}): string {
+ const when = new Date(row.slot_start).toLocaleString([], {
+ weekday: 'long', month: 'long', day: 'numeric',
+ hour: 'numeric', minute: '2-digit', timeZone: 'America/Los_Angeles',
+ });
+ const statusBadge = row.status === 'cancelled'
+ ? `<span style="color:#e8b8a8">Cancelled</span>`
+ : `<span style="color:#d4b683">Confirmed</span>`;
+ return `<!doctype html><html><head>
+<meta charset="utf-8"><title>Appointment #${row.id} · Ventura Corridor</title>
+<style>
+ :root{--noir:#0a0a0c;--ink:#f4f1ea;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2724;--noir-rise:#131316}
+ *{margin:0;padding:0;box-sizing:border-box}
+ body{background:var(--noir);color:var(--ink);font-family:'Inter',Helvetica,Arial,sans-serif;
+ font-weight:300;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
+ .card{max-width:560px;width:100%;padding:36px;border:1px solid var(--rule);background:var(--noir-rise)}
+ .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);margin-bottom:16px}
+ h1{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-size:36px;font-weight:500;
+ line-height:1.05;margin-bottom:12px}
+ .when{font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;color:var(--metal-glow);
+ margin-bottom:24px;font-style:italic}
+ dl{display:grid;grid-template-columns:auto 1fr;gap:8px 18px;margin-bottom:18px}
+ dt{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);align-self:center}
+ dd{font-size:14px;color:var(--ink)}
+ .footer{margin-top:24px;padding-top:14px;border-top:1px solid var(--rule);
+ font-size:11px;color:var(--ink-mute);letter-spacing:.1em}
+ .footer a{color:var(--metal);text-decoration:none}
+</style>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,400;1,500&family=Inter:wght@300;400&display=swap">
+</head><body>
+<div class="card">
+ <div class="kicker">Appointment · #${row.id}</div>
+ <h1>You’re on the books.</h1>
+ <div class="when">${esc(when)}</div>
+ <dl>
+ <dt>Name</dt><dd>${esc(row.name)}</dd>
+ <dt>Email</dt><dd>${esc(row.email)}</dd>
+ <dt>With</dt><dd>${esc(row.owner_email)}</dd>
+ <dt>Status</dt><dd>${statusBadge}</dd>
+ ${row.notes ? `<dt>Notes</dt><dd>${esc(row.notes)}</dd>` : ''}
+ ${row.google_event_id ? `<dt>Calendar</dt><dd style="color:var(--metal-glow)">Added to host calendar</dd>` : ''}
+ </dl>
+ <div class="footer">The Corridor · Smart Scheduling · <a href="/">venturacorridor.com</a></div>
+</div>
+</body></html>`;
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index e9564df..b0a22cc 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -37,6 +37,14 @@ const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());
+// ─── Appointments module (Smart Scheduling) ────────────────────────────
+// Mounted before the admin gate + static handler so /book, /embed/...,
+// /appointments/:id, and /api/appointments/* are publicly reachable and
+// embeddable from third-party origins (Shopify, etc.).
+// To disable the feature, delete these two lines and migration 022.
+import appointmentsRouter from './appointments/router.ts';
+app.use(appointmentsRouter);
+
// ─── Admin-gate competitive-intelligence surfaces ─────────────────────
// /signals.html, /dw-radar.html, /api/signals, /api/dw-radar are admin-only.
// Public users see the 2D map, 3D corridor, wall — those stay open.
← 75c7314 task #4: source CC0 glTF bus + car models (Kenney Car Kit +
·
back to Ventura Corridor
·
docs(appointments): Shopify embed snippet + copy-paste instr 0900593 →