← 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
A shopify/quote-api/appointments/README.mdA shopify/quote-api/appointments/calendar.jsA shopify/quote-api/appointments/db.jsA shopify/quote-api/appointments/drive-preview.mjsA shopify/quote-api/appointments/migrations/appointments.sqlA shopify/quote-api/appointments/notify.jsA shopify/quote-api/appointments/oauth.jsA shopify/quote-api/appointments/render-preview.jsA shopify/quote-api/appointments/router.jsA shopify/quote-api/appointments/run-local.jsA shopify/quote-api/appointments/slots.jsM shopify/quote-api/server.jsA shopify/sections/appointment-booking.liquidA shopify/templates/page.book-appointment.json
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+
+// ─── 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 →