← back to Prestige Car Wash
PCW scaffold: marketing site + growth admin (10 buckets), seeded from SFV market research; sort+density+left-filters, admin date+time chips, masked credentials, gated Google draft-writes
4a6ce212a0749504ce3467867cfe74da0ec56f77 · 2026-07-04 13:32:45 -0700 · Steve
Files touched
A .deploy.confA .env.exampleA .gitignoreA data/ads.jsonA data/best-times.jsonA data/competitors.jsonA data/directories.jsonA data/holidays.jsonA data/places.jsonA data/services.jsonA data/suggestions.jsonA ecosystem.config.cjsA package-lock.jsonA package.jsonA public/admin/index.htmlA public/assets/pcw.cssA public/contact.htmlA public/index.htmlA public/services.htmlA reports/leads.jsonlA server.js
Diff
commit 4a6ce212a0749504ce3467867cfe74da0ec56f77
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 4 13:32:45 2026 -0700
PCW scaffold: marketing site + growth admin (10 buckets), seeded from SFV market research; sort+density+left-filters, admin date+time chips, masked credentials, gated Google draft-writes
---
.deploy.conf | 10 +
.env.example | 20 ++
.gitignore | 25 ++
data/ads.json | 6 +
data/best-times.json | 34 ++
data/competitors.json | 14 +
data/directories.json | 10 +
data/holidays.json | 10 +
data/places.json | 15 +
data/services.json | 138 ++++++++
data/suggestions.json | 12 +
ecosystem.config.cjs | 12 +
package-lock.json | 857 ++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 22 ++
public/admin/index.html | 218 ++++++++++++
public/assets/pcw.css | 98 ++++++
public/contact.html | 70 ++++
public/index.html | 89 +++++
public/services.html | 118 +++++++
reports/leads.jsonl | 1 +
server.js | 196 +++++++++++
21 files changed, 1975 insertions(+)
diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..681b613
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,10 @@
+# Prestige Car Wash deploy config (used by ~/Projects/_shared/scripts/deploy.sh)
+# NOTE: deploy to Kamatera + DNS are Steve-gated — do not run autonomously.
+PROJECT_NAME=prestige-car-wash
+DEPLOY_HOST=45.61.58.125
+DEPLOY_PATH=/root/public-projects/prestige-car-wash
+HEALTH_URL=http://localhost:9808/api/health
+REQUIRED_ENVS=""
+INSTALL_CMD="npm ci --production"
+BUILD_CMD=""
+RSYNC_EXTRA_EXCLUDES="media/*.mp4 media/*.png reports"
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ceebbe2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,20 @@
+# Prestige Car Wash — environment template. Copy to .env and fill via /secrets.
+PORT=9808
+
+# Admin Basic Auth (defaults baked in server.js if unset)
+ADMIN_USER=admin
+ADMIN_PASS=DW2024!
+
+# Postgres (optional — app runs on data/*.json snapshots if unset)
+DATABASE_URL=
+
+# Media generation (gated spend — see plan)
+GEMINI_API_KEY= # Nano Banana (Gemini 2.5 Flash Image) stills
+REPLICATE_API_TOKEN= # SeeDance (bytedance/seedance-1-*) image->video
+
+# Google Places (read-only) — needs Maps Platform key + billing (gated setup)
+GOOGLE_PLACES_API_KEY=
+PCW_PLACE_ID= # Prestige Car Wash Google Place ID (once claimed)
+
+# Instagram (public pull now; owner token later)
+IG_HANDLE=prestigecwash
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..46722de
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.cache/
+coverage/
+.idea/
+.vscode/
+*.swp
+*.bak
+*.bak.*
+*.pre-*
+__pycache__/
+*.pyc
+# generated media binaries are large; keep out of git (regenerate via scripts/gen-media.js)
+media/*.mp4
+media/*.png
+media/*.jpg
+media/.cache/
diff --git a/data/ads.json b/data/ads.json
new file mode 100644
index 0000000..b0b0008
--- /dev/null
+++ b/data/ads.json
@@ -0,0 +1,6 @@
+[
+ { "id": "ad-gads-search", "platform": "Google Ads", "type": "Search", "status": "draft", "budget_month": 600, "targeting": "Radius 4mi around shop; SFV zips 91401/91403/91411/91423/91316/91325", "keywords": ["car wash near me", "hand car wash sherman oaks", "ceramic coating san fernando valley", "auto detailing van nuys"], "headline": "Prestige Car Wash — No-Pressure Flat Pricing", "body": "Hand wash, detail & ceramic in the Valley. Every seat touched, guaranteed.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "ad-gads-lsa", "platform": "Google", "type": "Local Services / Map", "status": "draft", "budget_month": 300, "targeting": "Google Business Profile promotions + Maps pin", "keywords": [], "headline": "Top-rated wash & detail — book today", "body": "Photos, live hours, reviews. Book a slot, skip the wait.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "ad-meta", "platform": "Meta (FB/IG)", "type": "Awareness + Lead", "status": "draft", "budget_month": 400, "targeting": "SFV 4mi radius, age 25–60, interests: cars, auto enthusiasts, local", "keywords": [], "headline": "Your car deserves the Prestige treatment", "body": "Before/after reels of the two-bucket hand wash. Book online — no upsell pressure.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "ad-nextdoor", "platform": "Nextdoor", "type": "Local sponsored", "status": "draft", "budget_month": 200, "targeting": "Sherman Oaks, Encino, Van Nuys, Studio City neighborhoods", "keywords": [], "headline": "Your neighborhood car wash", "body": "Neighbor-referral credit. Rain check within 48h. Fragrance-free option.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" }
+]
diff --git a/data/best-times.json b/data/best-times.json
new file mode 100644
index 0000000..b93621e
--- /dev/null
+++ b/data/best-times.json
@@ -0,0 +1,34 @@
+{
+ "traffic": {
+ "note": "Demand windows for an SFV car wash — weekends + warm dry days peak; mid-week mornings are slow (fill with fleet/mobile).",
+ "by_day": [
+ { "day": "Mon", "demand": 2, "note": "Slow — push fleet/B2B and midweek discount" },
+ { "day": "Tue", "demand": 2, "note": "Slow — mobile route day" },
+ { "day": "Wed", "demand": 3, "note": "Building — 'hump-day' promo" },
+ { "day": "Thu", "demand": 4, "note": "Pre-weekend prep begins" },
+ { "day": "Fri", "demand": 5, "note": "Peak begins — before weekend plans" },
+ { "day": "Sat", "demand": 5, "note": "Highest volume — staff up, manage wait times" },
+ { "day": "Sun", "demand": 4, "note": "Strong AM, tapers afternoon" }
+ ]
+ },
+ "post_times": {
+ "note": "Best times to POST for local SFV service audience (Pacific).",
+ "Instagram": ["Wed 11:00", "Fri 12:00", "Sat 9:00", "Sun 10:00"],
+ "TikTok": ["Tue 18:00", "Thu 19:00", "Sat 11:00"],
+ "Nextdoor": ["Thu 17:00", "Sat 8:00"],
+ "Facebook": ["Fri 13:00", "Sun 11:00"],
+ "Google Post": ["Thu 9:00", "Fri 9:00"]
+ },
+ "market_times": {
+ "note": "Best times to spend on ADS / promos.",
+ "triggers": [
+ "48h AFTER rain (everyone's car is filthy — highest intent)",
+ "Heat waves 90°F+ (paint-protection / ceramic messaging)",
+ "Thu–Fri before any holiday weekend",
+ "Pollen / Santa Ana dust events",
+ "First of month (membership renewals + new sign-ups)"
+ ]
+ },
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+}
diff --git a/data/competitors.json b/data/competitors.json
new file mode 100644
index 0000000..0c7393a
--- /dev/null
+++ b/data/competitors.json
@@ -0,0 +1,14 @@
+[
+ { "id": "cmp-fashion-square", "name": "Fashion Square Car Wash", "city": "Sherman Oaks", "address": "14006 Riverside Dr / Ventura Blvd", "format": "Full Service", "rating": 4.4, "reviews": 1277, "price_signal": "$29.99–$79.99; ceramic $1,000; complete detail $500", "notes": "Voted #1 by LA Times. State-of-the-art tunnel + membership. High review volume = the incumbent to beat.", "ad_platforms": [], "website": "https://fashioncarwash.com", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-sherman-oaks-cw", "name": "Sherman Oaks Car Wash", "city": "Sherman Oaks", "address": "15150 Ventura Blvd", "format": "Full Service + Detail", "rating": 4.3, "reviews": 99, "price_signal": "Average; rewards program", "notes": "Hand wash + detail center. PAIN POINT: reviewers call staff pushy on wax/polish upsells.", "ad_platforms": [], "website": "https://shermanoakscarwash.com", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-robs", "name": "Rob's Car Wash", "city": "Sherman Oaks", "address": "5300 Van Nuys Blvd", "format": "Express Tunnel", "rating": 3.4, "reviews": 20, "price_signal": "$17.99 base wash", "notes": "PAIN POINTS: rude cashiers, sloppy interior (back seat untouched), sticky over-applied Armor-All, 'huge rip-off' pricing. Clear differentiation target.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-ee-detail", "name": "ee detail wash", "city": "Sherman Oaks", "address": "15301 Ventura Blvd", "format": "Detail Shop (mobile)", "rating": 4.7, "reviews": 25, "price_signal": "Detailing; mobile", "notes": "High-rated mobile detailer. PAIN POINT flagged: one review reports rushed wax + driveway staining + ghosting.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-luv-vannuys", "name": "LUV Car Wash - Sherman Way", "city": "Van Nuys", "address": "Sherman Way", "format": "Express Tunnel", "rating": 4.5, "reviews": 1748, "price_signal": "Express membership model", "notes": "Chain, highest review volume in Van Nuys. Volume/subscription play.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-bliss-vannuys", "name": "BLISS Car Wash - Van Nuys", "city": "Van Nuys", "address": "Van Nuys", "format": "Express Tunnel", "rating": 4.8, "reviews": 1482, "price_signal": "Express membership", "notes": "Highest-rated tunnel chain in the area (4.8). Strong brand + app.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-bellagio", "name": "Bellagio Express Car Wash", "city": "Van Nuys", "address": "Van Nuys", "format": "Express Tunnel", "rating": 4.3, "reviews": 1018, "price_signal": "Express", "notes": "Established express tunnel, high review count.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-suds-up", "name": "Sud's Up Express Car Wash", "city": "Van Nuys", "address": "Van Nuys", "format": "Express Tunnel", "rating": 4.3, "reviews": 328, "price_signal": "Express", "notes": "Express tunnel, mid review volume.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-icarspa", "name": "iCarSpa Mobile Detailing", "city": "Valley-wide", "address": "Mobile (818.554.5129)", "format": "Mobile Detail", "rating": 5.0, "reviews": 0, "price_signal": "Mobile packages; same-day", "notes": "Valley-wide mobile: West Hills→Sylmar. Luxury/exotic focus. Convenience play.", "ad_platforms": [], "website": "https://icarspa.com", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-strictly-detailed", "name": "Strictly Detailed", "city": "Valley-wide", "address": "Mobile", "format": "Mobile Detail / Ceramic", "rating": 5.0, "reviews": 0, "price_signal": "Ceramic + mobile", "notes": "SEO-strong 'mobile detailing SFV' + ceramic. Digital-first competitor.", "ad_platforms": [], "website": "https://strictlydetailed.com", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-washos", "name": "Washos (marketplace)", "city": "Valley-wide", "address": "Mobile marketplace", "format": "Mobile marketplace", "rating": 4.5, "reviews": 0, "price_signal": "Gold Wash → Diamond Ceramic tiers", "notes": "Neighborhood-SEO marketplace, thousands of reviews. Aggregator threat, not a single shop.", "ad_platforms": [], "website": "https://washos.com", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" },
+ { "id": "cmp-xclusive", "name": "Xclusive Auto Spa & Valet", "city": "Sherman Oaks", "address": "14006 Riverside Dr", "format": "Full Service", "rating": 3.8, "reviews": 18, "price_signal": "Full service + valet", "notes": "Lower-rated full service. Differentiation target.", "ad_platforms": [], "website": "", "created_at": "2026-07-04T13:25:00-07:00", "source": "exa-research" }
+]
diff --git a/data/directories.json b/data/directories.json
new file mode 100644
index 0000000..11db5d0
--- /dev/null
+++ b/data/directories.json
@@ -0,0 +1,10 @@
+[
+ { "id": "dir-google", "name": "Google Business Profile", "type": "Listing", "priority": "Critical", "status": "to-do", "url": "https://business.google.com", "notes": "#1 local-search driver. Claim, verify, add photos/hours/services, collect reviews. Feeds the Google Business admin tab.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-nextdoor", "name": "Nextdoor Business", "type": "Community", "priority": "Critical", "status": "to-do", "url": "https://business.nextdoor.com", "notes": "Hyper-local SFV neighborhoods. Claim free business page; run neighborhood sponsored posts. Posting stays MANUAL/draft (TOS) — app tracks & drafts, never auto-posts.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-yelp", "name": "Yelp", "type": "Listing", "priority": "High", "status": "to-do", "url": "https://biz.yelp.com", "notes": "Major for car washes/detailing. Claim, add service menu + photos, respond to reviews.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-apple", "name": "Apple Business Connect", "type": "Listing", "priority": "Medium", "status": "to-do", "url": "https://businessconnect.apple.com", "notes": "Apple Maps listing — free, quick, drives iPhone navigation traffic.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-bing", "name": "Bing Places", "type": "Listing", "priority": "Low", "status": "to-do", "url": "https://www.bingplaces.com", "notes": "Free; can import from Google. Low effort, some incremental reach.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-washos-dir", "name": "Local detailing directories", "type": "Niche", "priority": "Medium", "status": "to-do", "url": "https://finddetailersnow.com/california/san-fernando", "notes": "FindDetailersNow, Clean Car Wash LA, LA Mobile Detailing Directory — niche directories where rivals already list.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "dir-chamber", "name": "SFV Chambers of Commerce", "type": "Community", "priority": "Medium", "status": "to-do", "url": "", "notes": "Sherman Oaks / Encino / Van Nuys chambers — local B2B, fleet leads, backlinks.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "grp-fb-sfv", "name": "SFV neighborhood Facebook groups", "type": "Group", "priority": "Medium", "status": "to-do", "url": "", "notes": "Sherman Oaks / Encino / Studio City buy-nothing & neighborhood groups. Value-first posts only, no spam.", "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" }
+]
diff --git a/data/holidays.json b/data/holidays.json
new file mode 100644
index 0000000..2da23c5
--- /dev/null
+++ b/data/holidays.json
@@ -0,0 +1,10 @@
+[
+ { "id": "hol-mlk", "name": "MLK Weekend", "date": "2026-01-19", "campaign": "3-day weekend car-care special — $5 off any wash", "channels": ["Instagram", "Nextdoor", "Email"], "lead_days": 7, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-valentines", "name": "Valentine's Day", "date": "2026-02-14", "campaign": "'Clean ride for date night' — free air freshener + interior wipe with any wash", "channels": ["Instagram", "TikTok"], "lead_days": 5, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-memorial", "name": "Memorial Day", "date": "2026-05-25", "campaign": "Road-trip ready detail bundle; military discount", "channels": ["Instagram", "Nextdoor", "Google"], "lead_days": 10, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-july4", "name": "4th of July", "date": "2026-07-04", "campaign": "Red-white-blue detail + ceramic promo; summer heat paint-protection push", "channels": ["Instagram", "TikTok", "Nextdoor"], "lead_days": 10, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-laborday", "name": "Labor Day", "date": "2026-09-07", "campaign": "End-of-summer full detail special; membership sign-up bonus", "channels": ["Instagram", "Email"], "lead_days": 10, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-thanksgiving", "name": "Thanksgiving", "date": "2026-11-26", "campaign": "'Clean for the family visit' interior detail; gift-card launch", "channels": ["Instagram", "Nextdoor"], "lead_days": 7, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-blackfriday", "name": "Black Friday / Cyber Monday", "date": "2026-11-27", "campaign": "Prepaid wash packs + ceramic coating door-buster; gift cards", "channels": ["Instagram", "Email", "Google"], "lead_days": 14, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" },
+ { "id": "hol-holidays", "name": "December Holidays", "date": "2026-12-20", "campaign": "Gift-a-detail; year-end fleet detailing for local businesses", "channels": ["Instagram", "Nextdoor", "Email"], "lead_days": 14, "created_at": "2026-07-04T13:25:00-07:00", "source": "seed" }
+]
diff --git a/data/places.json b/data/places.json
new file mode 100644
index 0000000..5fa3db3
--- /dev/null
+++ b/data/places.json
@@ -0,0 +1,15 @@
+{
+ "_mock": true,
+ "_note": "Mock fallback. Real data populates once GOOGLE_PLACES_API_KEY + PCW_PLACE_ID are set and scripts/places-read.js runs. Adding the key + billing is a Steve-gated step.",
+ "name": "Prestige Car Wash",
+ "address": "San Fernando Valley, CA",
+ "phone": "",
+ "rating": null,
+ "reviews_count": null,
+ "hours": ["Mon–Sat 8:00 AM – 6:00 PM", "Sun 9:00 AM – 5:00 PM"],
+ "website": "",
+ "place_id": "",
+ "photos": [],
+ "reviews": [],
+ "updated_at": "2026-07-04T13:25:00-07:00"
+}
diff --git a/data/services.json b/data/services.json
new file mode 100644
index 0000000..d4fc98b
--- /dev/null
+++ b/data/services.json
@@ -0,0 +1,138 @@
+[
+ {
+ "id": "svc-express-wash",
+ "name": "Express Exterior Wash",
+ "category": "Wash",
+ "price": 19,
+ "price_display": "$19",
+ "duration_min": 15,
+ "blurb": "Foam bath, spot-free rinse, and hand dry. In and out — no upsell pressure.",
+ "highlights": ["Hand dry", "Spot-free rinse", "Tire shine add-on"],
+ "media_still": "media/express-wash.png",
+ "media_clip": "media/express-wash.mp4",
+ "ig_photo": "",
+ "featured": true,
+ "sort_order": 1,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-full-service",
+ "name": "Full-Service Wash",
+ "category": "Wash",
+ "price": 39,
+ "price_display": "$39",
+ "duration_min": 30,
+ "blurb": "Exterior hand wash, interior vacuum, dash wipe, windows, and tire dressing — every seat touched, guaranteed.",
+ "highlights": ["Interior vacuum", "We-touch-every-seat guarantee", "Fragrance-free option"],
+ "media_still": "media/full-service.png",
+ "media_clip": "media/full-service.mp4",
+ "ig_photo": "",
+ "featured": true,
+ "sort_order": 2,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-hand-wash",
+ "name": "Premium Hand Wash",
+ "category": "Wash",
+ "price": 79,
+ "price_display": "$79",
+ "duration_min": 45,
+ "blurb": "Two-bucket, foam-cannon hand wash — no tunnel, no swirl marks. Safe for ceramic, matte, and PPF.",
+ "highlights": ["Two-bucket method", "No swirl marks", "Ceramic/matte safe"],
+ "media_still": "media/hand-wash.png",
+ "media_clip": "media/hand-wash.mp4",
+ "ig_photo": "",
+ "featured": true,
+ "sort_order": 3,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-wax-seal",
+ "name": "Wax & Ceramic Seal",
+ "category": "Protection",
+ "price": 49,
+ "price_display": "$49",
+ "duration_min": 30,
+ "blurb": "Triple-foam polish plus a hydrophobic ceramic seal that shields clear coat from UV and repels rain.",
+ "highlights": ["Hydrophobic finish", "UV protection", "Rain-repellent"],
+ "media_still": "media/wax-seal.png",
+ "media_clip": "media/wax-seal.mp4",
+ "ig_photo": "",
+ "featured": true,
+ "sort_order": 4,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-interior-detail",
+ "name": "Interior Express Detail",
+ "category": "Detail",
+ "price": 120,
+ "price_display": "$120",
+ "duration_min": 90,
+ "blurb": "Deep interior: seats, carpets, dash, vents, and door jambs. Pet-hair extraction and shampoo add-ons.",
+ "highlights": ["Shampoo & extraction", "Pet-hair removal", "Leather conditioning"],
+ "media_still": "media/interior-detail.png",
+ "media_clip": "media/interior-detail.mp4",
+ "ig_photo": "",
+ "featured": false,
+ "sort_order": 5,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-complete-detail",
+ "name": "Complete Detail",
+ "category": "Detail",
+ "price": 499,
+ "price_display": "$499",
+ "duration_min": 240,
+ "blurb": "Full inside-and-out restoration: wash, clay bar, polish, wax, interior shampoo, and engine-bay cleaning.",
+ "highlights": ["Clay bar + polish", "Engine bay", "Showroom finish"],
+ "media_still": "media/complete-detail.png",
+ "media_clip": "media/complete-detail.mp4",
+ "ig_photo": "",
+ "featured": false,
+ "sort_order": 6,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-ceramic-coating",
+ "name": "Ceramic Coating (9H)",
+ "category": "Protection",
+ "price": 999,
+ "price_display": "from $999",
+ "duration_min": 480,
+ "blurb": "Iron decon, clay bar, one-step polish, and a 9H nano-ceramic coating — 1–3 years of hydrophobic protection.",
+ "highlights": ["9H hardness", "1–3 year durability", "Paint correction included"],
+ "media_still": "media/ceramic-coating.png",
+ "media_clip": "media/ceramic-coating.mp4",
+ "ig_photo": "",
+ "featured": false,
+ "sort_order": 7,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ },
+ {
+ "id": "svc-headlight",
+ "name": "Headlight Restoration",
+ "category": "Restore",
+ "price": 89,
+ "price_display": "$89",
+ "duration_min": 45,
+ "blurb": "Sand, polish, and seal foggy, yellowed headlights back to clear — better night visibility and curb appeal.",
+ "highlights": ["Clears yellowing", "Sealed finish", "Improves visibility"],
+ "media_still": "media/headlight.png",
+ "media_clip": "media/headlight.mp4",
+ "ig_photo": "",
+ "featured": false,
+ "sort_order": 8,
+ "created_at": "2026-07-04T13:25:00-07:00",
+ "source": "seed"
+ }
+]
diff --git a/data/suggestions.json b/data/suggestions.json
new file mode 100644
index 0000000..46307e5
--- /dev/null
+++ b/data/suggestions.json
@@ -0,0 +1,12 @@
+[
+ { "id": "sug-flat-pricing", "title": "No-pressure flat pricing", "category": "Positioning", "impact": 5, "effort": 1, "rationale": "Top recurring complaint across Sherman Oaks/Van Nuys washes is pushy upselling and surprise pricing. Publish flat, all-in prices with no commission-driven upsell — advertise 'the price on the sign is the price you pay.'", "created_at": "2026-07-04T13:25:00-07:00", "source": "pain-point" },
+ { "id": "sug-seat-guarantee", "title": "We-touch-every-seat guarantee", "category": "Quality", "impact": 5, "effort": 2, "rationale": "Reviews repeatedly cite back seats left untouched and rushed interiors. A written guarantee (re-do free if any seat/area is missed) attacks the #1 quality gripe head-on.", "created_at": "2026-07-04T13:25:00-07:00", "source": "pain-point" },
+ { "id": "sug-fragrance-free", "title": "Fragrance-free / low-scent option", "category": "Quality", "impact": 3, "effort": 1, "rationale": "A reviewer specifically praised a fragrance-free wash and dislikes artificial 'fresheners.' Offer a no-scent checkbox — cheap differentiator for a real segment.", "created_at": "2026-07-04T13:25:00-07:00", "source": "pain-point" },
+ { "id": "sug-membership", "title": "Unlimited monthly membership", "category": "Revenue", "impact": 5, "effort": 3, "rationale": "LUV/BLISS/Bellagio all run subscription tunnels with 1,000+ reviews. A $25–35/mo unlimited-exterior plan drives recurring revenue and retention; car washes are the textbook subscription business.", "created_at": "2026-07-04T13:25:00-07:00", "source": "best-practice" },
+ { "id": "sug-mobile-route", "title": "Add a mobile come-to-you route", "category": "Revenue", "impact": 4, "effort": 4, "rationale": "iCarSpa/Washos/Strictly Detailed compete on convenience. A mobile detailing route (office parks, apartment complexes along Ventura) captures the 'no time to drive' segment — but be reliable (rivals get dinged for ghosting/driveway stains).", "created_at": "2026-07-04T13:25:00-07:00", "source": "market-gap" },
+ { "id": "sug-ceramic-upsell", "title": "Lead with ceramic & paint correction", "category": "Revenue", "impact": 4, "effort": 3, "rationale": "Detail Shops rate highest in the Valley (4.86★ vs 4.12★ full-service). High-margin ceramic ($999+) and paint correction are where quality reputation converts — anchor the brand on craftsmanship, not tunnel volume.", "created_at": "2026-07-04T13:25:00-07:00", "source": "market-data" },
+ { "id": "sug-fleet", "title": "Fleet & dealer accounts", "category": "Revenue", "impact": 4, "effort": 3, "rationale": "SFV has dense small-business/dealer/rental fleets. Weekday B2B fleet contracts fill slow mid-week hours at predictable volume.", "created_at": "2026-07-04T13:25:00-07:00", "source": "best-practice" },
+ { "id": "sug-referral", "title": "Referral + loyalty punch card", "category": "Growth", "impact": 3, "effort": 2, "rationale": "Digital loyalty (every 6th wash free) + refer-a-neighbor credit. Cheap CAC in a repeat-purchase business; pairs with the Nextdoor route.", "created_at": "2026-07-04T13:25:00-07:00", "source": "best-practice" },
+ { "id": "sug-rain-guarantee", "title": "48-hour rain check", "category": "Positioning", "impact": 3, "effort": 1, "rationale": "Re-wash free if it rains within 48 hours. Removes the #1 objection to washing in an unpredictable-weather week; low real cost in dry SoCal.", "created_at": "2026-07-04T13:25:00-07:00", "source": "best-practice" },
+ { "id": "sug-online-booking", "title": "Online booking + wait-time display", "category": "Digital", "impact": 4, "effort": 3, "rationale": "Complaints cite long/unpredictable waits (Studio City 'takes an hour'). Show live wait time + let customers book a slot — turns a top annoyance into a selling point.", "created_at": "2026-07-04T13:25:00-07:00", "source": "pain-point" }
+]
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..358927d
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,12 @@
+module.exports = {
+ apps: [
+ {
+ name: 'prestige-car-wash',
+ script: 'server.js',
+ cwd: __dirname,
+ env: { NODE_ENV: 'production', PORT: 9808 },
+ max_memory_restart: '300M',
+ watch: false
+ }
+ ]
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..540050d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,857 @@
+{
+ "name": "prestige-car-wash",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "prestige-car-wash",
+ "version": "0.1.0",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "helmet": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
+ "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8d1f182
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "prestige-car-wash",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Prestige Car Wash — SFV marketing site + growth command-center admin",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js",
+ "db:init": "psql \"$DATABASE_URL\" -f scripts/db-init.sql",
+ "gen:media": "node scripts/gen-media.js",
+ "pull:ig": "node scripts/ig-pull.js",
+ "scan:competitors": "node scripts/competitors-scan.js",
+ "read:places": "node scripts/places-read.js"
+ },
+ "dependencies": {
+ "express": "^4.21.2",
+ "helmet": "^8.0.0",
+ "dotenv": "^16.4.7"
+ },
+ "engines": { "node": ">=18" }
+}
diff --git a/public/admin/index.html b/public/admin/index.html
new file mode 100644
index 0000000..706cf89
--- /dev/null
+++ b/public/admin/index.html
@@ -0,0 +1,218 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Prestige Car Wash — Growth Command Center</title>
+<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">
+<style>
+ .tabs{display:flex;gap:6px;overflow-x:auto;padding:10px 0;border-bottom:1px solid var(--line);position:sticky;top:60px;background:rgba(11,15,22,.9);backdrop-filter:blur(8px);z-index:30}
+ .tabs button{white-space:nowrap;background:var(--panel);border:1px solid var(--line);color:var(--mut);border-radius:999px;padding:8px 14px;font-size:13px;font-weight:700;cursor:pointer}
+ .tabs button.on{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;border-color:transparent}
+ .when{margin-top:8px;color:var(--mut);font-size:10.5px;font-variant-numeric:tabular-nums}
+ .stat{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px}
+ .stat .s{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;min-width:120px}
+ .stat .s b{font-size:24px;color:var(--brand);display:block}
+ .stat .s span{font-size:12px;color:var(--mut)}
+ .rate{color:var(--gold);font-weight:800}
+ .badge{font-size:10px;border-radius:999px;padding:2px 8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
+ .b-good{background:rgba(58,210,159,.15);color:var(--good)} .b-warn{background:rgba(255,180,84,.15);color:var(--warn)} .b-bad{background:rgba(255,107,107,.15);color:var(--bad)} .b-mut{background:var(--panel2);color:var(--mut)}
+ .heat{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;max-width:560px}
+ .heat .d{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px;text-align:center;font-size:12px}
+ .heat .d b{display:block;font-size:11px;color:var(--mut)}
+ code{background:var(--panel2);padding:1px 6px;border-radius:6px;font-size:12px}
+ .kv{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;font-size:14px}
+</style>
+</head>
+<body>
+<nav class="nav"><div class="wrap row">
+ <div class="brand"><span class="mk">🚗</span><div>Prestige <small>Admin · Growth Center</small></div></div>
+ <div class="links" style="margin-left:auto"><a href="/" target="_blank">View site ↗</a></div>
+</div></nav>
+
+<div class="wrap">
+ <div class="tabs" id="tabs"></div>
+
+ <div class="controls" id="ctl" style="margin-top:16px">
+ <input type="search" id="q" placeholder="Search…">
+ <label>Density <input type="range" id="density" min="2" max="6" step="1"></label>
+ <span id="count" style="color:var(--mut);font-size:13px;font-weight:600"></span>
+ </div>
+
+ <div id="view" style="padding:8px 0 60px"></div>
+</div>
+
+<script>
+const $=s=>document.querySelector(s);
+function fmtDate(iso){ if(!iso) return '—'; const d=new Date(iso); if(isNaN(d)) return '—';
+ return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+function when(iso){ return `<div class="when" title="created ${iso||''}">🕓 ${fmtDate(iso)}</div>`; }
+const rating=r=>r?`<span class="rate">★ ${r}</span>`:'';
+const enc=s=>String(s==null?'':s).replace(/</g,'<');
+
+let DENS=localStorage.getItem('pcwadmin.density')||'3';
+$('#density').value=DENS; setDensity(DENS);
+function setDensity(v){v=Number(v);document.documentElement.style.setProperty('--cols',v);
+ document.documentElement.style.setProperty('--fs',Math.max(.85,Math.min(1.2,1+(3-v)*0.05)).toFixed(3));}
+$('#density').oninput=e=>{setDensity(e.target.value);localStorage.setItem('pcwadmin.density',e.target.value)};
+let t;$('#q').oninput=()=>{clearTimeout(t);t=setTimeout(render,200)};
+
+// ---- tab definitions -------------------------------------------------------
+const TABS=[
+ {id:'overview',label:'📊 Overview'},
+ {id:'competitors',label:'🥊 Competitors',api:'/api/admin/competitors',search:['name','city','notes','format'],
+ card:c=>cardWrap(`<div class="row"><div class="cat">${enc(c.city)} · ${enc(c.format)}</div>${rating(c.rating)}</div>
+ <div class="ttl">${enc(c.name)}</div>
+ <div class="blurb">${enc(c.notes)}</div>
+ <div class="kv"><span>Reviews</span><b>${c.reviews||'—'}</b></div>
+ <div class="kv"><span>Pricing</span><span>${enc(c.price_signal)}</span></div>
+ <div class="tags">${(c.ad_platforms||[]).map(p=>`<span class="tag">📣 ${enc(p)}</span>`).join('')||'<span class="tag b-mut">no ad signals scanned</span>'}</div>
+ ${c.website?`<a class="tag" href="${enc(c.website)}" target="_blank" style="margin-top:6px;display:inline-block">site ↗</a>`:''}
+ ${when(c.created_at)}`)},
+ {id:'best-post',label:'⏰ Best Times to Post'},
+ {id:'best-market',label:'💸 Best Times to Market'},
+ {id:'holidays',label:'🎉 Holiday-Driven',api:'/api/admin/holidays',search:['name','campaign'],
+ card:h=>cardWrap(`<div class="cat">${enc(h.date)} · lead ${h.lead_days}d</div>
+ <div class="ttl">${enc(h.name)}</div><div class="blurb">${enc(h.campaign)}</div>
+ <div class="tags">${(h.channels||[]).map(c=>`<span class="tag">${enc(c)}</span>`).join('')}</div>${when(h.created_at)}`)},
+ {id:'services',label:'🚿 Services',api:'/api/admin/services',search:['name','category','blurb'],
+ card:s=>cardWrap(`<div class="row"><div class="cat">${enc(s.category)} · ${s.duration_min}min</div>${s.featured?'<span class="badge b-good">featured</span>':''}</div>
+ <div class="ttl">${enc(s.name)}</div><div class="blurb">${enc(s.blurb)}</div>
+ <div class="row"><span class="price">${enc(s.price_display||('$'+s.price))}</span>
+ <span class="badge b-mut">${s.media_clip?'media set':'no media'}</span></div>${when(s.created_at)}`)},
+ {id:'suggestions',label:'💡 Grow the Business',api:'/api/admin/suggestions',search:['title','rationale','category'],
+ card:s=>cardWrap(`<div class="row"><div class="cat">${enc(s.category)}</div>
+ <span class="badge ${s.impact>=5?'b-good':s.impact>=3?'b-warn':'b-mut'}">impact ${s.impact} · effort ${s.effort}</span></div>
+ <div class="ttl">${enc(s.title)}</div><div class="blurb">${enc(s.rationale)}</div>${when(s.created_at)}`)},
+ {id:'ads',label:'📣 Online Ads',api:'/api/admin/ads',search:['platform','type','headline'],
+ card:a=>cardWrap(`<div class="row"><div class="cat">${enc(a.platform)} · ${enc(a.type)}</div><span class="badge b-warn">${enc(a.status)}</span></div>
+ <div class="ttl">${enc(a.headline)}</div><div class="blurb">${enc(a.body)}</div>
+ <div class="kv"><span>Budget/mo</span><b>$${a.budget_month}</b></div>
+ <div class="kv"><span>Targeting</span><span>${enc(a.targeting)}</span></div>
+ <div class="tags">${(a.keywords||[]).map(k=>`<span class="tag">${enc(k)}</span>`).join('')}</div>${when(a.created_at)}`)},
+ {id:'directories',label:'📍 Groups · Nextdoor · Directories',api:'/api/admin/directories',search:['name','notes','type'],
+ card:d=>cardWrap(`<div class="row"><div class="cat">${enc(d.type)} · ${enc(d.priority)}</div>
+ <span class="badge ${d.status==='live'?'b-good':d.status==='submitted'?'b-warn':'b-mut'}">${enc(d.status)}</span></div>
+ <div class="ttl">${enc(d.name)}</div><div class="blurb">${enc(d.notes)}</div>
+ ${d.url?`<a class="tag" href="${enc(d.url)}" target="_blank" style="margin-top:6px;display:inline-block">open ↗</a>`:''}${when(d.created_at)}`)},
+ {id:'google',label:'🟢 Google Business'},
+ {id:'leads',label:'📨 Leads',api:'/api/admin/leads',search:['name','service','message'],
+ card:l=>cardWrap(`<div class="cat">${enc(l.service||'general')}</div>
+ <div class="ttl">${enc(l.name)}</div>
+ <div class="kv"><span>Phone</span><span>${enc(l.phone)}</span></div>
+ <div class="kv"><span>Email</span><span>${enc(l.email)}</span></div>
+ <div class="kv"><span>Vehicle</span><span>${enc(l.vehicle)}</span></div>
+ <div class="blurb">${enc(l.message)}</div>${when(l.created_at)}`)},
+ {id:'credentials',label:'🔑 Credentials'}
+];
+let CUR='overview';
+
+$('#tabs').innerHTML=TABS.map(t=>`<button data-id="${t.id}" class="${t.id===CUR?'on':''}" onclick="go('${t.id}')">${t.label}</button>`).join('');
+window.go=id=>{CUR=id;document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.id===id));
+ $('#q').value='';render();};
+
+function cardWrap(inner){return `<div class="card"><div class="body">${inner}</div></div>`;}
+const gridWrap=cards=>`<div class="grid">${cards}</div>`;
+
+function render(){
+ const tab=TABS.find(t=>t.id===CUR);
+ const showCtl=!!tab.api; $('#ctl').style.display=showCtl?'':'none';
+ if(tab.id==='overview') return renderOverview();
+ if(tab.id==='best-post') return renderBestPost();
+ if(tab.id==='best-market') return renderBestMarket();
+ if(tab.id==='google') return renderGoogle();
+ if(tab.id==='credentials') return renderCreds();
+ // generic data bucket
+ fetch(tab.api).then(r=>r.json()).then(rows=>{
+ const q=$('#q').value.trim().toLowerCase();
+ let list=rows;
+ if(q&&tab.search) list=rows.filter(r=>tab.search.some(f=>String(r[f]||'').toLowerCase().includes(q)));
+ $('#count').textContent=list.length+' item'+(list.length!==1?'s':'');
+ $('#view').innerHTML=list.length?gridWrap(list.map(tab.card).join('')):'<p style="color:var(--mut)">Nothing here yet.</p>';
+ });
+}
+
+function renderOverview(){
+ fetch('/api/health').then(r=>r.json()).then(h=>{
+ const c=h.counts;
+ $('#view').innerHTML=`
+ <div class="stat">
+ ${Object.entries(c).map(([k,v])=>`<div class="s"><b>${v}</b><span>${k}</span></div>`).join('')}
+ </div>
+ <div class="card"><div class="body">
+ <div class="ttl">Growth command center</div>
+ <div class="blurb">Every bucket below is seeded from live SFV market research. Buckets with a search box are data-driven; the rest (best-times, Google, credentials) are tools. Cards show their created date + time. All write/spend/post actions are gated — see the plan.</div>
+ <div class="tags">
+ <span class="tag">Market: San Fernando Valley</span>
+ <span class="tag">IG: @prestigecwash</span>
+ <span class="tag">Media: Nano Banana + SeeDance</span>
+ <span class="tag b-mut">Google Places: key not set (mock)</span>
+ </div>
+ </div></div>`;
+ $('#count').textContent='';
+ });
+}
+function renderBestPost(){
+ fetch('/api/admin/best-times').then(r=>r.json()).then(bt=>{
+ const pt=bt.post_times||{};
+ $('#view').innerHTML=`
+ <p class="sub" style="color:var(--mut)">${enc(pt.note||'')}</p>
+ <div class="grid">${Object.entries(pt).filter(([k])=>k!=='note').map(([plat,times])=>cardWrap(
+ `<div class="ttl">${enc(plat)}</div><div class="tags">${(times||[]).map(x=>`<span class="tag">${enc(x)}</span>`).join('')}</div>`
+ )).join('')}</div>`;
+ });
+}
+function renderBestMarket(){
+ fetch('/api/admin/best-times').then(r=>r.json()).then(bt=>{
+ const tr=bt.traffic||{},mk=bt.market_times||{};
+ $('#view').innerHTML=`
+ <div class="card"><div class="body"><div class="ttl">Weekly demand</div>
+ <p class="blurb">${enc(tr.note||'')}</p>
+ <div class="heat">${(tr.by_day||[]).map(d=>`<div class="d" style="border-color:${d.demand>=5?'var(--good)':d.demand>=4?'var(--warn)':'var(--line)'}">
+ <b>${enc(d.day)}</b>${'▮'.repeat(d.demand)}<div style="font-size:10px;color:var(--mut);margin-top:4px">${enc(d.note)}</div></div>`).join('')}</div>
+ </div></div>
+ <div class="card"><div class="body"><div class="ttl">Spend triggers</div>
+ <p class="blurb">${enc(mk.note||'')}</p>
+ ${(mk.triggers||[]).map(x=>`<div class="kv"><span>⚡ ${enc(x)}</span></div>`).join('')}</div></div>`;
+ });
+}
+function renderGoogle(){
+ fetch('/api/admin/places').then(r=>r.json()).then(p=>{
+ const mock=p._mock;
+ $('#view').innerHTML=`
+ ${mock?'<div class="card"><div class="body"><span class="badge b-warn">mock data</span><div class="blurb" style="margin-top:8px">Live data needs <code>GOOGLE_PLACES_API_KEY</code> + <code>PCW_PLACE_ID</code> (Steve-gated setup). Then run <code>npm run read:places</code>.</div></div></div>':''}
+ <div class="card"><div class="body">
+ <div class="ttl">${enc(p.name)}</div>
+ <div class="blurb">${enc(p.address)} ${p.rating?'· '+rating(p.rating)+' ('+(p.reviews_count||0)+')':''}</div>
+ <div class="ttl" style="font-size:14px;margin-top:10px">Hours</div>
+ ${(p.hours||[]).map(h=>`<div class="kv"><span>${enc(h)}</span></div>`).join('')}
+ <div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
+ <button class="btn ghost" onclick="draftUpdate('hours')">✎ Update hours in GBP</button>
+ <button class="btn ghost" onclick="draftUpdate('photos')">✎ Add photos in GBP</button>
+ <button class="btn ghost" onclick="draftUpdate('services')">✎ Update services in GBP</button>
+ </div>
+ <div class="when" id="draftMsg"></div>
+ </div></div>`;
+ });
+}
+window.draftUpdate=field=>{
+ fetch('/api/admin/place/draft-update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field})})
+ .then(r=>r.json()).then(j=>{$('#draftMsg').textContent=j.message;if(j.url&&j.url.length>28)window.open(j.url,'_blank');});
+};
+function renderCreds(){
+ fetch('/api/admin/credentials').then(r=>r.json()).then(list=>{
+ $('#view').innerHTML=`<div class="card"><div class="body">
+ <div class="ttl">Credentials</div>
+ <p class="blurb">Presence + last-4 only — full secret values are never shown here (managed via the <code>/secrets</code> skill).</p>
+ ${list.map(k=>`<div class="kv"><span><b>${enc(k.label)}</b><br><span style="color:var(--mut);font-size:12px">${enc(k.purpose)}</span></span>
+ <span>${k.present?`<span class="badge b-good">set ${enc(k.hint)}</span>`:'<span class="badge b-bad">not set</span>'}</span></div>`).join('')}
+ </div></div>`;
+ $('#count').textContent='';
+ });
+}
+render();
+</script>
+</body>
+</html>
diff --git a/public/assets/pcw.css b/public/assets/pcw.css
new file mode 100644
index 0000000..66ca21c
--- /dev/null
+++ b/public/assets/pcw.css
@@ -0,0 +1,98 @@
+/* Prestige Car Wash — shared design system (mobile-first) */
+:root{
+ --bg:#0b0f16; --panel:#121824; --panel2:#0e1420; --ink:#eef3fb; --mut:#8ba0bd;
+ --line:#222e42; --brand:#28c3ff; --brand2:#0a84ff; --gold:#e8c86a; --good:#3ad29f; --warn:#ffb454; --bad:#ff6b6b;
+ --cols:4; --fs:1; --radius:14px; --maxw:1280px;
+ --shadow:0 8px 30px rgba(0,0,0,.35);
+}
+*{box-sizing:border-box}
+html,body{margin:0;padding:0}
+body{
+ background:radial-gradient(1200px 600px at 70% -10%, #14324a 0%, var(--bg) 55%) fixed;
+ color:var(--ink); font-family:"Inter",system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
+ line-height:1.5; -webkit-font-smoothing:antialiased;
+}
+a{color:var(--brand);text-decoration:none}
+img{max-width:100%;display:block}
+.wrap{max-width:var(--maxw);margin:0 auto;padding:0 18px}
+
+/* top nav */
+.nav{position:sticky;top:0;z-index:40;background:rgba(11,15,22,.82);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
+.nav .row{display:flex;align-items:center;gap:16px;height:60px}
+.brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:.5px}
+.brand .mk{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--brand),var(--brand2));display:grid;place-items:center;font-size:16px}
+.brand small{color:var(--gold);font-weight:600;letter-spacing:2px;font-size:10px;text-transform:uppercase}
+.nav .links{display:flex;gap:18px;margin-left:auto;align-items:center}
+.nav .links a{color:var(--mut);font-weight:600;font-size:14px}
+.nav .links a:hover,.nav .links a.on{color:var(--ink)}
+.btn{display:inline-flex;align-items:center;gap:8px;background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;font-weight:700;padding:10px 16px;border-radius:999px;border:0;cursor:pointer;font-size:14px}
+.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
+.btn:hover{filter:brightness(1.07)}
+.hamb{display:none;background:none;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:8px 10px;font-size:18px;cursor:pointer}
+
+/* hero */
+.hero{position:relative;padding:70px 0 54px;text-align:center}
+.hero h1{font-size:clamp(30px,6vw,58px);line-height:1.05;margin:0 0 14px;font-weight:900;letter-spacing:-.5px}
+.hero p.lead{font-size:clamp(15px,2.4vw,20px);color:var(--mut);max-width:660px;margin:0 auto 26px}
+.hero .cta{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
+.pill{display:inline-block;background:var(--panel);border:1px solid var(--line);color:var(--gold);border-radius:999px;padding:6px 14px;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px}
+
+/* generic sections */
+.section{padding:40px 0}
+.section h2{font-size:clamp(22px,3.5vw,32px);margin:0 0 6px;font-weight:800}
+.section .sub{color:var(--mut);margin:0 0 22px}
+
+/* grid + cards */
+.controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
+.controls label{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:8px;font-weight:600}
+.controls select,.controls input[type=search]{background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:8px 10px;font-size:14px}
+.controls input[type=range]{accent-color:var(--brand)}
+.layout{display:grid;grid-template-columns:240px 1fr;gap:20px;align-items:start}
+.filters{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px;position:sticky;top:76px}
+.filters h4{margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--mut)}
+.facet{border-top:1px solid var(--line);padding:10px 0}
+.facet summary{cursor:pointer;font-weight:700;font-size:14px;list-style:none}
+.facet .opt{display:flex;justify-content:space-between;align-items:center;padding:6px 4px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--mut)}
+.facet .opt:hover{background:var(--panel2);color:var(--ink)}
+.facet .opt.on{color:var(--ink)}
+.facet .opt .n{background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--brand)}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
+.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);transition:transform .15s,border-color .15s}
+.card:hover{transform:translateY(-3px);border-color:var(--brand)}
+.card .media{aspect-ratio:4/3;background:linear-gradient(135deg,#0f2233,#0a1520);position:relative;overflow:hidden;display:grid;place-items:center}
+.card .media video,.card .media img{width:100%;height:100%;object-fit:cover}
+.card .media .ph{color:var(--mut);font-size:12px;text-align:center;padding:10px}
+.card .body{padding:calc(12px * var(--fs))}
+.card .ttl{font-weight:800;font-size:calc(15px * var(--fs));margin:0 0 4px}
+.card .cat{font-size:11px;color:var(--gold);text-transform:uppercase;letter-spacing:1px;font-weight:700}
+.card .blurb{color:var(--mut);font-size:calc(12.5px * var(--fs));margin:6px 0 10px}
+.card .price{font-weight:900;font-size:calc(18px * var(--fs));color:var(--brand)}
+.card .row{display:flex;justify-content:space-between;align-items:center;gap:8px}
+.tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
+.tag{font-size:10.5px;background:var(--panel2);border:1px solid var(--line);color:var(--mut);border-radius:999px;padding:2px 8px}
+
+/* footer */
+.foot{border-top:1px solid var(--line);margin-top:40px;padding:28px 0;color:var(--mut);font-size:13px}
+.foot a{color:var(--mut)}
+
+/* filters drawer (mobile) */
+.drawer-backdrop{display:none}
+.filters-toggle{display:none}
+
+/* ---------- responsive / mobile ---------- */
+@media (max-width:900px){
+ .nav .links{position:fixed;inset:60px 0 auto 0;flex-direction:column;background:var(--panel);border-bottom:1px solid var(--line);padding:14px 18px;gap:14px;display:none}
+ .nav .links.open{display:flex}
+ .nav .links a{font-size:16px}
+ .hamb{display:inline-block;margin-left:auto}
+ .layout{grid-template-columns:1fr}
+ .filters-toggle{display:inline-flex}
+ .filters{position:fixed;inset:0 20% 0 0;z-index:60;border-radius:0;transform:translateX(-105%);transition:transform .22s;overflow:auto}
+ .filters.open{transform:none}
+ .drawer-backdrop.open{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:55}
+ .grid{grid-template-columns:repeat(min(var(--cols),2),1fr)!important}
+}
+@media (max-width:520px){
+ .grid{grid-template-columns:1fr!important}
+ .controls{gap:8px}
+}
diff --git a/public/contact.html b/public/contact.html
new file mode 100644
index 0000000..7514473
--- /dev/null
+++ b/public/contact.html
@@ -0,0 +1,70 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Book / Contact — Prestige Car Wash</title>
+<meta name="description" content="Book a wash or detail with Prestige Car Wash in the San Fernando Valley. No-pressure flat pricing.">
+<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">
+<style>
+ form{max-width:560px}
+ .fld{margin-bottom:14px}
+ .fld label{display:block;font-size:13px;color:var(--mut);font-weight:600;margin-bottom:6px}
+ .fld input,.fld select,.fld textarea{width:100%;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:11px 12px;font-size:15px;font-family:inherit}
+ .fld textarea{min-height:90px;resize:vertical}
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+ @media(max-width:520px){.two{grid-template-columns:1fr}}
+ .ok{background:rgba(58,210,159,.12);border:1px solid var(--good);color:var(--good);padding:12px;border-radius:10px;margin-bottom:14px;display:none}
+</style>
+</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" onclick="document.getElementById('lnk').classList.toggle('open')">☰</button>
+ <div class="links" id="lnk">
+ <a href="/">Home</a><a href="/services">Services</a><a href="/contact" class="on">Book / Contact</a>
+ </div>
+</div></nav>
+
+<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>
+ <div class="ok" id="ok"></div>
+ <form id="f">
+ <div class="two">
+ <div class="fld"><label>Name *</label><input name="name" required></div>
+ <div class="fld"><label>Phone *</label><input name="phone" type="tel"></div>
+ </div>
+ <div class="two">
+ <div class="fld"><label>Email</label><input name="email" type="email"></div>
+ <div class="fld"><label>Vehicle (year / make / model)</label><input name="vehicle" placeholder="2021 Tesla Model 3"></div>
+ </div>
+ <div class="two">
+ <div class="fld"><label>Service</label><select name="service" id="svcSel"><option value="">— choose —</option></select></div>
+ <div class="fld"><label>Preferred date / time</label><input name="preferred" placeholder="Sat morning"></div>
+ </div>
+ <div class="fld"><label>Anything else?</label><textarea name="message" placeholder="Pet hair in back seat, fragrance-free please…"></textarea></div>
+ <button class="btn" type="submit">Request booking</button>
+ </form>
+</div></section>
+
+<footer class="foot"><div class="wrap">© 2026 Prestige Car Wash · San Fernando Valley, CA · Instagram @prestigecwash</div></footer>
+
+<script>
+fetch('/api/services?sort=featured').then(r=>r.json()).then(list=>{
+ document.getElementById('svcSel').innerHTML += list.map(s=>`<option value="${s.name}">${s.name} — ${s.price_display||('$'+s.price)}</option>`).join('');
+});
+document.getElementById('f').addEventListener('submit',async e=>{
+ e.preventDefault();
+ const data=Object.fromEntries(new FormData(e.target).entries());
+ const r=await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
+ const j=await r.json();
+ const ok=document.getElementById('ok');
+ if(j.ok){ok.textContent=j.message;ok.style.display='block';e.target.reset();window.scrollTo({top:0,behavior:'smooth'});}
+ else{ok.textContent=j.error||'Something went wrong.';ok.style.borderColor='var(--bad)';ok.style.color='var(--bad)';ok.style.display='block';}
+});
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..824342c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,89 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Prestige Car Wash — Hand Wash, Detail & Ceramic in the San Fernando Valley</title>
+<meta name="description" content="Prestige Car Wash: no-pressure flat pricing, two-bucket hand wash, full detailing and ceramic coating across the San Fernando Valley. Every seat touched, guaranteed.">
+<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" onclick="document.getElementById('lnk').classList.toggle('open')">☰</button>
+ <div class="links" id="lnk">
+ <a href="/" class="on">Home</a>
+ <a href="/services">Services</a>
+ <a href="/contact">Book / Contact</a>
+ <a class="btn" href="/contact">Book a Wash</a>
+ </div>
+</div></nav>
+
+<header class="hero"><div class="wrap">
+ <span class="pill">San Fernando Valley · Hand Wash & Detail</span>
+ <h1>The Prestige treatment,<br>without the upsell.</h1>
+ <p class="lead">Two-bucket hand wash, full-service detailing, and 9H ceramic coating across Sherman Oaks, Encino, Van Nuys, Studio City & the whole Valley. Flat pricing. Every seat touched, guaranteed.</p>
+ <div class="cta">
+ <a class="btn" href="/contact">Book a Wash</a>
+ <a class="btn ghost" href="/services">See Services & Pricing</a>
+ </div>
+</div></header>
+
+<section class="section"><div class="wrap">
+ <h2>Why drivers switch to Prestige</h2>
+ <p class="sub">Built to fix what the Valley complains about most.</p>
+ <div class="grid" style="--cols:4">
+ <div class="card"><div class="body"><div class="cat">No games</div><div class="ttl">Flat, honest pricing</div><div class="blurb">The price on the sign is the price you pay. No commission-driven wax-and-polish pressure.</div></div></div>
+ <div class="card"><div class="body"><div class="cat">Guaranteed</div><div class="ttl">Every seat touched</div><div class="blurb">Back seats and door jambs included — always. Miss a spot? We re-do it free.</div></div></div>
+ <div class="card"><div class="body"><div class="cat">Paint-safe</div><div class="ttl">Two-bucket, no swirls</div><div class="blurb">Hand wash with foam cannon — safe for ceramic, matte, and PPF. No tunnel scratches.</div></div></div>
+ <div class="card"><div class="body"><div class="cat">Peace of mind</div><div class="ttl">48-hour rain check</div><div class="blurb">Rains within two days? Your next wash is on us. Fragrance-free option, too.</div></div></div>
+ </div>
+</div></section>
+
+<section class="section"><div class="wrap">
+ <h2>See the shine in motion</h2>
+ <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>
+ </div>
+</div></section>
+
+<section class="section"><div class="wrap">
+ <div class="row" style="display:flex;justify-content:space-between;align-items:end;gap:12px;flex-wrap:wrap">
+ <div><h2>Popular services</h2><p class="sub">Flat pricing, no surprises.</p></div>
+ <a class="btn ghost" href="/services">View all & filter →</a>
+ </div>
+ <div class="grid" id="feat" style="--cols:4"></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>
+</div></footer>
+
+<script>
+fetch('/api/services?sort=featured').then(r=>r.json()).then(list=>{
+ const feat = list.filter(s=>s.featured).slice(0,4);
+ document.getElementById('feat').innerHTML = feat.map(s=>`
+ <div class="card">
+ <div class="media">${clip(s)}</div>
+ <div class="body">
+ <div class="cat">${s.category}</div>
+ <div class="ttl">${s.name}</div>
+ <div class="blurb">${s.blurb}</div>
+ <div class="row"><span class="price">${s.price_display||('$'+s.price)}</span><a class="btn" href="/contact">Book</a></div>
+ </div>
+ </div>`).join('');
+});
+function clip(s){
+ // Prefer generated video; fall back to still; fall back to placeholder.
+ return `<video src="/${s.media_clip}" muted loop autoplay playsinline
+ onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'ph',innerHTML:'🚿 ${s.name}'}))"></video>`;
+}
+</script>
+</body>
+</html>
diff --git a/public/services.html b/public/services.html
new file mode 100644
index 0000000..aacd751
--- /dev/null
+++ b/public/services.html
@@ -0,0 +1,118 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Services & Pricing — Prestige Car Wash</title>
+<meta name="description" content="Full service menu and flat pricing: express wash, hand wash, full detail, ceramic coating, headlight restoration — San Fernando Valley.">
+<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" onclick="document.getElementById('lnk').classList.toggle('open')">☰</button>
+ <div class="links" id="lnk">
+ <a href="/">Home</a><a href="/services" class="on">Services</a><a href="/contact">Book / Contact</a>
+ <a class="btn" href="/contact">Book a Wash</a>
+ </div>
+</div></nav>
+
+<section class="section"><div class="wrap">
+ <h2>Services & pricing</h2>
+ <p class="sub">Flat, all-in pricing. Filter, sort, and set the density that suits you.</p>
+
+ <div class="controls">
+ <button class="btn ghost filters-toggle" onclick="toggleDrawer(true)">☰ Filters</button>
+ <input type="search" id="q" placeholder="Search services…">
+ <label>Sort
+ <select id="sort">
+ <option value="featured">Featured</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="price-desc">Price ↓</option>
+ <option value="duration">Quickest</option>
+ <option value="title">A–Z</option>
+ </select>
+ </label>
+ <label>Density
+ <input type="range" id="density" min="2" max="6" step="1">
+ </label>
+ <span id="count" style="color:var(--mut);font-size:13px;font-weight:600"></span>
+ </div>
+
+ <div class="drawer-backdrop" id="backdrop" onclick="toggleDrawer(false)"></div>
+ <div class="layout">
+ <aside class="filters" id="filters">
+ <h4>Filters</h4>
+ <details class="facet" open><summary>Category</summary><div id="f-category"></div></details>
+ <details class="facet" open><summary>Price</summary><div id="f-band"></div></details>
+ <button class="btn ghost" style="margin-top:12px;width:100%" onclick="clearFilters()">Clear filters</button>
+ </aside>
+ <main><div class="grid" id="grid"></div></main>
+ </div>
+</div></section>
+
+<footer class="foot"><div class="wrap">© 2026 Prestige Car Wash · San Fernando Valley, CA · Instagram @prestigecwash</div></footer>
+
+<script>
+const $=s=>document.querySelector(s);
+const FIL={category:'',band:''};
+const KEY='pcw';
+let SORT=localStorage.getItem(KEY+'.sort')||'featured';
+let DENS=localStorage.getItem(KEY+'.density')||'4';
+
+$('#sort').value=SORT; $('#density').value=DENS; setDensity(DENS);
+function setDensity(v){v=Number(v);
+ const fs=Math.max(.85,Math.min(1.25, 1+(4-v)*0.06));
+ document.documentElement.style.setProperty('--cols',v);
+ document.documentElement.style.setProperty('--fs',fs.toFixed(3));
+}
+$('#density').oninput=e=>{setDensity(e.target.value);localStorage.setItem(KEY+'.density',e.target.value)};
+$('#sort').onchange=e=>{SORT=e.target.value;localStorage.setItem(KEY+'.sort',SORT);load()};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(load,250)};
+
+function qs(){const p=new URLSearchParams();p.set('sort',SORT);
+ if(FIL.category)p.set('category',FIL.category); if(FIL.band)p.set('band',FIL.band);
+ if($('#q').value.trim())p.set('q',$('#q').value.trim()); return p.toString();}
+
+function facetBox(id,counts,field){
+ const el=$('#'+id);
+ el.innerHTML=Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`
+ <div class="opt ${FIL[field]===k?'on':''}" onclick="pick('${field}','${k.replace(/'/g,"\\'")}')">
+ <span>${k}</span><span class="n">${n}</span></div>`).join('') || '<div class="opt">—</div>';
+}
+window.pick=(field,val)=>{FIL[field]=FIL[field]===val?'':val;load()};
+window.clearFilters=()=>{FIL.category='';FIL.band='';$('#q').value='';load()};
+window.toggleDrawer=(open)=>{$('#filters').classList.toggle('open',open);$('#backdrop').classList.toggle('open',open)};
+
+function load(){
+ Promise.all([
+ fetch('/api/services?'+qs()).then(r=>r.json()),
+ fetch('/api/facets?'+qs()).then(r=>r.json())
+ ]).then(([list,facets])=>{
+ $('#count').textContent=list.length+' service'+(list.length!==1?'s':'');
+ facetBox('f-category',facets.category,'category');
+ facetBox('f-band',facets.band,'band');
+ $('#grid').innerHTML=list.map(card).join('')||'<p style="color:var(--mut)">No services match those filters.</p>';
+ });
+}
+function card(s){
+ return `<div class="card">
+ <div class="media">
+ <video src="/${s.media_clip}" muted loop autoplay playsinline
+ onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'ph',innerHTML:'🚿 ${s.name}'}))"></video>
+ </div>
+ <div class="body">
+ <div class="cat">${s.category} · ${s.duration_min} min</div>
+ <div class="ttl">${s.name}</div>
+ <div class="blurb">${s.blurb}</div>
+ <div class="tags">${(s.highlights||[]).map(h=>`<span class="tag">${h}</span>`).join('')}</div>
+ <div class="row" style="margin-top:10px"><span class="price">${s.price_display||('$'+s.price)}</span><a class="btn" href="/contact">Book</a></div>
+ </div>
+ </div>`;
+}
+load();
+</script>
+</body>
+</html>
diff --git a/reports/leads.jsonl b/reports/leads.jsonl
new file mode 100644
index 0000000..2f83476
--- /dev/null
+++ b/reports/leads.jsonl
@@ -0,0 +1 @@
+{"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
new file mode 100644
index 0000000..297e7d6
--- /dev/null
+++ b/server.js
@@ -0,0 +1,196 @@
+'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', {});
+
+// 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'"],
+ 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 - a.featured) || (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
+ },
+ 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()));
+app.get('/api/places', (req, res) => res.json(places()));
+
+// ---- 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()));
+
+// 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 || '')
+ });
+});
+
+// Contact / booking lead — appended locally (no outbound send; email fan-out is gated).
+app.post('/api/contact', (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 || '',
+ created_at: new Date().toISOString(), source: 'web-form'
+ };
+ 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' }); }
+ res.json({ ok: true, message: "Thanks! We'll reach out to confirm your booking." });
+});
+
+// Admin: view captured leads.
+app.get('/api/admin/leads', (req, res) => {
+ try {
+ const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
+ res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
+ } catch { res.json([]); }
+});
+
+// ---- static + clean URLs ---------------------------------------------------
+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)`));
(oldest)
·
back to Prestige Car Wash
·
Add scripts: db schema, gen-media (Nano Banana + SeeDance, d b94f670 →