← 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
M public/index.htmlM server.js
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, '&').replace(/</g, '<').replace(/>/g, '>');
+ 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 →