[object Object]

← back to Prestige Car Wash

Contrarian FIX-FIRST pass: real hero copy (no terminal cmd), location/hours block from Places, honest contact copy, contact rate-limit + field caps + optional webhook notify, prod default-password warning; gitignore runtime leads

36db139ec7c5befdb25e44b0e521227dfbbbffcc · 2026-07-04 13:46:04 -0700 · Steve

Files touched

Diff

commit 36db139ec7c5befdb25e44b0e521227dfbbbffcc
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 4 13:46:04 2026 -0700

    Contrarian FIX-FIRST pass: real hero copy (no terminal cmd), location/hours block from Places, honest contact copy, contact rate-limit + field caps + optional webhook notify, prod default-password warning; gitignore runtime leads
---
 .env.example        |  3 +++
 .gitignore          |  3 +++
 public/contact.html |  2 +-
 public/index.html   | 33 ++++++++++++++++++++++++++++++++-
 reports/leads.jsonl |  1 -
 server.js           | 40 ++++++++++++++++++++++++++++++++++------
 6 files changed, 73 insertions(+), 9 deletions(-)

diff --git a/.env.example b/.env.example
index ceebbe2..ebb5790 100644
--- a/.env.example
+++ b/.env.example
@@ -18,3 +18,6 @@ PCW_PLACE_ID=            # Prestige Car Wash Google Place ID (once claimed)
 
 # Instagram (public pull now; owner token later)
 IG_HANDLE=prestigecwash
+
+# Optional lead notification (Slack/Discord/Zapier webhook URL)
+NOTIFY_WEBHOOK=
diff --git a/.gitignore b/.gitignore
index 46722de..6069e71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ media/*.mp4
 media/*.png
 media/*.jpg
 media/.cache/
+
+# runtime lead capture (PII) — never commit
+reports/leads.jsonl
diff --git a/public/contact.html b/public/contact.html
index 7514473..777abc1 100644
--- a/public/contact.html
+++ b/public/contact.html
@@ -30,7 +30,7 @@
 
 <section class="section"><div class="wrap">
   <h2>Book your wash</h2>
-  <p class="sub">Tell us what you need — we'll text back to confirm a time. No upsell pressure, ever.</p>
+  <p class="sub">Tell us what you need — we'll follow up to confirm a time, usually within a few hours. No upsell pressure, ever.</p>
   <div class="ok" id="ok"></div>
   <form id="f">
     <div class="two">
diff --git a/public/index.html b/public/index.html
index 824342c..aa87c08 100644
--- a/public/index.html
+++ b/public/index.html
@@ -47,7 +47,7 @@
   <p class="sub">Wash & wax, up close.</p>
   <div class="card" style="max-width:820px;margin:0 auto">
     <div class="media" id="heroClip" style="aspect-ratio:16/9">
-      <div class="ph">🎬 Wash/wax clip renders here once media is generated<br>(Nano Banana still → SeeDance video · run <code>npm run gen:media</code>)</div>
+      <div class="ph"><div style="font-size:22px;font-weight:800;color:var(--ink)">✨ The two-bucket hand wash</div><div style="opacity:.75;margin-top:6px">Foam cannon · spot-free rinse · ceramic seal</div></div>
     </div>
   </div>
 </div></section>
@@ -60,6 +60,23 @@
   <div class="grid" id="feat" style="--cols:4"></div>
 </div></section>
 
+<section class="section"><div class="wrap">
+  <h2>Visit us</h2>
+  <p class="sub">Serving the whole San Fernando Valley.</p>
+  <div class="grid" style="--cols:2">
+    <div class="card"><div class="body">
+      <div class="ttl" id="locName">Prestige Car Wash</div>
+      <div class="blurb" id="locAddr">San Fernando Valley, CA</div>
+      <div id="locHours" style="margin-top:8px"></div>
+    </div></div>
+    <div class="card"><div class="body">
+      <div class="ttl">Neighborhoods we serve</div>
+      <div class="blurb">Sherman Oaks · Encino · Van Nuys · Studio City · Northridge · Reseda · Tarzana · Woodland Hills · North Hollywood</div>
+      <a class="btn" href="/contact" style="margin-top:8px">Book a wash</a>
+    </div></div>
+  </div>
+</div></section>
+
 <footer class="foot"><div class="wrap">
   © 2026 Prestige Car Wash · San Fernando Valley, CA · <a href="/services">Services</a> · <a href="/contact">Book</a>
   <div style="margin-top:6px;opacity:.7">Instagram @prestigecwash</div>
@@ -84,6 +101,20 @@ function clip(s){
   return `<video src="/${s.media_clip}" muted loop autoplay playsinline
              onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'ph',innerHTML:'🚿 ${s.name}'}))"></video>`;
 }
+// Hero: swap in the wash/wax clip ONLY if it exists; otherwise the branded copy stays.
+(function(){
+  var v=document.createElement('video');
+  v.src='/media/full-service.mp4'; v.muted=v.loop=v.autoplay=v.playsInline=true;
+  v.style.cssText='width:100%;height:100%;object-fit:cover';
+  v.addEventListener('canplay',function(){ v.play&&v.play(); document.getElementById('heroClip').replaceChildren(v); });
+})();
+// Location block from Google Places (mock until the API key is added).
+fetch('/api/places').then(r=>r.json()).then(p=>{
+  if(p.name) document.getElementById('locName').textContent=p.name;
+  document.getElementById('locAddr').textContent=(p.address||'San Fernando Valley, CA')+(p.phone?' · '+p.phone:'');
+  document.getElementById('locHours').innerHTML=(p.hours||[]).map(h=>
+    `<div style="padding:5px 0;border-top:1px solid var(--line);font-size:13px;color:var(--mut)">${h}</div>`).join('');
+});
 </script>
 </body>
 </html>
diff --git a/reports/leads.jsonl b/reports/leads.jsonl
deleted file mode 100644
index 2f83476..0000000
--- a/reports/leads.jsonl
+++ /dev/null
@@ -1 +0,0 @@
-{"name":"Test Driver","phone":"818-555-0100","email":"","vehicle":"","service":"Premium Hand Wash","preferred":"","message":"fragrance-free please","created_at":"2026-07-04T20:32:16.169Z","source":"web-form"}
diff --git a/server.js b/server.js
index 92c8e2b..8d4474c 100644
--- a/server.js
+++ b/server.js
@@ -167,13 +167,29 @@ app.post('/api/admin/place/draft-update', (req, res) => {
   });
 });
 
-// Contact / booking lead — appended locally (no outbound send; email fan-out is gated).
-app.post('/api/contact', (req, res) => {
+// 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 || {};
   if (!b.name || !(b.phone || b.email)) return res.status(400).json({ ok: false, error: 'name + phone/email required' });
   const lead = {
-    name: b.name, phone: b.phone || '', email: b.email || '', vehicle: b.vehicle || '',
-    service: b.service || '', preferred: b.preferred || '', message: b.message || '',
+    name: cap(b.name, 120), phone: cap(b.phone, 40), email: cap(b.email, 160), 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'
   };
   try {
@@ -181,7 +197,14 @@ app.post('/api/contact', (req, res) => {
     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' }); }
-  res.json({ ok: true, message: "Thanks! We'll reach out to confirm your booking." });
+  // 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." });
 });
 
 // Admin: view captured leads.
@@ -201,4 +224,9 @@ app.use('/media', express.static(path.join(__dirname, 'media')));
 app.get(/^\/(.+)\.html$/, (req, res) => res.redirect(301, '/' + req.params[0]));
 app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
 
-app.listen(PORT, () => console.log(`[prestige-car-wash] http://localhost:${PORT}  (admin: /admin)`));
+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.');
+  }
+});

← 4f61ddf Fix CSP: allow inline event-handler attrs (script-src-attr)  ·  back to Prestige Car Wash  ·  Fix service media paths to match generated svc-<id>.{png,mp4 9dc4f07 →