← back to Prestige Car Wash

server.js

911 lines

'use strict';
/**
 * Prestige Car Wash (PCW) — one Express app serving:
 *   - the public marketing site (/, /services, /contact)
 *   - the Basic-Auth growth admin (/admin) + its bucket APIs (/api/admin/*)
 *
 * Data source: data/*.json snapshots (Postgres is an optional future upgrade; the
 * schema in scripts/db-init.sql mirrors these files). Everything the front-end needs
 * comes through /api/* so the same catalog powers both the public page and the admin.
 */
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const express = require('express');
const helmet = require('helmet');

const app = express();
const PORT = process.env.PORT || 9808;
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'DW2024!';
const DATA = path.join(__dirname, 'data');

// ---- helpers ---------------------------------------------------------------
const readJSON = (f, fallback) => {
  try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); }
  catch { return fallback; }
};
// Re-read on each request so admin edits / script runs show up without a restart.
const services     = () => readJSON('services.json', []);
const competitors  = () => readJSON('competitors.json', []);
const suggestions  = () => readJSON('suggestions.json', []);
const holidays     = () => readJSON('holidays.json', []);
const directories  = () => readJSON('directories.json', []);
const ads          = () => readJSON('ads.json', []);
const bestTimes    = () => readJSON('best-times.json', {});
const places       = () => readJSON('places.json', {});
const socialTpl    = () => readJSON('social-templates.json', { platforms: [], hashtag_sets: {}, templates: [] });
const promos       = () => readJSON('promos.json', { promos: [] });

// Mask a secret to a "present (…last4)" descriptor — never returns the value.
const maskEnv = (key) => {
  const v = process.env[key];
  if (!v) return { key, present: false, hint: '' };
  return { key, present: true, hint: '…' + String(v).slice(-4) };
};

// ---- middleware ------------------------------------------------------------
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      // inline event-handler attributes (onclick=, onerror=) are a separate directive;
      // Helmet defaults it to 'none'. The site/admin use inline handlers (house style,
      // Basic-Auth admin, no user-generated content) so allow them explicitly.
      scriptSrcAttr: ["'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
      imgSrc: ["'self'", 'data:', 'https:'],
      mediaSrc: ["'self'", 'data:', 'blob:'],
      connectSrc: ["'self'"]
    }
  }
}));
app.use(express.json());

// Basic Auth gate for /admin and /api/admin/*
function requireAdmin(req, res, next) {
  const hdr = req.headers.authorization || '';
  const [scheme, encoded] = hdr.split(' ');
  if (scheme === 'Basic' && encoded) {
    const [u, p] = Buffer.from(encoded, 'base64').toString().split(':');
    if (u === ADMIN_USER && p === ADMIN_PASS) return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="Prestige Admin"').status(401).send('Auth required');
}
app.use('/admin', requireAdmin);
app.use('/api/admin', requireAdmin);

// ---- sort (shared by public services grid) --------------------------------
function sortServices(list, mode) {
  const arr = [...list];
  const byTitle = (a, b) => String(a.name).localeCompare(String(b.name));
  switch (mode) {
    case 'title':      return arr.sort(byTitle);
    case 'price-asc':  return arr.sort((a, b) => (a.price || 0) - (b.price || 0) || byTitle(a, b));
    case 'price-desc': return arr.sort((a, b) => (b.price || 0) - (a.price || 0) || byTitle(a, b));
    case 'duration':   return arr.sort((a, b) => (a.duration_min || 0) - (b.duration_min || 0) || byTitle(a, b));
    case 'featured':
    default:           return arr.sort((a, b) => ((b.featured ? 1 : 0) - (a.featured ? 1 : 0)) || (a.sort_order || 99) - (b.sort_order || 99));
  }
}

// Price bands for the left filter panel.
const priceBand = (p) => p <= 25 ? 'Under $25' : p <= 60 ? '$25–$60' : p <= 150 ? '$60–$150' : '$150+';

// ---- public API ------------------------------------------------------------
app.get('/api/health', (req, res) => {
  res.json({
    ok: true, service: 'prestige-car-wash', port: PORT,
    counts: {
      services: services().length, competitors: competitors().length,
      suggestions: suggestions().length, holidays: holidays().length,
      directories: directories().length, ads: ads().length,
      promos: (promos().promos || []).length
    },
    ts: new Date().toISOString()
  });
});

app.get('/api/services', (req, res) => {
  let list = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
  const { category, band, q } = req.query;
  if (category) list = list.filter(s => s.category === category);
  if (band) list = list.filter(s => s.price_band === band);
  if (q) {
    const needle = String(q).toLowerCase();
    list = list.filter(s => [s.name, s.blurb, s.category].join(' ').toLowerCase().includes(needle));
  }
  res.json(sortServices(list, req.query.sort));
});

// Facet counts for the left panel — each dimension counted over the OTHER active filters.
app.get('/api/facets', (req, res) => {
  const base = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
  const tally = (rows, key) => rows.reduce((m, r) => (m[r[key]] = (m[r[key]] || 0) + 1, m), {});
  const applyExcept = (except) => base.filter(s =>
    (except === 'category' || !req.query.category || s.category === req.query.category) &&
    (except === 'band' || !req.query.band || s.price_band === req.query.band)
  );
  res.json({
    category: tally(applyExcept('category'), 'category'),
    band: tally(applyExcept('band'), 'price_band')
  });
});

app.get('/api/best-times', (req, res) => res.json(bestTimes()));
// Emit ready-to-use photo/video URLs so the client is format-agnostic:
// Google photo refs → the proxy route; local paths (real business photos) → direct.
app.get('/api/places', (req, res) => {
  const p = places();
  const photo_urls = (p.photos || []).map((ph, i) =>
    /^places\//.test(ph) ? `/api/places/photo/${i}` : '/' + String(ph).replace(/^\//, ''));
  const videos = (p.videos || []).map(v => ({
    ...v,
    src: '/' + String(v.src || '').replace(/^\//, ''),
    poster: v.poster ? '/' + String(v.poster).replace(/^\//, '') : ''
  }));
  res.json({ ...p, photo_urls, videos });
});

// ---- admin bucket APIs -----------------------------------------------------
app.get('/api/admin/competitors', (req, res) => res.json(competitors()));
app.get('/api/admin/suggestions', (req, res) => res.json(suggestions()));
app.get('/api/admin/holidays', (req, res) => res.json(holidays()));
app.get('/api/admin/directories', (req, res) => res.json(directories()));
app.get('/api/admin/ads', (req, res) => res.json(ads()));
app.get('/api/admin/services', (req, res) => res.json(services()));
app.get('/api/admin/best-times', (req, res) => res.json(bestTimes()));
app.get('/api/admin/places', (req, res) => res.json(places()));

// ---- Socials: create + post from ONE place (draft-only; never auto-posts) --
// Media library: every image/video under /media (incl. media/real/), each video
// paired with its poster image (svc-<stem>.png or <stem>-poster.jpg) so the
// composer can preview + attach any asset. Read-only scan of the media dir.
const MEDIA_DIR = path.join(__dirname, 'media');
function mediaLibrary() {
  const walk = (dir, prefix) => {
    let out = [];
    let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
    for (const e of entries) {
      if (e.isDirectory()) { out = out.concat(walk(path.join(dir, e.name), prefix + e.name + '/')); continue; }
      const ext = path.extname(e.name).toLowerCase();
      const kind = ['.mp4', '.mov', '.webm'].includes(ext) ? 'video'
        : ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? 'image' : null;
      if (!kind) continue;
      out.push({ name: e.name, stem: e.name.replace(/\.[^.]+$/, ''), kind, url: '/media/' + prefix + e.name });
    }
    return out;
  };
  const all = walk(MEDIA_DIR, '');
  const imgByStem = {};
  all.filter(m => m.kind === 'image').forEach(m => { imgByStem[m.stem] = m.url; });
  all.forEach(m => { if (m.kind === 'video') m.poster = imgByStem[m.stem] || imgByStem[m.stem + '-poster'] || ''; });
  // Hide poster stills that only exist to back a video (keep genuine standalone photos).
  const videoStems = new Set(all.filter(m => m.kind === 'video').map(m => m.stem));
  return all
    .filter(m => !(m.kind === 'image' && (videoStems.has(m.stem) || videoStems.has(m.stem.replace(/-poster$/, '')))))
    .sort((a, b) => a.name.localeCompare(b.name));
}
app.get('/api/admin/media', (req, res) => res.json(mediaLibrary()));
app.get('/api/admin/social/templates', (req, res) => res.json(socialTpl()));

// ---- Promos: HyperFrames-generated brand films -----------------------------
// Curated, first-class list of the finished brand videos (distinct from the raw
// svc-* service clips in the media library). Repo-relative src/poster are
// normalized to absolute /media URLs so the client stays format-agnostic; any
// promo whose file is missing on disk is flagged (ready=false) rather than
// served as a broken <video>. mm:ss is precomputed for the card badge.
const abs = (p) => '/' + String(p || '').replace(/^\//, '');
const mmss = (s) => { const sec=Math.max(0,parseInt(s,10)||0); return `${Math.floor(sec/60)}:${String(sec%60).padStart(2,'0')}`; };
app.get('/api/admin/promos', (req, res) => {
  const list = (promos().promos || []).map(p => {
    const rel = String(p.src || '').replace(/^\//, '');
    const ready = rel ? fs.existsSync(path.join(__dirname, rel)) : false;
    return {
      ...p,
      src: abs(p.src),
      poster: p.poster ? abs(p.poster) : '',
      duration_label: p.duration_sec ? mmss(p.duration_sec) : '',
      ready
    };
  });
  res.json(list);
});

// Save a composed post as a DRAFT (append-only). This NEVER posts to any network —
// the front-end deep-links into each platform's native composer for the human to post.
app.post('/api/admin/social/draft', (req, res) => {
  const b = req.body || {};
  const draft = {
    id: 'sd-' + Date.now().toString(36),
    caption: cap(b.caption, 2200),
    platforms: Array.isArray(b.platforms) ? b.platforms.slice(0, 12).map(p => cap(p, 40)) : [],
    asset: b.asset ? { url: cap(b.asset.url, 300), kind: cap(b.asset.kind, 12), name: cap(b.asset.name, 160) } : null,
    scheduled_for: cap(b.scheduled_for, 40),
    created_at: new Date().toISOString(), status: 'draft'
  };
  if (!draft.caption && !draft.asset) return res.status(400).json({ ok: false, error: 'need a caption or an asset' });
  try {
    const dir = path.join(__dirname, 'reports');
    fs.mkdirSync(dir, { recursive: true });
    fs.appendFileSync(path.join(dir, 'social-drafts.jsonl'), JSON.stringify(draft) + '\n');
  } catch { return res.status(500).json({ ok: false, error: 'could not save draft' }); }
  res.json({ ok: true, draft });
});
app.get('/api/admin/social/drafts', (req, res) => {
  try {
    const raw = fs.readFileSync(path.join(__dirname, 'reports', 'social-drafts.jsonl'), 'utf8').trim();
    res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
  } catch { res.json([]); }
});

// Credentials tab — presence + last-4 only. NEVER returns secret values.
app.get('/api/admin/credentials', (req, res) => {
  const keys = [
    { key: 'GEMINI_API_KEY', label: 'Nano Banana (Gemini image)', purpose: 'Generate service stills' },
    { key: 'REPLICATE_API_TOKEN', label: 'SeeDance (Replicate)', purpose: 'Generate wash/wax video clips' },
    { key: 'GOOGLE_PLACES_API_KEY', label: 'Google Places (read)', purpose: 'Live hours/reviews/photos — gated setup' },
    { key: 'PCW_PLACE_ID', label: 'Google Place ID', purpose: 'Which listing to read' },
    { key: 'DATABASE_URL', label: 'Postgres', purpose: 'Optional data backend' }
  ];
  res.json(keys.map(k => ({ ...maskEnv(k.key), label: k.label, purpose: k.purpose })));
});

// "Update Google Place" — draft-write: returns a prefilled Google Business Profile URL.
// No direct API write (owner OAuth + API approval deferred). Front-end opens this in a new tab.
app.post('/api/admin/place/draft-update', (req, res) => {
  const { field } = req.body || {};
  res.json({
    ok: true, mode: 'draft',
    message: `Draft update for "${field || 'listing'}" — opens Google Business Profile prefilled.`,
    url: 'https://business.google.com/edit/l/' + (process.env.PCW_PLACE_ID || '')
  });
});

// Simple per-IP rate limiter for the public form (prevents spam / unbounded lead file).
const _hits = new Map();
function rateLimit(max, windowMs) {
  return (req, res, next) => {
    const ip = req.ip || req.connection.remoteAddress || 'unknown';
    const now = Date.now();
    const arr = (_hits.get(ip) || []).filter(t => now - t < windowMs);
    if (arr.length >= max) return res.status(429).json({ ok: false, error: 'Too many requests — try again shortly.' });
    arr.push(now); _hits.set(ip, arr);
    next();
  };
}
const cap = (s, n) => String(s || '').slice(0, n);

// Contact / booking lead — saved locally + optional webhook notify (env-gated).
// NOTIFY_WEBHOOK (Slack/Discord/Zapier URL) gives leads a real notification path;
// without it the lead is captured in the admin Leads tab. No mass email (that stays gated).
app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
  const b = req.body || {};
  const OK_MSG = "Thanks! We'll follow up to confirm your booking — usually within a few hours.";
  // Honeypot: a hidden field real users never see. Field name is a NON-demographic token
  // (b_confirm) so password managers / mobile autofill won't populate it for a real user and
  // silently drop their lead. If it's filled it's a bot — fake success (no retry), drop it,
  // but LOG the drop so a false-positive is auditable instead of an invisible lost booking.
  if (b.b_confirm) { console.warn('[honeypot] dropped submission ip=%s name=%s', req.ip, cap(b.name, 60)); return res.json({ ok: true, message: OK_MSG }); }
  const name = cap(b.name, 120).trim();
  const phone = cap(b.phone, 40).trim();
  const email = cap(b.email, 160).trim();
  const emailOk = !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  const phoneOk = !phone || phone.replace(/\D/g, '').length >= 7;
  // Validate with human-readable errors so a real lead is never silently lost to a bad submit.
  if (name.length < 2) return res.status(400).json({ ok: false, error: 'Please enter your name.' });
  if (!phone && !email) return res.status(400).json({ ok: false, error: 'Please leave a phone number or email so we can reach you.' });
  if (!emailOk) return res.status(400).json({ ok: false, error: 'That email address doesn’t look right — please double-check it.' });
  if (!phoneOk) return res.status(400).json({ ok: false, error: 'That phone number looks too short — please double-check it.' });
  const lead = {
    id: 'ld_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    name, phone, email, vehicle: cap(b.vehicle, 120), service: cap(b.service, 120),
    preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
    created_at: new Date().toISOString(), source: 'web-form'
  };
  // Duplicate-submit guard: if the same person (name + phone + email + message) already
  // landed within the last 2 minutes, treat it as a double-click — succeed without saving twice.
  try {
    const lp = path.join(__dirname, 'reports', 'leads.jsonl');
    if (fs.existsSync(lp)) {
      const recent = fs.readFileSync(lp, 'utf8').trim().split('\n').slice(-25);
      const now = Date.now();
      const dup = recent.some(l => { try { const o = JSON.parse(l); return (now - new Date(o.created_at).getTime() < 120000) && o.name === name && (o.phone || '') === phone && (o.email || '') === email && (o.message || '') === lead.message; } catch { return false; } });
      if (dup) return res.json({ ok: true, message: "Thanks — we’ve already got your request and we’ll follow up shortly." });
    }
  } catch { /* if the dedupe read fails, fall through and save (never block a real lead) */ }
  try {
    const dir = path.join(__dirname, 'reports');
    fs.mkdirSync(dir, { recursive: true });
    fs.appendFileSync(path.join(dir, 'leads.jsonl'), JSON.stringify(lead) + '\n');
  } catch (e) { return res.status(500).json({ ok: false, error: 'could not save lead' }); }
  // Fire-and-forget notification if a webhook is configured.
  if (process.env.NOTIFY_WEBHOOK) {
    fetch(process.env.NOTIFY_WEBHOOK, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: `🚗 New Prestige lead: ${lead.name} (${lead.phone || lead.email}) — ${lead.service || 'general'}${lead.message ? ' · ' + lead.message : ''}` })
    }).catch(() => {});
  }
  res.json({ ok: true, message: "Thanks! We'll follow up to confirm your booking — usually within a few hours." });
});

// Lead pipeline status — kept in a SEPARATE json map (keyed by lead id, or created_at for
// legacy leads) so the append-only leads.jsonl log is never rewritten. dtd:C 2026-07-26.
const LEAD_STATUSES = ['new', 'contacted', 'booked', 'won', 'lost'];
const leadStatusPath = () => path.join(__dirname, 'reports', 'lead-status.json');
function readLeadStatus() { try { return JSON.parse(fs.readFileSync(leadStatusPath(), 'utf8')); } catch { return {}; } }

// Admin: view captured leads, each merged with its pipeline status (default 'new').
app.get('/api/admin/leads', (req, res) => {
  try {
    const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
    const st = readLeadStatus();
    const leads = raw ? raw.split('\n').map(l => JSON.parse(l)) : [];
    leads.forEach(l => { l.status = st[l.id || l.created_at] || 'new'; });
    res.json(leads.reverse());
  } catch { res.json([]); }
});

// Admin: export all leads (+ pipeline status) as CSV for offline follow-up / records.
app.get('/api/admin/leads.csv', (req, res) => {
  let leads = [];
  try {
    const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
    const st = readLeadStatus();
    leads = raw ? raw.split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
    leads.forEach(l => { l.status = st[l.id || l.created_at] || 'new'; });
    leads.reverse();
  } catch { /* empty export is still a valid CSV */ }
  const cols = ['created_at', 'name', 'phone', 'email', 'vehicle', 'service', 'preferred', 'message', 'status'];
  const esc = v => {
    let s = String(v == null ? '' : v);
    if (/^[=+\-@]/.test(s)) s = "'" + s;                          // neutralize CSV/formula injection
    return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
  };
  const out = [cols.join(',')].concat(leads.map(l => cols.map(c => esc(l[c])).join(','))).join('\r\n') + '\r\n';
  const day = new Date().toISOString().slice(0, 10);
  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
  res.setHeader('Content-Disposition', `attachment; filename="prestige-leads-${day}.csv"`);
  res.send(out);
});

// Admin: advance a lead through the pipeline (new → contacted → booked/won/lost).
app.post('/api/admin/lead-status', (req, res) => {
  const { id, status } = req.body || {};
  if (!id || !LEAD_STATUSES.includes(status)) return res.status(400).json({ ok: false, error: 'id + valid status required' });
  try {
    const st = readLeadStatus(); st[id] = status;
    fs.mkdirSync(path.join(__dirname, 'reports'), { recursive: true });
    fs.writeFileSync(leadStatusPath(), JSON.stringify(st, null, 2));
    res.json({ ok: true, id, status });
  } catch { res.status(500).json({ ok: false, error: 'could not save status' }); }
});

// ── Self-hosted, privacy-friendly analytics-lite (DTD 2026-07-27) ──────────────────────
// First-party AGGREGATE counts ONLY — no cookies, no IP, no per-user data — so it needs no
// consent banner and stores nothing we can't stand behind. Gives the owner the denominator
// (views) behind their lead counts => a real, honestly-measured conversion rate. NOT GA.
const ANALYTICS_EVENTS = ['pageview', 'form_start', 'form_submit'];
const analyticsPath = () => path.join(__dirname, 'reports', 'analytics.json');
function readAnalytics() { try { return JSON.parse(fs.readFileSync(analyticsPath(), 'utf8')); } catch { return {}; } }
const dayKeyLA = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' }); // YYYY-MM-DD, shop tz

// Public beacon. Whitelisted events only; unknown events are silently ignored (204, no error
// surface). Aggregate increment only — never stores anything about who the visitor is.
app.post('/api/track', rateLimit(60, 60 * 1000), (req, res) => {
  const ev = String((req.body || {}).event || '');
  if (!ANALYTICS_EVENTS.includes(ev)) return res.status(204).end();
  try {
    const a = readAnalytics(); const day = dayKeyLA();
    a[day] = a[day] || {}; a[day][ev] = (a[day][ev] || 0) + 1;
    const days = Object.keys(a).sort();                       // keep the file bounded (last 120 days)
    if (days.length > 120) for (const d of days.slice(0, days.length - 120)) delete a[d];
    fs.mkdirSync(path.join(__dirname, 'reports'), { recursive: true });
    // Atomic write (temp + rename) so a partial/concurrent write can never leave a corrupt
    // analytics.json that a reader would choke on.
    const tmp = analyticsPath() + '.tmp';
    fs.writeFileSync(tmp, JSON.stringify(a));
    fs.renameSync(tmp, analyticsPath());
  } catch { /* analytics must NEVER break a page — swallow */ }
  res.status(204).end();
});

// Admin: aggregate analytics (today / last 7 days / all-time + honest conversion rate).
app.get('/api/admin/analytics', (req, res) => {
  const a = readAnalytics();
  const sum = keys => keys.reduce((o, d) => { const b = a[d] || {}; ANALYTICS_EVENTS.forEach(e => o[e] = (o[e] || 0) + (b[e] || 0)); return o; }, {});
  const allDays = Object.keys(a).sort();
  const last7 = []; for (let i = 0; i < 7; i++) { const d = new Date(); d.setDate(d.getDate() - i); last7.push(d.toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' })); }
  const conv = t => t.pageview ? Math.round((t.form_submit || 0) / t.pageview * 1000) / 10 : 0; // submits per 100 views
  const t7 = sum(last7), tot = sum(allDays);
  res.json({
    today: sum([dayKeyLA()]), last7: t7, total: tot,
    conversion7: conv(t7), conversionTotal: conv(tot),
    series: allDays.slice(-14).map(d => ({ date: d, ...ANALYTICS_EVENTS.reduce((o, e) => (o[e] = (a[d] || {})[e] || 0, o), {}) }))
  });
});

// Public: HONEST "typical wait" estimate derived from the demand model.
// It is NOT a live queue count — it's a transparent function of best-times.json
// demand for the current weekday, always labeled as an estimate, so we never
// display a number we can't verify (the places.json data-honesty rule). dtd:A 2026-07-26.
app.get('/api/wait', (req, res) => {
  const days = ((bestTimes() || {}).traffic || {}).by_day || [];
  // Weekday in the shop's LOCAL tz (LA) — the server clock is UTC, so a Friday-evening
  // CA visitor must not be shown Saturday's level. (contrarian gate, 2026-07-26)
  const dayName = new Date().toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles', weekday: 'short' });
  const today = days.find(d => d.day === dayName);
  const demand = today ? Math.max(1, Math.min(5, Number(today.demand) || 3)) : 3;
  // Report the demand LEVEL the data actually measures — a qualitative busy-ness, NOT a
  // fabricated minute count we can't verify (Steve's data-honesty rule).
  const LEVELS = { 1: ['Quiet', 'little to no wait'], 2: ['Quiet', 'little to no wait'], 3: ['Moderate', 'a short wait is typical'], 4: ['Busy', 'expect a wait'], 5: ['Very busy', 'expect a longer wait'] };
  const [level, phrase] = LEVELS[demand];
  res.json({
    level, phrase, demand, day: dayName,
    estimate: true,
    basis: 'Based on typical demand for this day — not a live queue count'
  });
});

// ---- static + clean URLs ---------------------------------------------------
// Google Place photo proxy — fetches photo media with the server-side key and streams
// it, so the key never reaches the browser. Cached to avoid re-billing on every view.
app.get('/api/places/photo/:i', async (req, res) => {
  const key = process.env.GOOGLE_PLACES_API_KEY;
  const name = (places().photos || [])[parseInt(req.params.i, 10)];
  if (!key || !name) return res.status(404).end();
  const w = Math.min(1600, Math.max(100, parseInt(req.query.w, 10) || 800));
  try {
    const r = await fetch(`https://places.googleapis.com/v1/${name}/media?maxWidthPx=${w}&key=${key}`);
    if (!r.ok) return res.status(502).end();
    res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
    res.set('Cache-Control', 'public, max-age=86400');
    res.end(Buffer.from(await r.arrayBuffer()));
  } catch { res.status(502).end(); }
});

// Inline SVG favicon (avoids a 404 on the browser's automatic request).
app.get('/favicon.svg', (req, res) => res.type('image/svg+xml').send(
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0a84ff"/><text x="16" y="22" font-size="18" text-anchor="middle">🚗</text></svg>'));
app.get('/favicon.ico', (req, res) => res.redirect(302, '/favicon.svg'));
app.use('/media', express.static(path.join(__dirname, 'media')));
app.get(/^\/(.+)\.html$/, (req, res) => res.redirect(301, '/' + req.params[0]));

// LocalBusiness (AutoWash) JSON-LD for local SEO — built LIVE from data/places.json so it
// can never drift from the visible name/address/phone/hours (Google cross-checks NAP).
// Server-rendered into <head> so crawlers see it without executing JS. dtd:B 2026-07-26.
// We only emit fields we can verify — NO aggregateRating (unverified) and NO geo (no lat/lng).
const DOW = { Mon: 'Monday', Tue: 'Tuesday', Wed: 'Wednesday', Thu: 'Thursday', Fri: 'Friday', Sat: 'Saturday', Sun: 'Sunday' };
const DOW_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function to24(t) {
  const m = String(t).match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
  if (!m) return null;
  let h = +m[1]; const ap = m[3].toUpperCase();
  if (ap === 'PM' && h !== 12) h += 12;
  if (ap === 'AM' && h === 12) h = 0;
  return String(h).padStart(2, '0') + ':' + m[2];
}
function parseHoursLine(line) {
  const times = String(line).match(/\d{1,2}:\d{2}\s*[AP]M/gi);
  if (!times || times.length < 2) return null;
  const opens = to24(times[0]), closes = to24(times[1]);
  const dayPart = String(line).split(/\s+\d/)[0].trim();
  const dr = dayPart.split(/[–—-]/).map(s => s.trim());
  let days = [];
  if (dr.length === 2) { const a = DOW_ORDER.indexOf(dr[0]), b = DOW_ORDER.indexOf(dr[1]); if (a >= 0 && b >= 0) days = DOW_ORDER.slice(a, b + 1); }
  else if (DOW[dr[0]]) days = [dr[0]];
  if (!days.length || !opens || !closes) return null;
  return { '@type': 'OpeningHoursSpecification', dayOfWeek: days.map(d => DOW[d]), opens, closes };
}
// Canonical serving origin. prestige.agentabrams.com is the PERMANENT public home for this
// build (Steve 2026-07-27: the prestigehandcarwash.com apex does not exist — agentabrams only).
// All self-referential SEO (canonical, og:url, sitemap, article URLs, entity url) points here.
const SITE_ORIGIN = 'https://prestige.agentabrams.com';
function siteOrigin() { return (SITE_ORIGIN || '').replace(/\/$/, ''); }
function buildJsonLd() {
  const p = places();
  if (!p || !p.name) return null;
  const ld = { '@context': 'https://schema.org', '@type': 'AutoWash', name: p.name };
  if (p.legal_name && p.legal_name !== p.name) ld.alternateName = p.legal_name;
  if (siteOrigin()) ld.url = siteOrigin();
  if (p.phone) ld.telephone = p.phone;
  if (p.address) {
    const parts = p.address.split(',').map(s => s.trim());
    const addr = { '@type': 'PostalAddress', addressCountry: 'US' };
    if (parts[0]) addr.streetAddress = parts[0];
    if (parts[1]) addr.addressLocality = parts[1];
    if (parts[2]) { const sz = parts[2].match(/([A-Z]{2})\s*(\d{5})/); if (sz) { addr.addressRegion = sz[1]; addr.postalCode = sz[2]; } }
    ld.address = addr;
  }
  const hrs = (p.hours || []).map(parseHoursLine).filter(Boolean);
  if (hrs.length) ld.openingHoursSpecification = hrs;
  const ig = p.instagram ? (/^https?:\/\//.test(p.instagram) ? p.instagram : `https://instagram.com/${String(p.instagram).replace(/^@/, '')}`) : null;
  const same = [ig, p.yelp_url].filter(Boolean);
  if (same.length) ld.sameAs = same;
  // Service catalog with REAL prices (only where a numeric price exists — quote-only
  // services list the Service without a fabricated price). Strengthens rich results.
  const svc = (services() || []).filter(s => s && s.name);
  if (svc.length) {
    ld.hasOfferCatalog = {
      '@type': 'OfferCatalog', name: 'Car Wash & Detailing Services',
      itemListElement: svc.map(s => {
        const offer = { '@type': 'Offer', itemOffered: { '@type': 'Service', name: s.name } };
        if (s.blurb) offer.itemOffered.description = s.blurb;
        if (s.price != null && !isNaN(Number(s.price))) { offer.price = Number(s.price); offer.priceCurrency = 'USD'; }
        return offer;
      })
    };
  }
  return ld;
}
// Booking-objection FAQ — the SINGLE SOURCE for both the on-page accordion (/api/faqs)
// and the FAQPage JSON-LD, so visible text and structured data can never drift. Every
// answer is a verifiable operational commitment the business already makes on-site
// (data-honesty rule: no invented durations, prices, or ratings). Framed as the exact
// objections that stall a booking — folds the /dtd "D" dissent (friction reduction) into A.
function faqs() {
  return [
    { q: 'Do you actually hand wash, or is it an automatic tunnel?',
      a: 'Every car is hand washed using the two-bucket method with a foam cannon — no automatic tunnel and no brushes, so no swirl marks. It is safe for ceramic-coated, matte, and PPF-wrapped finishes.' },
    { q: 'Is the pricing really flat, or will I get upsold?',
      a: 'The price on the sign is the price you pay. There is no commission-driven wax-and-polish pressure — pick a service and that is the cost. The full price list is on the Services page.' },
    { q: 'What happens if it rains right after my wash?',
      a: 'If it rains within 48 hours of your wash, your next basic wash is on us — just come back.' },
    { q: 'Do you clean the back seats and door jambs?',
      a: 'Yes. Every seat is touched and door jambs are included on every wash, always. If we miss a spot, we re-do it free.' },
    { q: 'I am sensitive to fragrances — is there an option?',
      a: 'Yes, a fragrance-free option is available on request. Just note it when you book.' },
    { q: 'Do you offer full detailing and ceramic coating?',
      a: 'Yes — alongside the hand wash we offer full-service detailing and 9H ceramic coating. See the Services page for current options and flat pricing.' },
    { q: 'Which neighborhoods do you serve?',
      a: 'We serve the San Fernando Valley — including Sherman Oaks, Encino, Van Nuys, Studio City, Northridge, Reseda, Tarzana, Woodland Hills, and North Hollywood.' },
    { q: 'How do I book, and how soon will I hear back?',
      a: 'Send a request from the Book / Contact page with your vehicle and preferred time. We follow up to confirm — usually within a few hours during business hours. No pressure, no upsell.' }
  ];
}
// Persistent mobile action bar — injected into customer pages so the primary CTA (Book)
// and a click-to-call are always one thumb-tap away on phones (where most Valley car-wash
// traffic is). CSS-only visibility (<=640px), no JS. The tel: number comes from the REAL
// places().phone (single source, no fabrication); the Call button is omitted if no phone.
function mobileBar(pagePath) {
  const p = places() || {};
  let d = String(p.phone || '').replace(/\D/g, '');
  if (d.length === 11 && d[0] === '1') d = d.slice(1);
  const tel = d.length === 10 ? '+1' + d : (d ? '+' + d : '');
  const call = tel ? `<a class="mbar-btn call" href="tel:${tel}" aria-label="Call ${(p.name || 'Prestige Car Wash').replace(/"/g, '')}">📞 Call</a>` : '';
  // On the contact page the form IS the booking action, so scroll to it (#f) rather than
  // self-linking to the top of the page and losing any half-filled form state.
  const bookHref = pagePath === '/contact' ? '#f' : '/contact';
  return `<style>
.mbar{display:none}
@media(max-width:640px){
  body{padding-bottom:74px}
  .mbar{display:flex;position:fixed;left:0;right:0;bottom:0;z-index:80;gap:10px;padding:10px 12px calc(10px + env(safe-area-inset-bottom,0px));background:rgba(11,15,22,.93);backdrop-filter:blur(10px);border-top:1px solid var(--line)}
  .mbar-btn{flex:1;text-align:center;padding:13px 10px;border-radius:12px;font-weight:800;font-size:15px;text-decoration:none;display:flex;align-items:center;justify-content:center;gap:6px}
  .mbar .call{background:transparent;color:var(--ink);border:1px solid var(--line)}
  .mbar .book{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018}
}</style>
<div class="mbar" aria-label="Quick actions">${call}<a class="mbar-btn book" href="${bookHref}">Book a Wash →</a></div>`;
}
// Honest "find & review us" trust block — links to the REAL, verified off-site profiles
// from places() (Google Maps, Yelp, Instagram). Deliberately shows NO star number: ratings
// drift and would go stale on-page, so we route to the authoritative live profile instead
// (data-honesty rule — never display a number we can't keep verifiably current). Only a
// profile with a real non-empty value is rendered (no dead links). Server-rendered so the
// off-site authority links ship in the crawler HTML.
function trustBlockHtml() {
  const p = places() || {};
  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  const btn = (href, label, cls) => href
    ? `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener" style="text-decoration:none">${esc(label)}</a>` : '';
  const igHandle = p.instagram ? '@' + String(p.instagram).replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/\/+$/, '').replace(/^@/, '') : '';
  const igUrl = p.instagram ? (/^https?:\/\//.test(p.instagram) ? p.instagram : 'https://instagram.com/' + String(p.instagram).replace(/^@/, '')) : '';
  const links = [
    btn(p.maps_query, '📍 Find us on Google Maps', 'btn'),
    btn(p.yelp_url, '⭐ Read reviews on Yelp', 'btn ghost'),
    btn(igUrl, '📸 ' + igHandle, 'btn ghost')
  ].filter(Boolean);
  if (!links.length) return '';
  // Review-SOLICITATION CTA routes to Google's write-review dialog — and ONLY when a real
  // Google place_id is present (currently empty). We deliberately do NOT solicit on Yelp
  // (Yelp's "Don't Ask for Reviews" policy filters solicited reviews into "not recommended",
  // burying exactly what we'd be trying to grow), and we never point "leave a review" at a
  // bare search URL or a guessed id. So the CTA stays gated on a verified place_id and simply
  // appears once it's set — the "Read reviews on Yelp" link above stays (finding ≠ soliciting).
  const pid = /^[A-Za-z0-9_-]{20,}$/.test(String(p.place_id || '')) ? p.place_id : '';
  const reviewHref = pid ? `https://search.google.com/local/writereview?placeid=${encodeURIComponent(pid)}` : '';
  const cta = reviewHref ? `<div style="margin-top:16px">${btn(reviewHref, 'Leave us a review on Google →', 'btn')}</div>` : '';
  return `<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">${links.join('')}</div>${cta}`;
}
function buildFaqLd() {
  const list = faqs();
  if (!list.length) return null;
  return {
    '@context': 'https://schema.org', '@type': 'FAQPage',
    mainEntity: list.map(f => ({
      '@type': 'Question', name: f.q,
      acceptedAnswer: { '@type': 'Answer', text: f.a }
    }))
  };
}
// Server-rendered accordion HTML from the SAME faqs() source. Injected into the #faqList
// placeholder (see pageHtml) so the visible FAQ ships in the crawler-received HTML and
// MATCHES the FAQPage JSON-LD — Google requires structured data to reflect visible content
// (client-only rendering risks a manual action). Native <details> means zero JS needed.
function faqHtml() {
  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  return faqs().map((f, i) => `
    <details class="faq"${i === 0 ? ' open' : ''} style="border:1px solid var(--line);border-radius:12px;margin-bottom:10px;background:var(--panel)">
      <summary style="cursor:pointer;padding:15px 18px;font-weight:700;font-size:16px;display:flex;justify-content:space-between;gap:12px;align-items:center">
        <span>${esc(f.q)}</span><span class="faq-mk" aria-hidden="true" style="color:var(--mut);font-weight:800;font-size:20px">+</span>
      </summary>
      <div style="padding:0 18px 16px;color:var(--mut);font-size:15px;line-height:1.55">${esc(f.a)}</div>
    </details>`).join('');
}
// ── Evergreen car-care guides (DTD iteration 5) ────────────────────────────────────────
// Genuinely useful, HONEST top-of-funnel content: established detailing best-practice
// guidance phrased as advice (no fabricated statistics, no invented studies). Rendered
// server-side so it's fully crawlable, with BlogPosting + BreadcrumbList JSON-LD and
// internal links into services/booking. GUIDE_DATE is the real publish date.
const GUIDE_DATE = '2026-07-26';
function guides() {
  return [
    {
      slug: 'how-often-to-wash-a-ceramic-coated-car',
      title: 'How Often Should You Wash a Ceramic-Coated Car?',
      description: 'A ceramic coating changes the maintenance math. Here is a sensible wash rhythm that protects the coating without over-washing.',
      dek: 'A coating is slick and sacrificial — but it still needs the right care to keep beading and looking its best.',
      sections: [
        { h: 'The short answer', p: 'For a daily-driven coated car, every two weeks is a sensible default. Stretch it if the car lives in a garage; tighten it after rain, road grime, or bird droppings — droppings are acidic and can etch even a coated surface if left to bake in the sun.' },
        { h: 'Why gentle washing matters more, not less', p: 'A coating makes dirt easier to remove, but automatic tunnel brushes still drag grit across the surface and dull the gloss over time. A gentle two-bucket hand wash with a pH-neutral soap preserves the coating’s hydrophobic behavior far longer than a tunnel ever will.' },
        { h: 'What to avoid', p: 'Skip strong degreasers and high-alkaline wheel chemicals on coated paint — they strip the coating’s top layer. And never let water spot-dry in direct sun; the dissolved minerals bond to the coating and are a pain to remove.' },
        { h: 'When to top it up', p: 'If water stops beading and starts sheeting flat, the coating is asking for help. A spray-on SiO2 booster applied after a wash restores hydrophobicity between professional maintenance visits.' }
      ]
    },
    {
      slug: 'hand-wash-vs-automatic-tunnel',
      title: 'Hand Wash vs. Automatic Tunnel: What Is Actually Safe for Your Paint',
      description: 'Automatic tunnels are fast and cheap — but here is what they do to your clear coat, and why hand washing is gentler.',
      dek: 'The difference comes down to one thing: grit, and whether it gets dragged across your paint.',
      sections: [
        { h: 'Where tunnel washes go wrong', p: 'Tunnels recirculate water and run stiff brushes or cloth strips that have already touched every car before yours. The grit they carry is exactly what puts fine swirl marks and hairline scratches into a clear coat.' },
        { h: 'Why two-bucket hand washing is gentler', p: 'Two buckets — one of clean soapy water, one to rinse the mitt — let grit drop out of circulation instead of going back onto the paint. A foam cannon lifts and floats dirt off the surface before the mitt ever touches it.' },
        { h: 'The finishes that cannot take a tunnel', p: 'Ceramic coatings, matte and satin paint, and paint protection film (PPF) can all be dulled, hazed, or lifted at the edges by automated equipment. For any of these, hand washing is the safe default.' },
        { h: 'The honest trade-off', p: 'Tunnels win on speed and price per wash. If your car is a daily beater, that may be all you need. If you care about the finish — or you have invested in a coating, a wrap, or a fresh repaint — hand washing pays for itself in preserved gloss and resale value.' }
      ]
    },
    {
      slug: 'protecting-matte-and-ppf-finishes',
      title: 'Protecting Matte and PPF Finishes: A Care Guide',
      description: 'Matte paint and paint protection film need different care than glossy clear coat. Here is how to keep them looking right.',
      dek: 'The wrong product on a matte finish is a mistake you cannot polish out — so it pays to know the rules.',
      sections: [
        { h: 'Matte is a different animal', p: 'Matte and satin clear coats get their look from a micro-textured surface that scatters light. Anything that fills or polishes that texture — wax, sealant, polish, or a buffer — creates permanent shiny spots that can only be corrected with a refinish.' },
        { h: 'How to wash matte safely', p: 'Hand wash only, with a matte-specific (wax-free) pH-neutral shampoo, a soft mitt, and straight-line motions rather than circles. Dry with a clean microfiber or a filtered-air blower instead of dragging a towel across the texture.' },
        { h: 'Caring for PPF', p: 'Paint protection film is tough, but its edges can trap dirt and lift if scrubbed hard. Keep the edges clean, avoid blasting high-pressure water straight at the seams, and skip cutting compounds. Many modern films are self-healing — light swirls vanish in the sun or after a warm rinse.' },
        { h: 'What we do', p: 'Our hand wash is matte- and PPF-safe by default: no automatic brushes, never any wax on matte, and a fragrance-free option on request. Tell us what your car is wearing and we will treat it accordingly.' }
      ]
    }
  ];
}
function _e(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function guideLayout(title, description, bodyHtml) {
  return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${_e(title)}</title>
<meta name="description" content="${_e(description)}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/pcw.css">
</head><body>
<nav class="nav"><div class="wrap row">
  <div class="brand"><span class="mk">🚗</span><div>Prestige<br><small>Car Wash · SFV</small></div></div>
  <button class="hamb" aria-label="Menu" aria-expanded="false" onclick="var o=document.getElementById('lnk').classList.toggle('open');this.setAttribute('aria-expanded',o)">☰</button>
  <div class="links" id="lnk"><a href="/">Home</a><a href="/services">Services</a><a href="/guides" class="on">Guides</a><a class="btn" href="/contact">Book a Wash</a></div>
</div></nav>
${bodyHtml}
<footer class="foot"><div class="wrap">© 2026 Prestige Car Wash · San Fernando Valley, CA · <a href="/services">Services</a> · <a href="/guides">Guides</a> · <a href="/contact">Book</a></div></footer>
</body></html>`;
}
function guideCtaHtml() {
  return `<div class="card" style="max-width:760px;margin:26px auto 0"><div class="body" style="text-align:center">
    <div class="ttl" style="font-size:20px">Want it done right, by hand?</div>
    <div class="blurb">We hand wash every car — coating-, matte-, and PPF-safe — with no upsell pressure.</div>
    <div style="margin-top:12px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
      <a class="btn" href="/contact">Book a Wash</a><a class="btn ghost" href="/services">See Services &amp; Pricing</a>
    </div>
  </div></div>`;
}
function guideArticleHtml(g) {
  const secs = g.sections.map(s => `<h2>${_e(s.h)}</h2>\n<p>${_e(s.p)}</p>`).join('\n');
  return `<article class="section"><div class="wrap" style="max-width:760px">
    <div style="margin-bottom:10px"><a href="/guides" style="color:var(--mut);font-size:13px">← Car-care guides</a></div>
    <span class="pill">Car-care guide</span>
    <h1>${_e(g.title)}</h1>
    <p class="lead">${_e(g.dek)}</p>
    ${secs}
    ${guideCtaHtml()}
  </div></article>`;
}
function guideIndexHtml() {
  const cards = guides().map(g => `<a class="card" href="/guides/${_e(g.slug)}" style="text-decoration:none;color:inherit"><div class="body">
      <div class="cat">Car-care guide</div>
      <div class="ttl">${_e(g.title)}</div>
      <div class="blurb">${_e(g.description)}</div>
      <div style="margin-top:8px;color:var(--brand);font-weight:700;font-size:14px">Read →</div>
    </div></a>`).join('');
  return `<section class="section"><div class="wrap">
    <span class="pill">Guides</span>
    <h1>Car-care guides</h1>
    <p class="sub">Straight, no-nonsense advice on keeping your car’s finish looking its best — from the crew that hand washes them.</p>
    <div class="grid" style="--cols:3">${cards}</div>
  </div></section>`;
}
function buildArticleLd(g, canonicalUrl) {
  const name = (places() || {}).name || 'Prestige Car Wash';
  const ld = {
    '@context': 'https://schema.org', '@type': 'BlogPosting',
    headline: g.title, description: g.description,
    datePublished: GUIDE_DATE, dateModified: GUIDE_DATE,
    author: { '@type': 'Organization', name }, publisher: { '@type': 'Organization', name },
    articleSection: 'Car care'
  };
  if (canonicalUrl) ld.mainEntityOfPage = canonicalUrl;
  return ld;
}
function buildBreadcrumbLd(items) {
  return {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url }))
  };
}
// Open Graph / Twitter tags for the homepage — reuses the page's own <title> and meta
// description so social previews stay in sync with on-page SEO. og:image is a real asset.
function buildSocialTags(html, pagePath) {
  // Bail if the HTML already carries og: tags OR a canonical (e.g. added directly to the
  // file) so we never inject a duplicate that makes Facebook/Google flag the markup.
  if (/property=["']og:title["']/i.test(html) || /<link\b[^>]*\brel=["']canonical["']/i.test(html)) return '';
  const p = places() || {};
  const base = siteOrigin();
  const url = base ? base + (pagePath || '') : '';   // page-specific canonical + og:url
  const title = (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || p.name || 'Prestige Car Wash';
  // Attribute-order-independent description extraction, with a real fallback (never a
  // blank social card). Handles both name-first and content-first, single or double quotes.
  const descMatch = html.match(/<meta\b[^>]*\bname=["']description["'][^>]*\bcontent=["']([^"']*)["']/i)
                 || html.match(/<meta\b[^>]*\bcontent=["']([^"']*)["'][^>]*\bname=["']description["']/i);
  const desc = (descMatch || [])[1] || `${p.name || 'Prestige Car Wash'} — no-pressure flat pricing, two-bucket hand wash, detailing & ceramic coating in the San Fernando Valley.`;
  const img = base ? base + '/media/svc-full-service.png' : '';
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
  // Individual guide articles are og:type "article"; everything else is a "website".
  const ogType = /^\/guides\/.+/.test(pagePath || '') ? 'article' : 'website';
  const tags = [
    ['og:type', ogType], ['og:site_name', p.name || 'Prestige Car Wash'],
    ['og:title', title], ['og:description', desc], url && ['og:url', url], img && ['og:image', img],
    ['twitter:card', img ? 'summary_large_image' : 'summary'],
    ['twitter:title', title], ['twitter:description', desc], img && ['twitter:image', img]
  ].filter(Boolean);
  const metas = tags.map(([k, v]) => k.startsWith('twitter')
    ? `<meta name="${k}" content="${esc(v)}">`
    : `<meta property="${k}" content="${esc(v)}">`).join('\n');
  return (url ? `<link rel="canonical" href="${esc(url)}">\n` : '') + metas;
}
// Built fresh per request (sync file read + one string replace = microseconds) so the
// injected JSON-LD/OG NEVER drift from live places.json — a stale NAP is the exact thing
// Google penalizes, which would defeat the point of injecting it. (contrarian gate, 2026-07-26)
// GA4 gtag snippet — injected on the customer pages ONLY when GA_MEASUREMENT_ID is set
// (a valid G-XXXX id). No-op until then, so this is safe to ship before the id exists.
// Admin (internal) is intentionally NOT tracked as customer traffic.
function gaSnippet() {
  const id = process.env.GA_MEASUREMENT_ID;
  if (!id || !/^G-[A-Z0-9]{6,}$/.test(id)) return '';
  return `\n<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>` +
         `\n<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}');</script>`;
}
// Shared page decorator: takes a full HTML string and injects the FAQ/trust placeholders,
// the mobile bar, OG/canonical tags, JSON-LD, and the GA snippet. Used by BOTH file-backed
// pages (pageHtml) and server-generated pages (the /guides articles), so head/injection logic
// lives in exactly one place.
function decorate(html, pagePath, ld) {
  // Fill the FAQ accordion server-side so the visible content is in the crawler-received
  // HTML and matches the FAQPage JSON-LD. Only touches a page that carries the placeholder.
  if (html.includes('id="faqList"')) {
    html = html.replace(/(<div id="faqList"[^>]*>)\s*(<\/div>)/, (m, open, close) => open + faqHtml() + close);
  }
  // Fill the honest find-&-review trust block server-side (real off-site profile links).
  // If there are no real profiles to show, strip the WHOLE section so a heading never floats
  // over an empty body (graceful degradation, per contrarian).
  if (html.includes('id="findReview"')) {
    const tb = trustBlockHtml();
    if (tb) html = html.replace(/(<div id="findReview"[^>]*>)\s*(<\/div>)/, (m, open, close) => open + tb + close);
    else html = html.replace(/<section class="section" id="findreview">[\s\S]*?<\/section>/, '');
  }
  // Persistent mobile Book/Call bar on every customer page (guard against double-inject).
  if (!/class="mbar"/.test(html)) html = html.replace('</body>', mobileBar(pagePath) + '\n</body>');
  // Admin ↔ live-view toggle: on customer pages show a "🔧 Admin" pill ONLY if this browser
  // carries the pcw.admin flag (set when the admin visits /admin). Invisible to real customers;
  // gives the logged-in admin a one-tap way back to the admin from any live page.
  html = html.replace('</body>', `<script>(function(){try{if(localStorage.getItem('pcw.admin')!=='1')return;var a=document.createElement('a');a.href='/admin';a.textContent='🔧 Admin view';a.title='Switch to admin view';a.setAttribute('aria-label','Switch to admin view');a.style.cssText='position:fixed;left:12px;bottom:12px;z-index:95;background:#12100e;color:#c6a765;font:700 11px/1 system-ui,-apple-system,sans-serif;letter-spacing:.1em;text-transform:uppercase;padding:9px 13px;border-radius:6px;text-decoration:none;box-shadow:0 6px 18px rgba(0,0,0,.45);border:1px solid rgba(198,167,101,.45)';document.body.appendChild(a);}catch(e){}})();</script>\n</body>`);
  let inject = buildSocialTags(html, pagePath);
  // Perf: preconnect to the font-file origin (pages already preconnect googleapis; the actual
  // WOFF2 files come from gstatic, so this shaves a round-trip on first paint). Dedup-guarded.
  if (!/fonts\.gstatic\.com/.test(html)) inject += '\n<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
  // ld may be a single JSON-LD object OR an array of them (e.g. AutoWash + FAQPage on the
  // homepage). Emit one <script> per object; escape "<" so the JSON can't break out of the tag.
  for (const obj of [].concat(ld || []).filter(Boolean)) {
    inject += `\n<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
  }
  inject += gaSnippet();
  // First-party pageview beacon (analytics-lite) — sendBeacon so it never blocks paint; the
  // /api/track endpoint only increments an aggregate counter (no cookie/IP/PII). Customer
  // pages only (admin isn't served through decorate, so the owner's own visits aren't counted).
  inject += `\n<script>(function(){try{var b=JSON.stringify({event:'pageview'});if(navigator.sendBeacon){navigator.sendBeacon('/api/track',new Blob([b],{type:'application/json'}))}else{fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true})}}catch(e){}})();</script>`;
  return html.replace('</head>', inject + '\n</head>');
}
function pageHtml(file, pagePath, ld) {
  return decorate(fs.readFileSync(path.join(__dirname, 'public', file), 'utf8'), pagePath, ld);
}
const sendPage = (res, file, pagePath, ld) => {
  // If tag INJECTION throws, still serve the raw page (graceful degradation). If the file
  // itself is unreadable, sendFile's callback guarantees a clean 500 — never a hung response.
  try { res.type('html').send(pageHtml(file, pagePath, ld)); }
  catch { res.sendFile(path.join(__dirname, 'public', file), err => { if (err && !res.headersSent) res.status(500).end(); }); }
};
// FAQ list for the on-page accordion — same source the homepage FAQPage schema is built from.
app.get('/api/faqs', (req, res) => res.json(faqs()));
// Homepage carries BOTH the AutoWash business schema and the FAQPage schema (the FAQ is
// visibly rendered on the page below, which is what makes the FAQPage markup legitimate).
app.get('/', (req, res) => sendPage(res, 'index.html', '', [buildJsonLd(), buildFaqLd()]));
app.get('/services', (req, res) => sendPage(res, 'services.html', '/services', null));
app.get('/contact', (req, res) => sendPage(res, 'contact.html', '/contact', null));
// Car-care guides — server-generated (shares the decorate() pipeline: OG/canonical/GA/mobile bar).
app.get('/guides', (req, res) => {
  try {
    const html = guideLayout('Car-Care Guides — Prestige Car Wash',
      'Honest, practical car-care guides from a San Fernando Valley hand-wash & detail shop: ceramic-coating care, hand wash vs. tunnel, and matte & PPF protection.',
      guideIndexHtml());
    res.type('html').send(decorate(html, '/guides', null));
  } catch { res.status(500).end(); }
});
app.get('/guides/:slug', (req, res) => {
  const g = guides().find(x => x.slug === req.params.slug);
  const base = siteOrigin();
  if (!g) {
    const nf = guideLayout('Guide Not Found — Prestige Car Wash', 'That guide could not be found.',
      `<section class="section"><div class="wrap"><h1>Guide not found</h1><p class="sub">That guide may have moved. <a href="/guides">Browse all car-care guides →</a></p></div></section>`);
    return res.status(404).type('html').send(decorate(nf, '/guides', null));
  }
  const canonical = base ? base + '/guides/' + g.slug : '';
  const ld = [
    buildArticleLd(g, canonical),
    buildBreadcrumbLd([
      { name: 'Home', url: base || '/' },
      { name: 'Guides', url: (base || '') + '/guides' },
      { name: g.title, url: canonical || ('/guides/' + g.slug) }
    ])
  ];
  try {
    res.type('html').send(decorate(guideLayout(g.title + ' — Prestige Car Wash', g.description, guideArticleHtml(g)), '/guides/' + g.slug, ld));
  } catch { res.status(500).end(); }
});

// XML sitemap generated from the public URL — kept in one place, no static file to drift.
app.get('/sitemap.xml', (req, res) => {
  const base = siteOrigin();
  if (!base) return res.status(404).end();
  const urls = ['/', '/services', '/contact', '/guides', ...guides().map(g => '/guides/' + g.slug)];
  const xml = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
    urls.map(u => `  <url><loc>${base}${u}</loc></url>`).join('\n') + '\n</urlset>';
  res.type('application/xml').send(xml);
});

app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));

app.listen(PORT, () => {
  console.log(`[prestige-car-wash] http://localhost:${PORT}  (admin: /admin)`);
  if (process.env.NODE_ENV === 'production' && !process.env.ADMIN_PASS) {
    console.warn('⚠ SECURITY: running in production with the DEFAULT admin password. Set ADMIN_PASS in .env before exposing this host.');
  }
});