[object Object]

← 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

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+
+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">&laquo; Earlier</button>
+    <div class="range" id="pgRange"></div>
+    <button id="pgNext" type="button">Later &raquo;</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 &middot; Smart Scheduling &middot; <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 →