[object Object]

← back to Prestige Car Wash

FAQ: booking-objection accordion + FAQPage JSON-LD, single-source & server-rendered

cc20300f9a2f8663ffc77a7e712df4ab4dda3fba · 2026-07-26 20:27:06 -0700 · Steve Abrams

DTD panel picked A (3/2 over friction-reducers); dissent folded in — answers ARE
the booking objections. faqs() is one source for both the visible <details> accordion
(server-rendered into #faqList, native <details>, zero JS) and the FAQPage schema, so
visible content matches structured data. Contrarian FIX-FIRST resolved: was client-only
render (Google manual-action risk) -> now server-rendered in crawler HTML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit cc20300f9a2f8663ffc77a7e712df4ab4dda3fba
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 20:27:06 2026 -0700

    FAQ: booking-objection accordion + FAQPage JSON-LD, single-source & server-rendered
    
    DTD panel picked A (3/2 over friction-reducers); dissent folded in — answers ARE
    the booking objections. faqs() is one source for both the visible <details> accordion
    (server-rendered into #faqList, native <details>, zero JS) and the FAQPage schema, so
    visible content matches structured data. Contrarian FIX-FIRST resolved: was client-only
    render (Google manual-action risk) -> now server-rendered in crawler HTML.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 14 +++++++++++
 server.js         | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 80 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index 8532ed6..2fb6467 100644
--- a/public/index.html
+++ b/public/index.html
@@ -86,6 +86,20 @@ fetch('/api/wait').then(r=>r.json()).then(w=>{const el=document.getElementById('
   <div class="grid" id="locPhotos" style="--cols:6;margin-top:16px"></div>
 </div></section>
 
+<style>
+  #faq summary::-webkit-details-marker{display:none}
+  #faq summary{list-style:none}
+  #faq .faq-mk{display:inline-block;transition:transform .15s ease}
+  #faq details[open] .faq-mk{transform:rotate(45deg)}
+</style>
+<section class="section" id="faq"><div class="wrap">
+  <h2>Questions? Answered.</h2>
+  <p class="sub">The things drivers ask before booking — straight answers, no fine print.</p>
+  <!-- Accordion is rendered server-side into #faqList from faqs() so it ships in the HTML
+       and matches the FAQPage JSON-LD; native <details> + the CSS above need no JS. -->
+  <div id="faqList" style="max-width:820px"></div>
+  <div style="margin-top:20px"><a class="btn" href="/contact">Book a Wash</a></div>
+</div></section>
 <section class="section"><div class="wrap">
   <div class="card" style="text-align:center;max-width:720px;margin:0 auto">
     <div class="body">
diff --git a/server.js b/server.js
index fc44e82..578c94c 100644
--- a/server.js
+++ b/server.js
@@ -442,6 +442,56 @@ function buildJsonLd() {
   }
   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.' }
+  ];
+}
+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" 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('');
+}
 // 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) {
@@ -483,10 +533,18 @@ function gaSnippet() {
          `\n<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}');</script>`;
 }
 function pageHtml(file, pagePath, ld) {
-  const html = fs.readFileSync(path.join(__dirname, 'public', file), 'utf8');
+  let html = fs.readFileSync(path.join(__dirname, 'public', file), 'utf8');
+  // 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);
+  }
   let inject = buildSocialTags(html, pagePath);
-  // escape "<" so the JSON can't break out of the <script> tag
-  if (ld) inject += `\n<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, '\\u003c')}</script>`;
+  // 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();
   return html.replace('</head>', inject + '\n</head>');
 }
@@ -496,7 +554,11 @@ const sendPage = (res, file, pagePath, ld) => {
   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(); }); }
 };
-app.get('/', (req, res) => sendPage(res, 'index.html', '', buildJsonLd()));
+// 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));
 

← 39653bd chore: v0.3.1 (session close — LHF/SWOT/Slideshow)  ·  back to Prestige Car Wash  ·  Mobile: persistent Book + click-to-call action bar on custom 0760139 →