[object Object]

← back to Designer Wallcoverings

Add appointment booking (phone/in-person) — engine + Shopify section staged on dev theme 5.9.0

5a2739b32bedbf887afab9ab9aa2026ca4ee4d59 · 2026-06-29 09:46:38 -0700 · Steve Abrams

- DW appointments backend folded into quote-api (dw_unified appt_* tables, 9-4/60 Mon-Fri,
  Google Calendar freeBusy+events sync, George internal notify, ICS, DW confirmation page)
- Phone vs in-person appointment_type, race-protected double-booking, OAuth owner-connect
- Storefront section appointment-booking.liquid + page.book-appointment.json pushed to
  newest dev theme (Steves Version 5.9.0 [Dev] 144070803507)
- Verified end-to-end vs local dw_unified: 8 API checks + headless Chrome booking, both types
- Go-live (Kamatera deploy, canonical migration, nginx, OAuth consent, page create) = README

Files touched

Diff

commit 5a2739b32bedbf887afab9ab9aa2026ca4ee4d59
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 09:46:38 2026 -0700

    Add appointment booking (phone/in-person) — engine + Shopify section staged on dev theme 5.9.0
    
    - DW appointments backend folded into quote-api (dw_unified appt_* tables, 9-4/60 Mon-Fri,
      Google Calendar freeBusy+events sync, George internal notify, ICS, DW confirmation page)
    - Phone vs in-person appointment_type, race-protected double-booking, OAuth owner-connect
    - Storefront section appointment-booking.liquid + page.book-appointment.json pushed to
      newest dev theme (Steves Version 5.9.0 [Dev] 144070803507)
    - Verified end-to-end vs local dw_unified: 8 API checks + headless Chrome booking, both types
    - Go-live (Kamatera deploy, canonical migration, nginx, OAuth consent, page create) = README
---
 shopify/quote-api/appointments/README.md           |  91 ++++++
 shopify/quote-api/appointments/calendar.js         |  67 ++++
 shopify/quote-api/appointments/db.js               |  38 +++
 shopify/quote-api/appointments/drive-preview.mjs   |  63 ++++
 .../appointments/migrations/appointments.sql       |  72 ++++
 shopify/quote-api/appointments/notify.js           |  78 +++++
 shopify/quote-api/appointments/oauth.js            | 157 +++++++++
 shopify/quote-api/appointments/render-preview.js   |  35 ++
 shopify/quote-api/appointments/router.js           | 363 +++++++++++++++++++++
 shopify/quote-api/appointments/run-local.js        |  16 +
 shopify/quote-api/appointments/slots.js            | 114 +++++++
 shopify/quote-api/server.js                        |  20 +-
 shopify/sections/appointment-booking.liquid        | 299 +++++++++++++++++
 shopify/templates/page.book-appointment.json       |   9 +
 14 files changed, 1415 insertions(+), 7 deletions(-)

diff --git a/shopify/quote-api/appointments/README.md b/shopify/quote-api/appointments/README.md
new file mode 100644
index 00000000..f5e352d6
--- /dev/null
+++ b/shopify/quote-api/appointments/README.md
@@ -0,0 +1,91 @@
+# DW Appointment Scheduling
+
+Phone **or** in-person booking for Designer Wallcoverings, synced to the info@
+Google Calendar. One-hour appointments, weekdays **9 AM–4 PM Pacific**.
+
+Engine ported from the proven ventura-corridor "Smart Scheduling" subsystem →
+CommonJS, backed by **dw_unified**, folded into the DW (Koroseal) quote app.
+
+## Pieces
+
+| Where | What |
+|---|---|
+| `appointments/router.js` | API + confirmation page + `.ics`. Mount with `app.use(require('./appointments/router'))` |
+| `appointments/slots.js` | DST-safe PT slot computer (availability − DB-booked − Google-busy − past) |
+| `appointments/calendar.js` | Google Calendar v3 freeBusy + events.insert/delete (fetch, no SDK) |
+| `appointments/oauth.js` | Google OAuth (offline, refresh-token) for the calendar owner |
+| `appointments/notify.js` | New-booking email to info@ via **George** (internal, no external-send gate) |
+| `appointments/db.js` | pg Pool → `DATABASE_URL` (prod dw_unified) or local socket (dev) |
+| `appointments/migrations/appointments.sql` | `appt_*` tables, seeds info@ 9–4/60 Mon–Fri |
+| `../../sections/appointment-booking.liquid` | Storefront booking section (DW-branded, OS 2.0) |
+| `../../templates/page.book-appointment.json` | Page template that renders the section |
+
+## API (all under the DW app origin)
+
+```
+GET  /api/appointments/health
+GET  /api/appointments/availability?owner_email=&date=YYYY-MM-DD   → { slots:[{slot_start,slot_end}] }
+POST /api/appointments/book        body: name,email,phone,notes,appointment_type(phone|in_person),slot_start,slot_end
+POST /api/appointments/:id/cancel?token=
+GET  /api/appointments/:id/calendar.ics
+GET  /appointments/:id                                              confirmation page (DW-branded)
+GET  /api/appointments/oauth/start?owner_email=                     owner connects calendar (Steve clicks once)
+GET  /api/appointments/oauth/callback
+GET  /api/appointments/oauth/status?owner_email=
+GET  /api/appointments/list                                        admin (Basic ADMIN_USER/ADMIN_PASS)
+```
+
+## Local dev / verification
+
+```bash
+psql -d dw_unified -v ON_ERROR_STOP=1 -f appointments/migrations/appointments.sql
+PORT=4499 node appointments/run-local.js
+# render the section to a standalone page + drive it headless:
+node appointments/render-preview.js && (cd appointments && python3 -m http.server 4500 &)
+cd appointments && APPT_TYPE=phone node drive-preview.mjs
+```
+
+## Env
+
+```
+DATABASE_URL=postgres://dw_admin@127.0.0.1:5432/dw_unified   # prod (dw_unified)
+APPT_OWNER_EMAIL=info@designerwallcoverings.com
+APPT_PUBLIC_BASE=https://www.designerwallcoverings.com       # absolute confirmation/ics URLs
+DW_SHOWROOM_LOCATION=<real showroom address>                 # shown for in-person bookings
+GOOGLE_OAUTH_CLIENT_ID=...                                   # via /secrets
+GOOGLE_OAUTH_CLIENT_SECRET=...
+GOOGLE_OAUTH_REDIRECT_URI=https://www.designerwallcoverings.com/api/appointments/oauth/callback
+GEORGE_BASIC_AUTH_PASS=...                                   # internal new-booking email to info@
+ADMIN_USER=admin  ADMIN_PASS=...                             # /api/appointments/list
+```
+
+---
+
+## GO-LIVE CHECKLIST (each item is Steve-gated)
+
+1. **Deploy backend** — fold `appointments/` into the live Koroseal quote app on
+   Kamatera and add the two mount lines (see `server.js`), or deploy this
+   `quote-api` app. Ensure `pg`, `express`, `node-fetch` installed and
+   `DATABASE_URL` → dw_unified.
+2. **Run the migration on canonical dw_unified** (over ssh must use the postgres role):
+   ```bash
+   ssh my-server "sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -f <path>/appointments.sql"
+   ```
+3. **nginx proxy** — route `/api/appointments/` **and** `/appointments/` on the
+   storefront domain to the app port (same shape as `/api/koroseal-quote`).
+4. **Google OAuth (only Steve can click):**
+   - Google Cloud Console → enable **Google Calendar API**; create/locate the OAuth client.
+   - Authorized redirect URI: `https://www.designerwallcoverings.com/api/appointments/oauth/callback`
+   - Scopes: `calendar.events`, `calendar.readonly`.
+   - Paste `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` via `/secrets`.
+   - Visit `…/api/appointments/oauth/start?owner_email=info@designerwallcoverings.com` and
+     consent with the Google account that holds info@'s calendar.
+   - Confirm: `curl …/api/appointments/oauth/status?owner_email=info@designerwallcoverings.com` → `connected:true`.
+   - (Until connected, bookings still work + email info@; they just don't auto-push to Google.)
+5. **Set `DW_SHOWROOM_LOCATION`** to the real showroom address (in-person confirmations/ICS use it).
+6. **Create the storefront page** — Online Store → Pages → Add page → title
+   "Book an Appointment", template **book-appointment**; add it to a nav menu.
+   (Page template + section already staged on dev theme **Steves Version 5.9.0 [Dev]** `144070803507`.)
+7. **Publish the theme** (5.9.0 → live) when ready — separate gate.
+8. **Verify** on the dev theme once backend is live:
+   `…/pages/book-appointment?preview_theme_id=144070803507`
diff --git a/shopify/quote-api/appointments/calendar.js b/shopify/quote-api/appointments/calendar.js
new file mode 100644
index 00000000..5965f663
--- /dev/null
+++ b/shopify/quote-api/appointments/calendar.js
@@ -0,0 +1,67 @@
+/**
+ * calendar.js — Google Calendar v3 helpers (fetch only, no SDK).
+ *   busy()        freeBusy.query  → busy windows for a date
+ *   insertEvent() events.insert   → push an appointment onto the owner's primary cal
+ *   deleteEvent() events.delete   → wipe on cancel
+ *
+ * Ported from the ventura engine; adds a `location` field (used for in-person
+ * showroom appointments).
+ */
+const { getValidAccessToken } = require('./oauth');
+
+// Node 18+ has global fetch; fall back to node-fetch (already a dep here).
+const fetchFn = (...a) => (globalThis.fetch ? globalThis.fetch(...a) : require('node-fetch')(...a));
+
+const CAL_BASE = 'https://www.googleapis.com/calendar/v3';
+
+async function busy(ownerEmail, startISO, endISO) {
+  const token = await getValidAccessToken(ownerEmail);
+  if (!token) return [];
+  const r = await fetchFn(`${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 = await r.json();
+  return (j.calendars && j.calendars.primary && j.calendars.primary.busy) || [];
+}
+
+async function insertEvent(ownerEmail, payload) {
+  const token = await getValidAccessToken(ownerEmail);
+  if (!token) return null;
+  const body = {
+    summary: payload.summary,
+    description: payload.description,
+    start: { dateTime: payload.startISO },
+    end: { dateTime: payload.endISO },
+  };
+  if (payload.location) body.location = payload.location;
+  if (payload.attendeeEmail) body.attendees = [{ email: payload.attendeeEmail }];
+  const r = await fetchFn(`${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 = await r.json();
+  return j.id || null;
+}
+
+async function deleteEvent(ownerEmail, eventId) {
+  const token = await getValidAccessToken(ownerEmail);
+  if (!token) return false;
+  const r = await fetchFn(
+    `${CAL_BASE}/calendars/primary/events/${encodeURIComponent(eventId)}?sendUpdates=none`,
+    { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } },
+  );
+  return r.ok || r.status === 410; // 410 = already gone
+}
+
+module.exports = { busy, insertEvent, deleteEvent };
diff --git a/shopify/quote-api/appointments/db.js b/shopify/quote-api/appointments/db.js
new file mode 100644
index 00000000..8fa2740c
--- /dev/null
+++ b/shopify/quote-api/appointments/db.js
@@ -0,0 +1,38 @@
+/**
+ * db.js — single pg Pool for the appointments module, backed by dw_unified.
+ *
+ * Resolution order:
+ *   1. APPOINTMENTS_DATABASE_URL  (lets the module point at a dedicated DB if ever needed)
+ *   2. DATABASE_URL               (the DW app's standard connection string on Kamatera)
+ *   3. libpq defaults             (local dev: connects to dw_unified as the current OS user)
+ *
+ * Everything this module touches is namespaced under the appt_ prefix, so it
+ * coexists with the rest of dw_unified without collision.
+ */
+const os = require('node:os');
+const { Pool } = require('pg');
+
+const connectionString =
+  process.env.APPOINTMENTS_DATABASE_URL || process.env.DATABASE_URL || undefined;
+
+// Local dev: connect over the unix socket (peer auth, like psql) instead of
+// node-pg's default TCP loopback, which would hit scram-sha-256 and demand a
+// password. On Kamatera DATABASE_URL is set, so this branch never runs there.
+const pool = new Pool(
+  connectionString
+    ? { connectionString }
+    : {
+        host: process.env.PGHOST || '/tmp',
+        database: process.env.PGDATABASE || 'dw_unified',
+        user: process.env.PGUSER || os.userInfo().username,
+      },
+);
+
+pool.on('error', (err) => console.error('[appointments] pg pool error', err));
+
+/** Thin wrapper so call-sites read like the ventura `query<T>()` helper. */
+async function query(text, params) {
+  return pool.query(text, params);
+}
+
+module.exports = { pool, query };
diff --git a/shopify/quote-api/appointments/drive-preview.mjs b/shopify/quote-api/appointments/drive-preview.mjs
new file mode 100644
index 00000000..8bc46c31
--- /dev/null
+++ b/shopify/quote-api/appointments/drive-preview.mjs
@@ -0,0 +1,63 @@
+// Headless click-through of the booking section against the local backend.
+import { chromium } from 'playwright';
+
+const URL = process.env.PREVIEW_URL || 'http://127.0.0.1:4500/preview.html';
+const TYPE = process.env.APPT_TYPE || 'phone';
+const log = (...a) => console.log('  •', ...a);
+
+const browser = await chromium.launch();
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
+page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+
+await page.goto(URL, { waitUntil: 'networkidle' });
+log('loaded', URL);
+
+// 1) pick type
+await page.click(`.appt-type[data-type="${TYPE}"]`);
+const checked = await page.getAttribute(`.appt-type[data-type="${TYPE}"]`, 'aria-checked');
+log('type', TYPE, 'aria-checked=', checked);
+
+// 2) first bookable (non-weekend) day
+const day = page.locator('.appt-day:not(.is-off)').first();
+await day.waitFor();
+const dayNum = await day.locator('.num').textContent();
+await day.click();
+log('clicked day', dayNum.trim());
+
+// 3) wait for slots, click first
+await page.locator('.appt-slot').first().waitFor({ timeout: 8000 });
+const slotCount = await page.locator('.appt-slot').count();
+const slotText = await page.locator('.appt-slot').first().textContent();
+await page.locator('.appt-slot').first().click();
+log('slots loaded', slotCount, '· picked', slotText.trim());
+
+// 4) fill details
+await page.fill('input[name="name"]', 'Playwright Tester');
+await page.fill('input[name="email"]', 'pwtest@example.com');
+if (TYPE === 'phone') await page.fill('input[name="phone"]', '310-555-0199');
+await page.fill('textarea[name="notes"]', 'automated end-to-end check');
+
+// 5) submit
+await page.click('.appt-book[type="submit"]');
+
+// 6) success
+await page.locator('.appt-done:not([hidden])').waitFor({ timeout: 8000 });
+const when = (await page.locator('.appt-done-when').textContent()).trim();
+const ics = await page.getAttribute('.appt-ics', 'href');
+const conf = await page.getAttribute('.appt-conf', 'href');
+log('SUCCESS panel shown');
+log('when:', when);
+log('ics:', ics);
+log('confirmation:', conf);
+
+const shot = '/tmp/dw-appt-' + TYPE + '.png';
+await page.screenshot({ path: shot, fullPage: true });
+log('screenshot', shot);
+
+if (errors.length) { console.log('  ⚠ JS errors:', errors); }
+else log('no JS/console errors');
+
+await browser.close();
+console.log(JSON.stringify({ ok: true, type: TYPE, slotCount, when, ics, conf, errors }));
diff --git a/shopify/quote-api/appointments/migrations/appointments.sql b/shopify/quote-api/appointments/migrations/appointments.sql
new file mode 100644
index 00000000..eb68504c
--- /dev/null
+++ b/shopify/quote-api/appointments/migrations/appointments.sql
@@ -0,0 +1,72 @@
+-- appointments.sql — DW appointment-booking schema (lives in dw_unified).
+-- All tables prefixed appt_ to stay isolated. Fully idempotent: safe to re-run.
+--
+-- LOCAL (Mac2 mirror, verification):
+--   psql -d dw_unified -v ON_ERROR_STOP=1 -f appointments.sql
+-- CANONICAL (Kamatera dw_unified) — GATED, run via the standard sudo -u postgres path:
+--   ssh my-server "sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -f appointments.sql"
+
+CREATE TABLE IF NOT EXISTS appt_appointments (
+  id               BIGSERIAL PRIMARY KEY,
+  name             TEXT NOT NULL,
+  email            TEXT NOT NULL,
+  phone            TEXT,
+  owner_email      TEXT NOT NULL,
+  appointment_type TEXT NOT NULL DEFAULT 'phone'
+                   CHECK (appointment_type IN ('phone','in_person')),
+  requested_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  slot_start       TIMESTAMPTZ NOT NULL,
+  slot_end         TIMESTAMPTZ NOT NULL,
+  status           TEXT NOT NULL DEFAULT 'confirmed'
+                   CHECK (status IN ('confirmed','cancelled','pending')),
+  notes            TEXT,
+  google_event_id  TEXT,
+  cancel_token     TEXT NOT NULL DEFAULT md5(random()::text || clock_timestamp()::text),
+  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- appointment_type added defensively in case an older appt_appointments exists.
+ALTER TABLE appt_appointments
+  ADD COLUMN IF NOT EXISTS appointment_type TEXT NOT NULL DEFAULT 'phone';
+DO $$ BEGIN
+  ALTER TABLE appt_appointments
+    ADD CONSTRAINT appt_appointments_type_chk CHECK (appointment_type IN ('phone','in_person'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+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=Sun..6=Sat
+  start_min         INT NOT NULL CHECK (start_min BETWEEN 0 AND 1439),       -- PT minutes from midnight
+  end_min           INT NOT NULL CHECK (end_min BETWEEN 1 AND 1440),
+  slot_duration_min INT NOT NULL DEFAULT 60 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()
+);
+
+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 info@designerwallcoverings.com: Mon–Fri, 9 AM – 4 PM PT, 60-minute slots.
+-- 9 AM = 540 min, 4 PM = 960 min. Mon=1 … Fri=5. (Yields 9,10,11,12,1,2,3 PT = 7 slots/day.)
+INSERT INTO appt_availability (owner_email, day_of_week, start_min, end_min, slot_duration_min)
+SELECT 'info@designerwallcoverings.com', d, 540, 960, 60
+FROM generate_series(1, 5) AS d
+ON CONFLICT (owner_email, day_of_week, start_min, end_min) DO NOTHING;
diff --git a/shopify/quote-api/appointments/notify.js b/shopify/quote-api/appointments/notify.js
new file mode 100644
index 00000000..1faa90ef
--- /dev/null
+++ b/shopify/quote-api/appointments/notify.js
@@ -0,0 +1,78 @@
+/**
+ * notify.js — internal new-booking notification to info@designerwallcoverings.com,
+ * routed through George (the sole send/read layer for fleet/agent mail).
+ *
+ * info@ is an INTERNAL recipient, so George's external-send approval gate does
+ * NOT apply (same as sku-inquiry-handler.js). We deliberately do NOT send the
+ * customer an external confirmation email here — the customer gets the on-page
+ * confirmation + an .ics download. Wiring a customer-facing transactional email
+ * is a separate, gated step that must go through George's external-send path.
+ */
+const fetchFn = (...a) => (globalThis.fetch ? globalThis.fetch(...a) : require('node-fetch')(...a));
+
+const GEORGE_SEND_URL = process.env.GEORGE_SEND_URL || 'http://127.0.0.1:9850/api/send';
+const GEORGE_USER = process.env.GEORGE_BASIC_AUTH_USER || 'admin';
+const GEORGE_PASS = process.env.GEORGE_BASIC_AUTH_PASS || '';
+const INFO_EMAIL = process.env.INFO_EMAIL || 'info@designerwallcoverings.com';
+const AUTH = 'Basic ' + Buffer.from(`${GEORGE_USER}:${GEORGE_PASS}`).toString('base64');
+
+const esc = (s) =>
+  String(s == null ? '' : s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
+
+const TYPE_LABEL = { phone: 'Phone call', in_person: 'In person at DW showroom' };
+
+function whenPT(iso) {
+  return new Date(iso).toLocaleString('en-US', {
+    timeZone: 'America/Los_Angeles',
+    weekday: 'long', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
+  });
+}
+
+/** Best-effort; never throws (a failed notify must not fail the booking). */
+async function notifyInternalBooking(appt) {
+  if (!GEORGE_PASS) {
+    console.warn('[appointments] GEORGE_BASIC_AUTH_PASS unset — skipping internal notify');
+    return { ok: false, skipped: true };
+  }
+  const rows = [
+    ['Type', TYPE_LABEL[appt.appointment_type] || appt.appointment_type],
+    ['When (PT)', whenPT(appt.slot_start)],
+    ['Name', appt.name],
+    ['Email', appt.email],
+    ['Phone', appt.phone],
+    ['Notes', appt.notes],
+    ['Calendar', appt.calendar_pushed ? 'pushed to Google ✓' : 'not connected — review manually'],
+    ['Booking ID', `#${appt.id}`],
+  ].filter(([, v]) => v && String(v).trim());
+
+  const html =
+    `<!DOCTYPE html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:600px">
+      <h2 style="color:#2a2622">New appointment booked — ${esc(TYPE_LABEL[appt.appointment_type] || '')}</h2>
+      <p style="color:#6b6357">${esc(whenPT(appt.slot_start))} PT · reply-to: ${esc(appt.email)}</p>
+      <table style="border-collapse:collapse;width:100%">
+        ${rows
+          .map(
+            ([k, v]) =>
+              `<tr><td style="padding:6px 10px;color:#8a8276;border-bottom:1px solid #eee;white-space:nowrap">${esc(k)}</td><td style="padding:6px 10px;border-bottom:1px solid #eee">${esc(v)}</td></tr>`,
+          )
+          .join('')}
+      </table></body></html>`;
+
+  try {
+    const r = await fetchFn(GEORGE_SEND_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: AUTH },
+      body: JSON.stringify({
+        to: INFO_EMAIL,
+        subject: `New appointment · ${TYPE_LABEL[appt.appointment_type] || ''} · ${whenPT(appt.slot_start)} PT`,
+        body: html,
+      }),
+    });
+    return { ok: r.ok, status: r.status };
+  } catch (e) {
+    console.error('[appointments] internal notify failed (non-fatal):', e.message);
+    return { ok: false, error: e.message };
+  }
+}
+
+module.exports = { notifyInternalBooking, TYPE_LABEL };
diff --git a/shopify/quote-api/appointments/oauth.js b/shopify/quote-api/appointments/oauth.js
new file mode 100644
index 00000000..ac8e0644
--- /dev/null
+++ b/shopify/quote-api/appointments/oauth.js
@@ -0,0 +1,157 @@
+/**
+ * oauth.js — Google OAuth helpers (fetch-based, no googleapis dep).
+ *
+ * Used so the CALENDAR OWNER (info@designerwallcoverings.com) can connect the
+ * Google calendar that backs the showroom schedule. End-users who book never
+ * see this flow. Steve clicks the consent screen once (agents can't click it).
+ *
+ * Branding rule (global standing instructions): never call this "Google
+ * Calendar" in customer-visible copy — it's "Calendar sync". Google's brand
+ * only appears on the Google-hosted consent screen, which we don't control.
+ */
+const crypto = require('node:crypto');
+const { query } = require('./db');
+
+const fetchFn = (...a) => (globalThis.fetch ? globalThis.fetch(...a) : require('node-fetch')(...a));
+
+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';
+
+const SCOPES = [
+  'openid',
+  'email',
+  'profile',
+  'https://www.googleapis.com/auth/calendar.events',
+  'https://www.googleapis.com/auth/calendar.readonly',
+];
+
+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 ||
+    'https://www.designerwallcoverings.com/api/appointments/oauth/callback';
+  return { clientId, clientSecret, redirectUri };
+}
+
+function isOAuthConfigured() {
+  const { clientId, clientSecret } = getOAuthConfig();
+  return Boolean(clientId && clientSecret);
+}
+
+async function buildAuthUrl(ownerEmail) {
+  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()}`;
+}
+
+async function exchangeCodeAndStore(code, state) {
+  const r = await query(`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 fetchFn(TOKEN_ENDPOINT, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  if (!tok.ok) throw new Error(`Google token exchange failed: ${tok.status} ${await tok.text()}`);
+  const j = 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'],
+  );
+  await query(`DELETE FROM appt_oauth_state WHERE state = $1`, [state]); // single-use nonce
+  return ownerEmail;
+}
+
+async function getValidAccessToken(ownerEmail) {
+  const r = await query(
+    `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();
+  if (exp - Date.now() > 120_000) return row.access_token; // still fresh
+  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 fetchFn(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 = 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;
+}
+
+async function revoke(ownerEmail) {
+  const tok = await getValidAccessToken(ownerEmail);
+  if (tok) {
+    try {
+      await fetchFn(`${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]);
+}
+
+module.exports = {
+  SCOPES, getOAuthConfig, isOAuthConfigured, buildAuthUrl,
+  exchangeCodeAndStore, getValidAccessToken, revoke,
+};
diff --git a/shopify/quote-api/appointments/render-preview.js b/shopify/quote-api/appointments/render-preview.js
new file mode 100644
index 00000000..fa292f34
--- /dev/null
+++ b/shopify/quote-api/appointments/render-preview.js
@@ -0,0 +1,35 @@
+/**
+ * render-preview.js — turn the Liquid section into a standalone preview.html for
+ * local verification (Liquid can't render outside Shopify). NOT shipped to prod.
+ * Resolves {{ section.id }}, {{ ... | default: 'x' }}, the assign/if blocks, and
+ * strips {% schema %}. API base is overridden to the local runner.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const SRC = path.join(__dirname, '..', '..', 'sections', 'appointment-booking.liquid');
+const OUT = path.join(__dirname, 'preview.html');
+const API = process.env.PREVIEW_API || 'http://127.0.0.1:4499';
+
+let s = fs.readFileSync(SRC, 'utf8');
+s = s.replace(/{%\s*schema\s*%}[\s\S]*?{%\s*endschema\s*%}/g, ''); // drop schema
+s = s.replace(/{%-?\s*comment\s*-?%}[\s\S]*?{%-?\s*endcomment\s*-?%}/g, '');
+s = s.replace(/{%-?\s*assign[\s\S]*?-?%}/g, ''); // drop assigns; we hardcode below
+s = s.replace(/{{\s*section\.id\s*}}/g, 'preview');
+// resolve {{ X | default: 'Y' ... }} -> Y  and bare section.settings -> ''
+s = s.replace(/{{[^}]*?\|\s*default:\s*'([^']*)'[^}]*}}/g, '$1');
+s = s.replace(/{{[^}]*?\|\s*default:\s*"([^"]*)"[^}]*}}/g, '$1');
+// the data attributes use {{ api_base }} / {{ owner_email }} / {{ days_ahead }}
+s = s.replace(/{{\s*api_base\s*}}/g, API);
+s = s.replace(/{{\s*owner_email\s*}}/g, 'info@designerwallcoverings.com');
+s = s.replace(/{{\s*days_ahead\s*}}/g, '21');
+// intro {%- if -%} ... {%- endif -%} : keep inner, drop tags
+s = s.replace(/{%-?\s*if[\s\S]*?-?%}/g, '').replace(/{%-?\s*endif\s*-?%}/g, '');
+// any leftover {{ ... }} -> empty
+s = s.replace(/{{[^}]*}}/g, '');
+
+const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>DW Appointment — preview</title></head>
+<body style="margin:0">${s}</body></html>`;
+fs.writeFileSync(OUT, html);
+console.log('wrote', OUT, '(api=' + API + ')');
diff --git a/shopify/quote-api/appointments/router.js b/shopify/quote-api/appointments/router.js
new file mode 100644
index 00000000..0a834f2e
--- /dev/null
+++ b/shopify/quote-api/appointments/router.js
@@ -0,0 +1,363 @@
+/**
+ * router.js — DW appointments API + confirmation pages.
+ *
+ * Mount once into the existing DW (Koroseal) quote Express app:
+ *     const appointments = require('./appointments/router');
+ *     app.use(appointments);
+ * Reversibility: everything is namespaced under /api/appointments + /appointments.
+ * Removing the app.use line removes the feature; appt_ tables are untouched.
+ *
+ * Hours/slots come entirely from appt_availability (info@ seeded Mon–Fri 9–4,
+ * 60-min). Appointment type is phone | in_person. Google Calendar push is
+ * best-effort (only fires once info@'s calendar is connected via OAuth).
+ */
+const express = require('express');
+const { query } = require('./db');
+const { computeSlots } = require('./slots');
+const {
+  isOAuthConfigured, buildAuthUrl, exchangeCodeAndStore, getValidAccessToken, revoke,
+} = require('./oauth');
+const { insertEvent, deleteEvent } = require('./calendar');
+const { notifyInternalBooking, TYPE_LABEL } = require('./notify');
+
+const router = express.Router();
+router.use(express.json({ limit: '64kb' }));
+
+const DEFAULT_OWNER = (process.env.APPT_OWNER_EMAIL || 'info@designerwallcoverings.com').toLowerCase();
+const IN_PERSON_LOCATION =
+  process.env.DW_SHOWROOM_LOCATION ||
+  'Designer Wallcoverings Showroom — exact address sent with your confirmation.';
+const PUBLIC_BASE = process.env.APPT_PUBLIC_BASE || 'https://www.designerwallcoverings.com';
+const VALID_TYPES = new Set(['phone', 'in_person']);
+
+const esc = (s) =>
+  String(s == null ? '' : s)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+
+// ─── Permissive CORS so the Shopify storefront can fetch cross-origin ─────
+router.use((req, res, next) => {
+  res.setHeader('Access-Control-Allow-Origin', '*');
+  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+  if (req.method === 'OPTIONS') return res.sendStatus(204);
+  next();
+});
+
+// ─── Health ──────────────────────────────────────────────────────────────
+router.get('/api/appointments/health', async (_req, res) => {
+  try {
+    const r = await query(`SELECT COUNT(*)::int AS c FROM appt_availability`);
+    res.json({
+      ok: true,
+      service: 'dw-appointments',
+      oauth_configured: isOAuthConfigured(),
+      availability_rows: r.rows[0].c,
+    });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ─── Availability for a date ──────────────────────────────────────────────
+router.get('/api/appointments/availability', async (req, res) => {
+  const ownerEmail = String(req.query.owner_email || DEFAULT_OWNER).trim().toLowerCase();
+  const date = String(req.query.date || '').trim();
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
+    return res.status(400).json({ ok: false, error: 'YYYY-MM-DD date required' });
+  }
+  try {
+    const slots = await computeSlots(ownerEmail, date);
+    res.json({ ok: true, owner_email: ownerEmail, date, slots });
+  } catch (e) {
+    console.error('[appointments] availability failed', e);
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ─── Book ──────────────────────────────────────────────────────────────────
+router.post('/api/appointments/book', async (req, res) => {
+  const b = req.body || {};
+  const name = String(b.name || '').trim().slice(0, 200);
+  const email = String(b.email || '').trim().toLowerCase().slice(0, 200);
+  const phone = String(b.phone || '').trim().slice(0, 50) || null;
+  const notes = String(b.notes || '').trim().slice(0, 2000) || null;
+  const ownerEmail = String(b.owner_email || DEFAULT_OWNER).trim().toLowerCase();
+  const slotStart = String(b.slot_start || '').trim();
+  const slotEnd = String(b.slot_end || '').trim();
+  const apptType = VALID_TYPES.has(String(b.appointment_type)) ? String(b.appointment_type) : 'phone';
+
+  if (!name || !email || !slotStart || !slotEnd) {
+    return res.status(400).json({ ok: false, error: 'name, email, slot_start, slot_end required' });
+  }
+  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+    return res.status(400).json({ ok: false, error: 'invalid email' });
+  }
+  if (apptType === 'phone' && !phone) {
+    return res.status(400).json({ ok: false, error: 'phone number required for a phone appointment' });
+  }
+  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 {
+    // Race-protection: slot must still be in the freshly computed set.
+    const date = new Date(s).toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
+    const slots = await computeSlots(ownerEmail, date);
+    if (!slots.find((sl) => sl.slot_start === new Date(s).toISOString())) {
+      return res.status(409).json({ ok: false, error: 'that time was just taken — please pick another' });
+    }
+
+    const ins = await query(
+      `INSERT INTO appt_appointments
+         (name, email, phone, owner_email, slot_start, slot_end, notes, appointment_type, status)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'confirmed')
+       RETURNING id, cancel_token`,
+      [name, email, phone, ownerEmail, new Date(s).toISOString(), new Date(e).toISOString(), notes, apptType],
+    );
+    const row = ins.rows[0];
+
+    // Best-effort Google Calendar push.
+    let eventId = null;
+    try {
+      const isInPerson = apptType === 'in_person';
+      eventId = await insertEvent(ownerEmail, {
+        summary: `DW ${isInPerson ? 'Showroom' : 'Phone'} Appointment — ${name}`,
+        location: isInPerson ? IN_PERSON_LOCATION : `Phone: ${phone || '(provided at booking)'}`,
+        description:
+          `Booked via designerwallcoverings.com.\n\n` +
+          `Type:  ${TYPE_LABEL[apptType]}\nName:  ${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);
+    }
+
+    // Internal notify to info@ via George (best-effort, non-fatal).
+    notifyInternalBooking({
+      id: row.id, name, email, phone, notes, appointment_type: apptType,
+      slot_start: new Date(s).toISOString(), calendar_pushed: Boolean(eventId),
+    }).catch(() => {});
+
+    res.json({
+      ok: true,
+      id: row.id,
+      appointment_type: apptType,
+      confirmation_url: `${PUBLIC_BASE}/appointments/${row.id}`,
+      ics_url: `${PUBLIC_BASE}/api/appointments/${row.id}/calendar.ics`,
+      calendar_pushed: Boolean(eventId),
+    });
+  } catch (e) {
+    console.error('[appointments] book failed', e);
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
+// ─── Cancel (token-gated) ──────────────────────────────────────────────────
+router.post('/api/appointments/:id/cancel', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  const token = String(req.query.token || (req.body && req.body.token) || '');
+  if (!isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' });
+
+  const r = await query(
+    `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' });
+  if (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' });
+});
+
+// ─── ICS "add to calendar" download ────────────────────────────────────────
+function icsStamp(iso) {
+  return new Date(iso).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
+}
+router.get('/api/appointments/:id/calendar.ics', 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, slot_start, slot_end, appointment_type, phone, notes, owner_email
+       FROM appt_appointments WHERE id = $1 AND status <> 'cancelled'`, [id],
+  );
+  if (!r.rowCount) return res.status(404).type('text/plain').send('not found');
+  const a = r.rows[0];
+  const inPerson = a.appointment_type === 'in_person';
+  const loc = inPerson ? IN_PERSON_LOCATION : `Phone call to ${a.phone || '(your number)'}`;
+  const ics = [
+    'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Designer Wallcoverings//Appointments//EN',
+    'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT',
+    `UID:dw-appt-${a.id}@designerwallcoverings.com`,
+    `DTSTAMP:${icsStamp(new Date().toISOString())}`,
+    `DTSTART:${icsStamp(a.slot_start)}`,
+    `DTEND:${icsStamp(a.slot_end)}`,
+    `SUMMARY:Designer Wallcoverings — ${inPerson ? 'Showroom' : 'Phone'} appointment`,
+    `LOCATION:${String(loc).replace(/[,;\\]/g, '\\$&').replace(/\n/g, '\\n')}`,
+    `DESCRIPTION:${String(`${TYPE_LABEL[a.appointment_type]}.\n${a.notes || ''}`).replace(/[,;\\]/g, '\\$&').replace(/\n/g, '\\n')}`,
+    'END:VEVENT', 'END:VCALENDAR',
+  ].join('\r\n');
+  res.set('Content-Type', 'text/calendar; charset=utf-8');
+  res.set('Content-Disposition', `attachment; filename="dw-appointment-${a.id}.ics"`);
+  res.send(ics);
+});
+
+// ─── Confirmation page (DW-branded) ────────────────────────────────────────
+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, appointment_type, phone, notes, cancel_token
+       FROM appt_appointments WHERE id = $1`, [id],
+  );
+  if (!r.rowCount) return res.status(404).type('text/plain').send('appointment not found');
+  const a = r.rows[0];
+  const cancelled = a.status === 'cancelled';
+  const inPerson = a.appointment_type === 'in_person';
+  const whenPT = new Date(a.slot_start).toLocaleString('en-US', {
+    timeZone: 'America/Los_Angeles',
+    weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit',
+  });
+  const where = inPerson ? esc(IN_PERSON_LOCATION) : `Phone call to <strong>${esc(a.phone || 'your number')}</strong> (9 AM–4 PM PT)`;
+
+  res.type('html').send(`<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${cancelled ? 'Appointment cancelled' : 'Appointment confirmed'} · Designer Wallcoverings</title>
+<style>
+  :root{--bg:#f7f4ef;--card:#fff;--ink:#2a2622;--soft:#6b6357;--rule:#e7e1d7;--gold:#9c7a3c;--accent:#2a2622}
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{background:var(--bg);color:var(--ink);font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
+    min-height:100vh;display:flex;align-items:center;justify-content:center;padding:28px}
+  .card{background:var(--card);max-width:560px;width:100%;border:1px solid var(--rule);border-radius:14px;
+    padding:40px;box-shadow:0 24px 60px -30px rgba(42,38,34,.4)}
+  .kick{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--gold);font-weight:600}
+  h1{font-family:'Cormorant Garamond',Georgia,serif;font-size:34px;font-weight:600;margin:10px 0 6px;line-height:1.1}
+  .sub{color:var(--soft);font-size:15px;margin-bottom:22px}
+  .rows{border-top:1px solid var(--rule);margin-top:8px}
+  .r{display:flex;gap:14px;padding:13px 0;border-bottom:1px solid var(--rule);font-size:15px}
+  .r .k{flex:0 0 120px;color:var(--soft);text-transform:uppercase;letter-spacing:.08em;font-size:11px;padding-top:2px}
+  .r .v{flex:1}
+  .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:24px}
+  a.btn{display:inline-block;text-decoration:none;font-size:13px;letter-spacing:.04em;padding:12px 20px;border-radius:8px}
+  a.solid{background:var(--accent);color:#fff}
+  a.ghost{border:1px solid var(--rule);color:var(--ink)}
+  .cancel{margin-top:18px;font-size:12px;color:var(--soft)}
+  .cancel a{color:#9a5b4f}
+  .banner{padding:12px 14px;border-radius:8px;font-size:14px;margin-bottom:18px}
+  .ok{background:#eef7ee;color:#2f6b2f}.no{background:#fbeeee;color:#9a3b3b}
+</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:wght@500;600&display=swap">
+</head><body>
+  <div class="card">
+    <div class="kick">Designer Wallcoverings</div>
+    ${cancelled
+      ? `<div class="banner no">This appointment has been cancelled.</div><h1>Appointment cancelled</h1>`
+      : `<h1>You're booked.</h1><p class="sub">We've reserved this time for you, ${esc(a.name)}.</p>`}
+    <div class="rows">
+      <div class="r"><div class="k">Type</div><div class="v">${esc(TYPE_LABEL[a.appointment_type] || '')}</div></div>
+      <div class="r"><div class="k">When</div><div class="v">${esc(whenPT)} <span style="color:var(--soft)">PT · 1 hour</span></div></div>
+      <div class="r"><div class="k">${inPerson ? 'Where' : 'How'}</div><div class="v">${where}</div></div>
+      ${a.notes ? `<div class="r"><div class="k">Notes</div><div class="v">${esc(a.notes)}</div></div>` : ''}
+    </div>
+    ${cancelled ? '' : `<div class="actions">
+      <a class="btn solid" href="/api/appointments/${a.id}/calendar.ics">Add to calendar</a>
+      <a class="btn ghost" href="${esc(PUBLIC_BASE)}">Back to Designer Wallcoverings</a>
+    </div>
+    <p class="cancel">Need to cancel? <a href="#" onclick="cancelAppt(event)">Cancel this appointment</a></p>
+    <script>
+      function cancelAppt(ev){ev.preventDefault();
+        if(!confirm('Cancel this appointment?'))return;
+        fetch('/api/appointments/${a.id}/cancel?token=${encodeURIComponent(a.cancel_token)}',{method:'POST'})
+          .then(function(r){return r.json()}).then(function(j){
+            if(j.ok){location.reload()}else{alert(j.error||'Could not cancel')}});}
+    </script>`}
+  </div>
+</body></html>`);
+});
+
+// ─── OAuth (owner connects info@'s calendar — Steve clicks this once) ───────
+router.get('/api/appointments/oauth/start', async (req, res) => {
+  if (!isOAuthConfigured()) {
+    return res.status(503).type('text/plain').send(
+      'Calendar sync OAuth is not configured. Set GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET, then restart.',
+    );
+  }
+  const ownerEmail = String(req.query.owner_email || DEFAULT_OWNER).trim().toLowerCase();
+  res.redirect(await buildAuthUrl(ownerEmail));
+});
+
+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>Calendar connected</title>
+       <body style="font-family:-apple-system,sans-serif;background:#f7f4ef;color:#2a2622;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px">
+       <div><div style="font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:#9c7a3c">Designer Wallcoverings</div>
+       <h1 style="font-family:Georgia,serif;margin:12px 0">Calendar connected.</h1>
+       <p>${esc(ownerEmail)} will now sync bookings automatically.</p></div></body>`,
+    );
+  } catch (e) {
+    res.status(400).type('text/plain').send(`Token exchange failed: ${e.message}`);
+  }
+});
+
+router.get('/api/appointments/oauth/status', async (req, res) => {
+  const ownerEmail = String(req.query.owner_email || DEFAULT_OWNER).trim().toLowerCase();
+  const tok = await getValidAccessToken(ownerEmail);
+  res.json({ ok: true, owner_email: ownerEmail, connected: Boolean(tok), oauth_configured: isOAuthConfigured() });
+});
+
+router.post('/api/appointments/oauth/revoke', async (req, res) => {
+  const ownerEmail = String((req.body && req.body.owner_email) || req.query.owner_email || DEFAULT_OWNER)
+    .trim().toLowerCase();
+  await revoke(ownerEmail);
+  res.json({ ok: true, owner_email: ownerEmail, revoked: true });
+});
+
+// ─── Admin list (basic-auth) ───────────────────────────────────────────────
+router.get('/api/appointments/list', async (req, res) => {
+  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 || !expectedPass) {
+    res.set('WWW-Authenticate', 'Basic realm="Appointments Admin"');
+    return res.status(401).json({ ok: false, error: 'auth required' });
+  }
+  const [u, p] = Buffer.from(m[1], 'base64').toString('utf8').split(':');
+  if (u !== expectedUser || p !== expectedPass) {
+    return res.status(401).json({ ok: false, error: 'bad credentials' });
+  }
+  const owner = req.query.owner_email ? String(req.query.owner_email).toLowerCase() : null;
+  const r = await query(
+    owner
+      ? `SELECT id,name,email,phone,owner_email,appointment_type,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,phone,owner_email,appointment_type,slot_start,slot_end,status,google_event_id,created_at
+           FROM appt_appointments ORDER BY slot_start DESC LIMIT 200`,
+    owner ? [owner] : [],
+  );
+  res.json({ ok: true, count: r.rowCount, rows: r.rows });
+});
+
+module.exports = router;
diff --git a/shopify/quote-api/appointments/run-local.js b/shopify/quote-api/appointments/run-local.js
new file mode 100644
index 00000000..13d0cadf
--- /dev/null
+++ b/shopify/quote-api/appointments/run-local.js
@@ -0,0 +1,16 @@
+/**
+ * run-local.js — standalone runner for local dev / verification ONLY.
+ * On Kamatera go-live the router is mounted into the existing Koroseal quote
+ * app instead (see ../server.js + README.md). Not used in production.
+ *
+ *   PORT=4499 node appointments/run-local.js
+ */
+const express = require('express');
+const appointments = require('./router');
+
+const app = express();
+app.use(appointments);
+app.get('/', (_req, res) => res.json({ ok: true, service: 'dw-appointments (local runner)' }));
+
+const PORT = process.env.PORT || 4499;
+app.listen(PORT, () => console.log(`[dw-appointments] local runner on http://127.0.0.1:${PORT}`));
diff --git a/shopify/quote-api/appointments/slots.js b/shopify/quote-api/appointments/slots.js
new file mode 100644
index 00000000..b5bdac5f
--- /dev/null
+++ b/shopify/quote-api/appointments/slots.js
@@ -0,0 +1,114 @@
+/**
+ * slots.js — 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 date.
+ *
+ * Owner local timezone is America/Los_Angeles (Steve's universe is PT). Ported
+ * verbatim from the ventura-corridor engine (TS → CommonJS) — the DST-safe
+ * offset math is the load-bearing part; don't "simplify" it.
+ */
+const { query } = require('./db');
+const { busy: gcalBusy } = require('./calendar');
+
+const OWNER_TZ = 'America/Los_Angeles';
+
+/** UTC Date for a given local-PT date at H:M minutes-from-midnight (DST-safe). */
+function ptLocalToUTC(dateYYYYMMDD, minutesFromMidnight) {
+  const hour = Math.floor(minutesFromMidnight / 60);
+  const minute = minutesFromMidnight % 60;
+  // Read the target tz's offset for this specific local date (handles DST).
+  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';
+  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;
+  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 (Sun=0..Sat=6) for a YYYY-MM-DD interpreted in PT. */
+function dayOfWeekPT(dateYYYYMMDD) {
+  const noon = ptLocalToUTC(dateYYYYMMDD, 12 * 60);
+  return new Date(noon).getUTCDay();
+}
+
+/**
+ * @returns {Promise<Array<{slot_start:string, slot_end:string}>>}
+ */
+async function computeSlots(ownerEmail, dateYYYYMMDD) {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(dateYYYYMMDD)) return [];
+
+  const dow = dayOfWeekPT(dateYYYYMMDD);
+  const av = await query(
+    `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 from each availability window.
+  const candidates = [];
+  for (const row of av.rows) {
+    for (let mm = row.start_min; mm + row.slot_duration_min <= row.end_min; mm += row.slot_duration_min) {
+      const s = ptLocalToUTC(dateYYYYMMDD, mm);
+      const e = ptLocalToUTC(dateYYYYMMDD, mm + row.slot_duration_min);
+      candidates.push({ slot_start: s.toISOString(), slot_end: e.toISOString() });
+    }
+  }
+  if (!candidates.length) return [];
+
+  const dayStart = ptLocalToUTC(dateYYYYMMDD, 0).toISOString();
+  const dayEnd = ptLocalToUTC(dateYYYYMMDD, 24 * 60).toISOString();
+
+  // 1. DB-booked slots (confirmed | pending block the slot; cancelled freed).
+  const booked = await query(
+    `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 (only if the owner connected a calendar).
+  let gcal = [];
+  try {
+    gcal = await gcalBusy(ownerEmail, dayStart, dayEnd);
+  } catch (e) {
+    console.error('[appointments] gcalBusy threw, treating as empty:', e);
+  }
+
+  const busyWindows = [
+    ...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) })),
+  ];
+
+  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; // drop past slots
+    for (const b of busyWindows) {
+      if (cs < b.e && ce > b.s) return false; // overlap
+    }
+    return true;
+  });
+}
+
+module.exports = { computeSlots, ptLocalToUTC, dayOfWeekPT, OWNER_TZ };
diff --git a/shopify/quote-api/server.js b/shopify/quote-api/server.js
index adb4b36d..a1c7893a 100644
--- a/shopify/quote-api/server.js
+++ b/shopify/quote-api/server.js
@@ -1,17 +1,19 @@
 /**
- * server.js — minimal Express app exposing the generalized SKU-inquiry endpoint.
- * Mirrors the Koroseal quote server (Kamatera) but for /api/sku-inquiry.
+ * server.js — DW backend Express app: SKU-inquiry + appointment scheduling.
+ * Mirrors the Koroseal quote server (Kamatera).
  *
  * Integration options on go-live (GATED — deploy is Steve-gated):
- *   A) Mount the route into the existing Koroseal quote Express app on Kamatera:
- *        const { handleSkuInquiry } = require('./sku-inquiry-handler');
- *        app.post('/api/sku-inquiry', handleSkuInquiry);
- *   B) Run this standalone under pm2 and proxy /api/sku-inquiry to it from nginx,
- *      same way /api/koroseal-quote is proxied to the storefront domain.
+ *   A) Mount the routes into the existing Koroseal quote Express app on Kamatera:
+ *        app.post('/api/sku-inquiry', require('./sku-inquiry-handler').handleSkuInquiry);
+ *        app.use(require('./appointments/router'));        // /api/appointments/* + /appointments/:id
+ *   B) Run this standalone under pm2 and nginx-proxy the paths to it from the
+ *      storefront domain (same way /api/koroseal-quote is proxied today).
+ * Appointment data lives in dw_unified (appt_ tables). See appointments/README.md.
  */
 const express = require('express');
 const cors = require('cors');
 const { handleSkuInquiry } = require('./sku-inquiry-handler');
+const appointments = require('./appointments/router'); // sets its own permissive CORS
 
 const app = express();
 app.use(cors({ origin: process.env.CORS_ORIGIN || 'https://www.designerwallcoverings.com' }));
@@ -20,6 +22,10 @@ app.use(express.json({ limit: '64kb' }));
 app.get('/health', (_req, res) => res.json({ ok: true, service: 'sku-inquiry' }));
 app.post('/api/sku-inquiry', handleSkuInquiry);
 
+// Appointment scheduling — availability, booking, confirmation, calendar sync.
+// Folded into this DW app so the Shopify storefront fetches it cross-origin.
+app.use(appointments);
+
 const PORT = process.env.PORT || 3002;
 if (require.main === module) {
   app.listen(PORT, () => console.log(`sku-inquiry api on :${PORT}`));
diff --git a/shopify/sections/appointment-booking.liquid b/shopify/sections/appointment-booking.liquid
new file mode 100644
index 00000000..22952b95
--- /dev/null
+++ b/shopify/sections/appointment-booking.liquid
@@ -0,0 +1,299 @@
+{%- comment -%}
+  appointment-booking.liquid — Designer Wallcoverings appointment scheduler.
+
+  Self-contained OS-2.0 section. All CSS/JS scoped to #appt-{{ section.id }} so it
+  never collides with the theme. Talks to the DW appointments API (the mountable
+  router in shopify/quote-api/appointments) via fetch — availability + book.
+
+  Settings (Theme Editor):
+    api_base           backend origin, e.g. https://www.designerwallcoverings.com
+    owner_email        calendar owner (info@designerwallcoverings.com)
+    heading / intro    page copy
+    in_person_note     small print under the In-person option
+    days_ahead         how many days forward to offer
+{%- endcomment -%}
+
+{%- assign api_base = section.settings.api_base | default: 'https://www.designerwallcoverings.com' | strip -%}
+{%- assign owner_email = section.settings.owner_email | default: 'info@designerwallcoverings.com' | strip -%}
+{%- assign days_ahead = section.settings.days_ahead | default: 21 -%}
+
+<section id="appt-{{ section.id }}" class="appt-root" data-api="{{ api_base }}" data-owner="{{ owner_email }}" data-days="{{ days_ahead }}">
+  <div class="appt-wrap">
+    <p class="appt-kicker">{{ section.settings.kicker | default: 'Designer Wallcoverings' }}</p>
+    <h2 class="appt-h">{{ section.settings.heading | default: 'Book an appointment' }}</h2>
+    {%- if section.settings.intro != blank -%}<p class="appt-intro">{{ section.settings.intro }}</p>{%- endif -%}
+
+    <div class="appt-card">
+      <!-- STEP 1 — type -->
+      <div class="appt-step">
+        <div class="appt-step-t"><span>1</span> How would you like to meet?</div>
+        <div class="appt-types" role="radiogroup" aria-label="Appointment type">
+          <button type="button" class="appt-type is-active" data-type="phone" role="radio" aria-checked="true">
+            <span class="appt-type-ic" aria-hidden="true">☎</span>
+            <span class="appt-type-l">Phone call</span>
+            <span class="appt-type-s">We call you · 1 hour</span>
+          </button>
+          <button type="button" class="appt-type" data-type="in_person" role="radio" aria-checked="false">
+            <span class="appt-type-ic" aria-hidden="true">⌖</span>
+            <span class="appt-type-l">In person at DW</span>
+            <span class="appt-type-s">{{ section.settings.in_person_note | default: 'At our showroom · 1 hour' }}</span>
+          </button>
+        </div>
+      </div>
+
+      <!-- STEP 2 — day -->
+      <div class="appt-step">
+        <div class="appt-step-t"><span>2</span> Choose a day</div>
+        <div class="appt-pager">
+          <button type="button" class="appt-pg" data-pg="prev" aria-label="Earlier days">‹</button>
+          <span class="appt-range" aria-live="polite"></span>
+          <button type="button" class="appt-pg" data-pg="next" aria-label="Later days">›</button>
+        </div>
+        <div class="appt-days" role="listbox" aria-label="Available days"></div>
+      </div>
+
+      <!-- STEP 3 — time -->
+      <div class="appt-step">
+        <div class="appt-step-t"><span>3</span> Choose a time <em>(Pacific)</em></div>
+        <div class="appt-slots" aria-live="polite"><p class="appt-hint">Pick a day above to see open times.</p></div>
+      </div>
+
+      <!-- STEP 4 — details -->
+      <form class="appt-form" novalidate>
+        <div class="appt-step-t"><span>4</span> Your details</div>
+        <div class="appt-grid">
+          <label class="appt-field"><span>Name</span><input name="name" required autocomplete="name"></label>
+          <label class="appt-field"><span>Email</span><input name="email" type="email" required autocomplete="email"></label>
+          <label class="appt-field" data-phone><span>Phone <em class="appt-req">(required)</em></span><input name="phone" type="tel" autocomplete="tel"></label>
+          <label class="appt-field appt-span"><span>What would you like to discuss? <em>(optional)</em></span><textarea name="notes" rows="3" placeholder="Rooms, patterns, square footage, timeline…"></textarea></label>
+        </div>
+        <div class="appt-foot">
+          <p class="appt-sel"><em>Select a type, day and time above.</em></p>
+          <button type="submit" class="appt-book" disabled>Confirm appointment</button>
+        </div>
+        <div class="appt-msg" role="status" aria-live="assertive"></div>
+      </form>
+    </div>
+
+    <!-- SUCCESS -->
+    <div class="appt-done" hidden>
+      <div class="appt-done-ic" aria-hidden="true">✓</div>
+      <h3>You're booked.</h3>
+      <p class="appt-done-when"></p>
+      <div class="appt-done-actions">
+        <a class="appt-book appt-ics" href="#">Add to calendar</a>
+        <a class="appt-ghost appt-conf" href="#" target="_blank" rel="noopener">View confirmation</a>
+      </div>
+      <button type="button" class="appt-again">Book another</button>
+    </div>
+  </div>
+</section>
+
+<style>
+  #appt-{{ section.id }}.appt-root{
+    --bg:#f7f4ef;--card:#fff;--ink:#2a2622;--soft:#6b6357;--rule:#e7e1d7;
+    --gold:#9c7a3c;--accent:#2a2622;--accent-ink:#fff;--active:#f3ece0;
+    background:var(--bg);color:var(--ink);padding:48px 18px;
+    font-family:{{ section.settings.body_font_stack | default: "-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" }};
+  }
+  #appt-{{ section.id }} *{box-sizing:border-box}
+  #appt-{{ section.id }} .appt-wrap{max-width:760px;margin:0 auto}
+  #appt-{{ section.id }} .appt-kicker{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 8px}
+  #appt-{{ section.id }} .appt-h{font-family:'Cormorant Garamond',Georgia,serif;font-weight:600;font-size:40px;line-height:1.05;margin:0 0 10px}
+  #appt-{{ section.id }} .appt-intro{color:var(--soft);font-size:16px;margin:0 0 26px;max-width:54ch}
+  #appt-{{ section.id }} .appt-card{background:var(--card);border:1px solid var(--rule);border-radius:16px;padding:28px;box-shadow:0 30px 60px -42px rgba(42,38,34,.45)}
+  #appt-{{ section.id }} .appt-step{padding:6px 0 22px;border-bottom:1px solid var(--rule);margin-bottom:22px}
+  #appt-{{ section.id }} .appt-step-t{display:flex;align-items:center;gap:10px;font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);font-weight:600;margin-bottom:14px}
+  #appt-{{ section.id }} .appt-step-t em{font-style:normal;color:var(--soft);text-transform:none;letter-spacing:0;font-weight:400}
+  #appt-{{ section.id }} .appt-step-t span{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--accent);color:var(--accent-ink);font-size:12px}
+  #appt-{{ section.id }} .appt-types{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+  #appt-{{ section.id }} .appt-type{display:flex;flex-direction:column;align-items:flex-start;gap:3px;text-align:left;padding:16px;border:1px solid var(--rule);border-radius:12px;background:#fff;cursor:pointer;transition:.15s}
+  #appt-{{ section.id }} .appt-type:hover{border-color:var(--gold)}
+  #appt-{{ section.id }} .appt-type.is-active{border-color:var(--accent);background:var(--active);box-shadow:inset 0 0 0 1px var(--accent)}
+  #appt-{{ section.id }} .appt-type-ic{font-size:20px}
+  #appt-{{ section.id }} .appt-type-l{font-weight:600;font-size:15px}
+  #appt-{{ section.id }} .appt-type-s{font-size:12.5px;color:var(--soft)}
+  #appt-{{ section.id }} .appt-pager{display:flex;align-items:center;gap:14px;margin-bottom:12px}
+  #appt-{{ section.id }} .appt-pg{width:34px;height:34px;border:1px solid var(--rule);background:#fff;border-radius:8px;cursor:pointer;font-size:18px;line-height:1;color:var(--ink)}
+  #appt-{{ section.id }} .appt-pg:disabled{opacity:.32;cursor:not-allowed}
+  #appt-{{ section.id }} .appt-range{font-size:13px;color:var(--soft);letter-spacing:.04em}
+  #appt-{{ section.id }} .appt-days{display:grid;grid-template-columns:repeat(7,1fr);gap:7px}
+  #appt-{{ section.id }} .appt-day{padding:10px 2px;border:1px solid var(--rule);border-radius:9px;background:#fff;text-align:center;cursor:pointer;transition:.12s}
+  #appt-{{ section.id }} .appt-day:hover:not(.is-off){border-color:var(--gold)}
+  #appt-{{ section.id }} .appt-day.is-active{border-color:var(--accent);background:var(--active);box-shadow:inset 0 0 0 1px var(--accent)}
+  #appt-{{ section.id }} .appt-day.is-off{opacity:.34;cursor:not-allowed}
+  #appt-{{ section.id }} .appt-day .dow{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft)}
+  #appt-{{ section.id }} .appt-day .num{font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;line-height:1;margin-top:3px}
+  #appt-{{ section.id }} .appt-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:8px;min-height:46px}
+  #appt-{{ section.id }} .appt-slot{padding:11px 6px;border:1px solid var(--rule);border-radius:8px;background:#fff;cursor:pointer;font-size:14px;text-align:center;transition:.12s}
+  #appt-{{ section.id }} .appt-slot:hover{border-color:var(--gold)}
+  #appt-{{ section.id }} .appt-slot.is-active{border-color:var(--accent);background:var(--accent);color:var(--accent-ink)}
+  #appt-{{ section.id }} .appt-hint,#appt-{{ section.id }} .appt-empty{grid-column:1/-1;color:var(--soft);font-style:italic;font-size:14px;padding:6px 0}
+  #appt-{{ section.id }} .appt-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+  #appt-{{ section.id }} .appt-span{grid-column:1/-1}
+  #appt-{{ section.id }} .appt-field{display:block}
+  #appt-{{ section.id }} .appt-field>span{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--soft);margin-bottom:6px}
+  #appt-{{ section.id }} .appt-field em{text-transform:none;letter-spacing:0;color:var(--soft)}
+  #appt-{{ section.id }} .appt-field input,#appt-{{ section.id }} .appt-field textarea{width:100%;padding:12px;border:1px solid var(--rule);border-radius:9px;font:inherit;font-size:15px;color:var(--ink);background:#fff}
+  #appt-{{ section.id }} .appt-field input:focus,#appt-{{ section.id }} .appt-field textarea:focus{outline:none;border-color:var(--accent)}
+  #appt-{{ section.id }} .appt-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:20px;flex-wrap:wrap}
+  #appt-{{ section.id }} .appt-sel{font-size:14px;color:var(--soft);margin:0}
+  #appt-{{ section.id }} .appt-sel strong{color:var(--ink)}
+  #appt-{{ section.id }} .appt-book{background:var(--accent);color:var(--accent-ink);border:none;border-radius:9px;padding:14px 26px;font-size:13px;letter-spacing:.06em;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block}
+  #appt-{{ section.id }} .appt-book:disabled{opacity:.4;cursor:not-allowed}
+  #appt-{{ section.id }} .appt-ghost{border:1px solid var(--rule);border-radius:9px;padding:14px 26px;font-size:13px;color:var(--ink);text-decoration:none;display:inline-block}
+  #appt-{{ section.id }} .appt-msg{margin-top:14px;font-size:14px}
+  #appt-{{ section.id }} .appt-msg.is-err{color:#9a3b3b}
+  #appt-{{ section.id }} .appt-done{text-align:center;background:var(--card);border:1px solid var(--rule);border-radius:16px;padding:44px 28px}
+  #appt-{{ section.id }} .appt-done-ic{width:54px;height:54px;border-radius:50%;background:#eef7ee;color:#2f6b2f;font-size:26px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
+  #appt-{{ section.id }} .appt-done h3{font-family:'Cormorant Garamond',Georgia,serif;font-size:30px;margin:0 0 6px}
+  #appt-{{ section.id }} .appt-done-when{color:var(--soft);margin:0 0 22px}
+  #appt-{{ section.id }} .appt-done-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
+  #appt-{{ section.id }} .appt-again{margin-top:18px;background:none;border:none;color:var(--gold);cursor:pointer;font-size:13px;text-decoration:underline}
+  @media (max-width:560px){
+    #appt-{{ section.id }} .appt-h{font-size:30px}
+    #appt-{{ section.id }} .appt-types,#appt-{{ section.id }} .appt-grid{grid-template-columns:1fr}
+    #appt-{{ section.id }} .appt-days{grid-template-columns:repeat(4,1fr)}
+  }
+</style>
+
+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600&display=swap" media="print" onload="this.media='all'">
+
+<script>
+(function(){
+  var root = document.getElementById('appt-{{ section.id }}');
+  if(!root || root.dataset.bound) return; root.dataset.bound='1';
+  var API = root.dataset.api.replace(/\/+$/,''), OWNER = root.dataset.owner, DAYS = parseInt(root.dataset.days,10)||21;
+  var $ = function(s){return root.querySelector(s)}, $$ = function(s){return [].slice.call(root.querySelectorAll(s))};
+  var state = {type:'phone', date:null, slot:null, pageStart:0};
+  var availCache = {}; // iso -> slots[]
+
+  function fmtTime(iso){return new Date(iso).toLocaleTimeString([], {hour:'numeric',minute:'2-digit',timeZone:'America/Los_Angeles'});}
+  function fmtLong(iso){return new Date(iso).toLocaleString([], {weekday:'long',month:'long',day:'numeric',hour:'numeric',minute:'2-digit',timeZone:'America/Los_Angeles'});}
+  function isoOf(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}
+
+  // ── type ──
+  $$('.appt-type').forEach(function(b){b.addEventListener('click',function(){
+    state.type=b.dataset.type;
+    $$('.appt-type').forEach(function(x){var on=x===b;x.classList.toggle('is-active',on);x.setAttribute('aria-checked',on);});
+    var pf=$('.appt-field[data-phone]'); var req=pf.querySelector('.appt-req');
+    if(state.type==='phone'){req.textContent='(required)';} else {req.textContent='(optional)';}
+    updateFoot();
+  });});
+
+  // ── days ──
+  function renderDays(){
+    var wrap=$('.appt-days'), now=new Date(), html='', first=null, last=null;
+    for(var i=0;i<7;i++){
+      var idx=state.pageStart+i; if(idx>=DAYS) break;
+      var d=new Date(now.getFullYear(),now.getMonth(),now.getDate()+idx);
+      var iso=isoOf(d), dow=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
+      var off=(d.getDay()===0||d.getDay()===6); // weekends closed (9–4 Mon–Fri)
+      if(!first)first=d; last=d;
+      html+='<div class="appt-day'+(off?' is-off':'')+(state.date===iso?' is-active':'')+'" data-iso="'+iso+'" role="option" aria-selected="'+(state.date===iso)+'"'+(off?'':' tabindex="0"')+'><div class="dow">'+dow+'</div><div class="num">'+d.getDate()+'</div></div>';
+    }
+    wrap.innerHTML=html;
+    $('.appt-range').textContent = first&&last ? first.toLocaleDateString([],{month:'short',day:'numeric'})+' – '+last.toLocaleDateString([],{month:'short',day:'numeric'}) : '';
+    $('.appt-pg[data-pg="prev"]').disabled = state.pageStart===0;
+    $('.appt-pg[data-pg="next"]').disabled = state.pageStart+7>=DAYS;
+    $$('.appt-day').forEach(function(el){
+      if(el.classList.contains('is-off')) return;
+      var pick=function(){selectDay(el.dataset.iso);};
+      el.addEventListener('click',pick);
+      el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();pick();}});
+    });
+  }
+  $('.appt-pg[data-pg="prev"]').addEventListener('click',function(){state.pageStart=Math.max(0,state.pageStart-7);renderDays();});
+  $('.appt-pg[data-pg="next"]').addEventListener('click',function(){if(state.pageStart+7<DAYS){state.pageStart+=7;renderDays();}});
+
+  function selectDay(iso){
+    state.date=iso; state.slot=null; renderDays(); updateFoot();
+    var box=$('.appt-slots'); box.innerHTML='<p class="appt-hint">Loading open times…</p>';
+    var done=function(slots){availCache[iso]=slots;renderSlots(slots);};
+    if(availCache[iso]) return done(availCache[iso]);
+    fetch(API+'/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||'load failed'); done(j.slots||[]); })
+      .catch(function(e){ box.innerHTML='<p class="appt-empty">Could not load times ('+e.message+'). Please try again.</p>'; });
+  }
+
+  function renderSlots(slots){
+    var box=$('.appt-slots');
+    if(!slots.length){ box.innerHTML='<p class="appt-empty">No open times that day — try another.</p>'; return; }
+    box.innerHTML = slots.map(function(s){
+      var on=state.slot&&state.slot.slot_start===s.slot_start;
+      return '<button type="button" class="appt-slot'+(on?' is-active':'')+'" data-s="'+s.slot_start+'" data-e="'+s.slot_end+'">'+fmtTime(s.slot_start)+'</button>';
+    }).join('');
+    $$('.appt-slot').forEach(function(el){el.addEventListener('click',function(){
+      state.slot={slot_start:el.dataset.s,slot_end:el.dataset.e};
+      $$('.appt-slot').forEach(function(x){x.classList.toggle('is-active',x===el);});
+      updateFoot();
+    });});
+  }
+
+  function updateFoot(){
+    var sel=$('.appt-sel'), btn=$('.appt-book');
+    if(state.slot){
+      sel.innerHTML='Selected: <strong>'+fmtLong(state.slot.slot_start)+'</strong> Pacific';
+      btn.disabled=false;
+    } else { sel.innerHTML='<em>Select a type, day and time above.</em>'; btn.disabled=true; }
+  }
+
+  // ── submit ──
+  $('.appt-form').addEventListener('submit',function(ev){
+    ev.preventDefault();
+    if(!state.slot) return;
+    var f=ev.target, msg=$('.appt-msg'); msg.textContent=''; msg.className='appt-msg';
+    var name=f.name.value.trim(), email=f.email.value.trim(), phone=f.phone.value.trim(), notes=f.notes.value.trim();
+    if(!name||!email){ msg.textContent='Please enter your name and email.'; msg.className='appt-msg is-err'; return; }
+    if(state.type==='phone'&&!phone){ msg.textContent='A phone number is required for a phone appointment.'; msg.className='appt-msg is-err'; f.phone.focus(); return; }
+    var btn=$('.appt-book'); btn.disabled=true; var prev=btn.textContent; btn.textContent='Booking…';
+    fetch(API+'/api/appointments/book',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({name:name,email:email,phone:phone,notes:notes,appointment_type:state.type,
+        owner_email:OWNER,slot_start:state.slot.slot_start,slot_end:state.slot.slot_end})})
+    .then(function(r){return r.json();})
+    .then(function(j){
+      if(!j.ok) throw new Error(j.error||'Could not book');
+      var d=root.querySelector('.appt-done');
+      d.querySelector('.appt-done-when').textContent=fmtLong(state.slot.slot_start)+' Pacific · '+(state.type==='in_person'?'In person at DW':'Phone call');
+      d.querySelector('.appt-ics').setAttribute('href', j.ics_url);
+      d.querySelector('.appt-conf').setAttribute('href', j.confirmation_url);
+      root.querySelector('.appt-card').hidden=true; d.hidden=false;
+      d.scrollIntoView({behavior:'smooth',block:'center'});
+    })
+    .catch(function(e){
+      msg.textContent=e.message; msg.className='appt-msg is-err'; btn.disabled=false; btn.textContent=prev;
+      if(/taken|available/i.test(e.message)&&state.date){ delete availCache[state.date]; selectDay(state.date); }
+    });
+  });
+
+  $('.appt-again').addEventListener('click',function(){
+    state.slot=null; availCache={}; root.querySelector('.appt-done').hidden=true;
+    root.querySelector('.appt-card').hidden=false; $('.appt-form').reset();
+    $('.appt-slots').innerHTML='<p class="appt-hint">Pick a day above to see open times.</p>';
+    if(state.date) selectDay(state.date); updateFoot();
+  });
+
+  renderDays();
+})();
+</script>
+
+{% schema %}
+{
+  "name": "Appointment Booking",
+  "tag": "section",
+  "class": "section-appointment-booking",
+  "settings": [
+    { "type": "header", "content": "Backend" },
+    { "type": "text", "id": "api_base", "label": "Appointments API base URL", "default": "https://www.designerwallcoverings.com", "info": "Origin of the DW appointments service (the mounted /api/appointments routes)." },
+    { "type": "text", "id": "owner_email", "label": "Calendar owner email", "default": "info@designerwallcoverings.com" },
+    { "type": "range", "id": "days_ahead", "min": 7, "max": 63, "step": 7, "unit": "d", "label": "Days bookable ahead", "default": 21 },
+    { "type": "header", "content": "Copy" },
+    { "type": "text", "id": "kicker", "label": "Kicker", "default": "Designer Wallcoverings" },
+    { "type": "text", "id": "heading", "label": "Heading", "default": "Book an appointment" },
+    { "type": "richtext", "id": "intro", "label": "Intro", "default": "<p>Speak with a Designer Wallcoverings consultant by phone, or visit us in person. Appointments run one hour, weekdays 9 AM–4 PM Pacific.</p>" },
+    { "type": "text", "id": "in_person_note", "label": "In-person sub-label", "default": "At our showroom · 1 hour" }
+  ],
+  "presets": [{ "name": "Appointment Booking" }]
+}
+{% endschema %}
diff --git a/shopify/templates/page.book-appointment.json b/shopify/templates/page.book-appointment.json
new file mode 100644
index 00000000..ae1f07f1
--- /dev/null
+++ b/shopify/templates/page.book-appointment.json
@@ -0,0 +1,9 @@
+{
+  "sections": {
+    "main": {
+      "type": "appointment-booking",
+      "settings": {}
+    }
+  },
+  "order": ["main"]
+}

← c1d34c95 auto-save: 2026-06-29T08:47:49 (5 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-29T09:48:16 (5 files) — pending-approval/ cae107be →