[object Object]

← back to Consulting Designerwallcoverings Com

scaffold consulting portal for Designer Wallcoverings

e4e5befdc3140bc4f0323761a92eaf9f97d17c75 · 2026-07-26 07:36:27 -0700 · Steve

Files touched

Diff

commit e4e5befdc3140bc4f0323761a92eaf9f97d17c75
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 07:36:27 2026 -0700

    scaffold consulting portal for Designer Wallcoverings
---
 .deploy.conf               |  10 ++++
 .gitignore                 |  10 ++++
 build.mjs                  |  57 ++++++++++++++++++++
 data/ads.json              |   1 +
 data/best-times.json       |  16 ++++++
 data/client.json           |  27 ++++++++++
 data/competitors.json      |  37 +++++++++++++
 data/content-calendar.json |   1 +
 data/directories.json      |  18 +++++++
 data/intakes.json          |  49 ++++++++++++++++++
 data/media.json            |   1 +
 data/socials.json          |  12 +++++
 data/suggestions.json      |  52 +++++++++++++++++++
 package.json               |  17 ++++++
 public/admin/index.html    | 122 +++++++++++++++++++++++++++++++++++++++++++
 public/intake.html         | 126 +++++++++++++++++++++++++++++++++++++++++++++
 public/portal.html         |  80 ++++++++++++++++++++++++++++
 public/signin.html         |  67 ++++++++++++++++++++++++
 server.js                  |  76 +++++++++++++++++++++++++++
 19 files changed, 779 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..6de250d
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,10 @@
+# Designer Wallcoverings consulting portal — deploy config (~/Projects/_shared/scripts/deploy.sh)
+# NOTE: deploy to Kamatera + DNS are Steve-gated — do NOT run autonomously.
+PROJECT_NAME=consulting-designerwallcoverings-com
+DEPLOY_HOST=45.61.58.125
+DEPLOY_PATH=/root/public-projects/consulting-designerwallcoverings-com
+HEALTH_URL=http://localhost:9702/api/health
+REQUIRED_ENVS=""
+INSTALL_CMD="npm ci --production"
+BUILD_CMD="node build.mjs"
+RSYNC_EXTRA_EXCLUDES="reports"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3bebb33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+reports/
diff --git a/build.mjs b/build.mjs
new file mode 100644
index 0000000..a765d7c
--- /dev/null
+++ b/build.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+// Designer Wallcoverings — deliverable generator (fantasea-consulting pattern).
+// Emits public/versions/<aesthetic>.html: ONE editorial template skinned by a
+// palette/type/copy object per aesthetic direction, so the client picks a look.
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const OUT = join(HERE, 'public', 'versions');
+mkdirSync(OUT, { recursive: true });
+
+const client = JSON.parse(readFileSync(join(HERE, 'data', 'client.json'), 'utf8'));
+
+// Each aesthetic = a skin. Add/edit freely; the client chooses from these.
+const SKINS = {
+  editorial: { bg:'#f6f2ec', ink:'#14110e', accent:'#a9884f', font:"Georgia,serif", vibe:'Luxury / editorial' },
+  modern:    { bg:'#ffffff', ink:'#0b0b0d', accent:'#2b6cff', font:"'Helvetica Neue',system-ui,sans-serif", vibe:'Modern / minimal' },
+  bold:      { bg:'#0d0d12', ink:'#f5f5f7', accent:'#ff4d6d', font:"'Arial Black',system-ui,sans-serif", vibe:'Bold / energetic' },
+  warm:      { bg:'#fbf3e9', ink:'#3a2a1c', accent:'#d98a3d', font:"'Georgia',serif", vibe:'Warm / approachable' },
+  classic:   { bg:'#f4f4f0', ink:'#1c2530', accent:'#7a5b34', font:"'Times New Roman',serif", vibe:'Classic / trustworthy' },
+};
+
+const esc = (s) => String(s || '').replace(/[&<>]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;' }[c]));
+const goals = (client.goals || []).slice(0, 4);
+
+for (const [name, s] of Object.entries(SKINS)) {
+  const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(client.business_name)} — ${s.vibe}</title>
+<style>
+:root{--bg:${s.bg};--ink:${s.ink};--accent:${s.accent}}
+*{box-sizing:border-box}body{margin:0;font-family:${s.font};background:var(--bg);color:var(--ink)}
+.bar{padding:14px 30px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--accent)}
+.bar b{letter-spacing:.06em}.bar span{color:var(--accent);font-size:12px;text-transform:uppercase;letter-spacing:.2em}
+.hero{padding:90px 30px;text-align:center}
+.hero .k{color:var(--accent);letter-spacing:.3em;text-transform:uppercase;font-size:12px}
+.hero h1{font-size:56px;margin:.2em 0;font-weight:700}
+.hero p{font-size:20px;max-width:620px;margin:0 auto;opacity:.8}
+.cta{display:inline-block;margin-top:26px;background:var(--accent);color:var(--bg);padding:14px 32px;text-decoration:none;text-transform:uppercase;letter-spacing:.16em;font-size:13px}
+.goals{max-width:900px;margin:40px auto;padding:0 30px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:18px}
+.goals div{border:1px solid var(--accent);padding:22px;font-size:17px}
+.foot{padding:40px 30px;text-align:center;opacity:.6;font-size:13px}
+</style></head><body>
+<div class="bar"><b>${esc(client.business_name)}</b><span>${s.vibe} concept</span></div>
+<div class="hero">
+  <div class="k">${esc(client.industry || '')} ${client.location ? '· ' + esc(client.location) : ''}</div>
+  <h1>${esc(client.business_name)}</h1>
+  <p>${esc(client.tagline || 'A new website concept, ready to make your first impression unforgettable.')}</p>
+  <a class="cta" href="#">Get in touch</a>
+</div>
+${goals.length ? '<div class="goals">' + goals.map(g => '<div>' + esc(g) + '</div>').join('') + '</div>' : ''}
+<div class="foot">Concept "${name}" · generated ${client.generated_at || ''} · one of several directions to choose from.</div>
+</body></html>`;
+  writeFileSync(join(OUT, `${name}.html`), html);
+}
+console.log('Generated ' + Object.keys(SKINS).length + ' concept versions into public/versions/');
diff --git a/data/ads.json b/data/ads.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/ads.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/best-times.json b/data/best-times.json
new file mode 100644
index 0000000..6a612d6
--- /dev/null
+++ b/data/best-times.json
@@ -0,0 +1,16 @@
+{
+  "instagram": [
+    "Tue 11:00",
+    "Thu 19:00",
+    "Sat 10:00"
+  ],
+  "facebook": [
+    "Wed 13:00",
+    "Sun 12:00"
+  ],
+  "tiktok": [
+    "Tue 18:00",
+    "Fri 20:00"
+  ],
+  "note": "Generic starting cadence — replace with account-specific data via scripts/best-times.js."
+}
\ No newline at end of file
diff --git a/data/client.json b/data/client.json
new file mode 100644
index 0000000..4e98488
--- /dev/null
+++ b/data/client.json
@@ -0,0 +1,27 @@
+{
+  "business_name": "Designer Wallcoverings",
+  "industry": "Luxury wallcoverings & designer fabrics — e-commerce + to-the-trade",
+  "location": "Los Angeles, CA",
+  "contact": {
+    "name": "Steve Abrams",
+    "email": "steve@designerwallcoverings.com",
+    "phone": ""
+  },
+  "current_website": null,
+  "tagline": "",
+  "goals": [
+    "Grow retail conversion on the flagship Shopify store",
+    "Win AI-answer citations (AEO) for wallcovering queries",
+    "Turn the 15.8k staged-draft activation drip into measurable revenue",
+    "Social-to-revenue attribution across the 6 connected channels",
+    "Leverage the ~50 sister-site microsite fleet as a discovery network"
+  ],
+  "competitors": [
+    "decoratorsbest.com",
+    "burkedecor.com",
+    "mahoneswallpapershop.com",
+    "wallpapersToGo",
+    "stevesblindsandwallpaper.com"
+  ],
+  "generated_at": "2026-07-26"
+}
\ No newline at end of file
diff --git a/data/competitors.json b/data/competitors.json
new file mode 100644
index 0000000..72c9d4c
--- /dev/null
+++ b/data/competitors.json
@@ -0,0 +1,37 @@
+[
+  {
+    "id": "comp-1",
+    "name": "decoratorsbest.com",
+    "socials": {},
+    "ads_running": null,
+    "notes": ""
+  },
+  {
+    "id": "comp-2",
+    "name": "burkedecor.com",
+    "socials": {},
+    "ads_running": null,
+    "notes": ""
+  },
+  {
+    "id": "comp-3",
+    "name": "mahoneswallpapershop.com",
+    "socials": {},
+    "ads_running": null,
+    "notes": ""
+  },
+  {
+    "id": "comp-4",
+    "name": "wallpapersToGo",
+    "socials": {},
+    "ads_running": null,
+    "notes": ""
+  },
+  {
+    "id": "comp-5",
+    "name": "stevesblindsandwallpaper.com",
+    "socials": {},
+    "ads_running": null,
+    "notes": ""
+  }
+]
\ No newline at end of file
diff --git a/data/content-calendar.json b/data/content-calendar.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/content-calendar.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/directories.json b/data/directories.json
new file mode 100644
index 0000000..9545008
--- /dev/null
+++ b/data/directories.json
@@ -0,0 +1,18 @@
+[
+  {
+    "name": "Google Business Profile",
+    "status": "todo"
+  },
+  {
+    "name": "Yelp",
+    "status": "todo"
+  },
+  {
+    "name": "Apple Maps",
+    "status": "todo"
+  },
+  {
+    "name": "Bing Places",
+    "status": "todo"
+  }
+]
\ No newline at end of file
diff --git a/data/intakes.json b/data/intakes.json
new file mode 100644
index 0000000..a42a919
--- /dev/null
+++ b/data/intakes.json
@@ -0,0 +1,49 @@
+[
+  {
+    "received_at": "2026-07-26T14:36:27.630Z",
+    "source": "scaffold",
+    "intake": {
+      "business_name": "Designer Wallcoverings",
+      "slug": "designerwallcoverings-com",
+      "website": "https://www.designerwallcoverings.com",
+      "no_website": false,
+      "industry": "Luxury wallcoverings & designer fabrics — e-commerce + to-the-trade",
+      "location": "Los Angeles, CA",
+      "contact_name": "Steve Abrams",
+      "contact_email": "steve@designerwallcoverings.com",
+      "phone": "",
+      "years_in_business": "30+",
+      "aesthetic": "Editorial luxury — quiet, archival, materials-first",
+      "goals": [
+        "Grow retail conversion on the flagship Shopify store",
+        "Win AI-answer citations (AEO) for wallcovering queries",
+        "Turn the 15.8k staged-draft activation drip into measurable revenue",
+        "Social-to-revenue attribution across the 6 connected channels",
+        "Leverage the ~50 sister-site microsite fleet as a discovery network"
+      ],
+      "services": [
+        "Designer wallpaper & wallcoverings (100+ vendor lines)",
+        "Designer fabrics by the yard",
+        "Memo samples ($4.25 program)",
+        "Trade / designer program",
+        "Private-label lines (Phillipe Romano, Malibu Wallpaper, Hollywood Wallcoverings)"
+      ],
+      "socials": {
+        "instagram": "https://www.instagram.com/designerwallcoverings",
+        "facebook": "https://www.facebook.com/designerwallcoverings",
+        "tiktok": "https://www.tiktok.com/@designerwallcoverings",
+        "youtube": "connected",
+        "bluesky": "connected",
+        "threads": "https://www.threads.net/@designerwallcoverings"
+      },
+      "competitors": [
+        "decoratorsbest.com",
+        "burkedecor.com",
+        "mahoneswallpapershop.com",
+        "wallpapersToGo",
+        "stevesblindsandwallpaper.com"
+      ],
+      "notes": "Internal engagement — DW is Steve's own business. Every analysis section binds to real internal data (dw_unified mirror, live Shopify store, MCC marketing.dw, sister-site fleet, GMC merchant 146735262). Read-only against all DW prod."
+    }
+  }
+]
\ No newline at end of file
diff --git a/data/media.json b/data/media.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/media.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/data/socials.json b/data/socials.json
new file mode 100644
index 0000000..86e3073
--- /dev/null
+++ b/data/socials.json
@@ -0,0 +1,12 @@
+{
+  "instagram": "https://www.instagram.com/designerwallcoverings",
+  "facebook": "https://www.facebook.com/designerwallcoverings",
+  "tiktok": "https://www.tiktok.com/@designerwallcoverings",
+  "youtube": "connected",
+  "linkedin": null,
+  "x": null,
+  "pinterest": null,
+  "google_business": null,
+  "connected": false,
+  "notes": "Connect accounts in the command center. Posting stays DRAFT-ONLY until Steve enables."
+}
\ No newline at end of file
diff --git a/data/suggestions.json b/data/suggestions.json
new file mode 100644
index 0000000..62e2fe7
--- /dev/null
+++ b/data/suggestions.json
@@ -0,0 +1,52 @@
+[
+  {
+    "id": "sug-1",
+    "title": "Grow retail conversion on the flagship Shopify store",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-26T14:36:27.630Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-2",
+    "title": "Win AI-answer citations (AEO) for wallcovering queries",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-26T14:36:27.630Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-3",
+    "title": "Turn the 15.8k staged-draft activation drip into measurable revenue",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-26T14:36:27.630Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-4",
+    "title": "Social-to-revenue attribution across the 6 connected channels",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-26T14:36:27.630Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-5",
+    "title": "Leverage the ~50 sister-site microsite fleet as a discovery network",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-26T14:36:27.630Z",
+    "source": "intake"
+  }
+]
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..45f3e6f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "consulting-designerwallcoverings-com",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Designer Wallcoverings — consulting portal (site concepts + integrated-social growth command center)",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "build": "node build.mjs",
+    "enrich": "node ../../.claude/skills/consulting/assets/enrich.mjs --dir ."
+  },
+  "dependencies": {
+    "express": "^4.21.2",
+    "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..5204822
--- /dev/null
+++ b/public/admin/index.html
@@ -0,0 +1,122 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Designer Wallcoverings · Growth &amp; Social Command Center</title>
+<style>
+  :root{--ink:#14110e;--bg:#12100e;--panel:#1c1915;--gold:#c6a765;--line:#2c2823;--txt:#e9e2d6}
+  *{box-sizing:border-box}
+  body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--txt)}
+  header{padding:16px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}
+  header h1{font-size:16px;font-weight:600;letter-spacing:.08em;margin:0}
+  header .eyebrow{color:var(--gold);font-size:11px;letter-spacing:.28em;text-transform:uppercase}
+  header a{color:var(--gold);text-decoration:none;font-size:13px}
+  .wrap{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding:20px 24px}
+  .panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px 18px}
+  .panel h2{font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--gold);margin:0 0 10px;display:flex;justify-content:space-between;align-items:center}
+  .panel h2 .tag{font-size:10px;color:#8a8272;letter-spacing:.06em}
+  textarea{width:100%;min-height:150px;background:#0e0c0a;color:#cfe6cf;border:1px solid var(--line);border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;padding:10px;resize:vertical}
+  button{background:var(--gold);color:#12100e;border:0;padding:8px 16px;font-weight:600;letter-spacing:.08em;font-size:12px;border-radius:4px;cursor:pointer;margin-top:8px}
+  button:hover{filter:brightness(1.08)}
+  .saved{color:#7ad17a;font-size:12px;margin-left:10px}
+  .note{font-size:12px;color:#8a8272;margin:0 0 8px}
+</style>
+</head>
+<body>
+  <header>
+    <div><div class="eyebrow">Integrated Social · Growth</div><h1>Designer Wallcoverings — Command Center</h1></div>
+    <div><a href="/portal">← Deliverable</a> &nbsp; <a href="/">Sign-in</a></div>
+  </header>
+
+  <!-- Low Hanging Fruit — reusable ranked read-only view over /api/admin/suggestions.
+       ROI = impact/effort; time from effort; quick wins (low effort + high impact) flagged. -->
+  <section id="lhf">
+    <style>
+      #lhf{margin:20px 24px 4px;background:#1c1915;border:1px solid #2c2823;border-radius:6px;padding:18px 20px}
+      #lhf .lhf-h{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:2px}
+      #lhf .lhf-h h2{font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#c6a765;margin:0}
+      #lhf .lhf-sub{color:#8a8272;font-size:12px}
+      #lhf ol{list-style:none;margin:14px 0 0;padding:0;display:grid;gap:10px}
+      #lhf li{display:grid;grid-template-columns:34px 1fr;gap:12px;background:#161310;border:1px solid #2c2823;border-radius:5px;padding:12px 14px}
+      #lhf li.fruit{border-color:#c6a765}
+      #lhf .rk{font-size:20px;font-weight:800;color:#c6a765;line-height:1.1}
+      #lhf .ti{font-weight:600;color:#e9e2d6}
+      #lhf .badges{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}
+      #lhf .b{font-size:10px;letter-spacing:.05em;text-transform:uppercase;padding:3px 8px;border-radius:3px;border:1px solid #2c2823;color:#b8ad97}
+      #lhf .b.roi-high,#lhf .b.time-quick{color:#7ad17a;border-color:#3a5a3a}
+      #lhf .b.roi-solid{color:#c6a765}
+      #lhf .b.fruit{background:#c6a765;color:#12100e;border:0;font-weight:700}
+      #lhf .rat{font-size:12.5px;color:#b8ad97;margin-top:2px}
+      #lhf .why{font-size:12.5px;color:#8a8272;margin-top:4px}
+      #lhf .why b{color:#c6a765;font-weight:600}
+      #lhf .empty{color:#8a8272;font-size:13px}
+    </style>
+    <div class="lhf-h">
+      <h2>⚡ Low Hanging Fruit</h2>
+      <span class="lhf-sub">ranked by ROI ÷ time — fastest, highest-return moves first</span>
+    </div>
+    <p style="color:#8a8272;font-size:12px;margin:8px 0 0">⚡ <b style="color:#c6a765">Quick wins</b> (low effort · high impact) leverage assets you already have — activate, don’t rebuild. That is why they are simple.</p>
+    <ol id="lhf-list"><li class="empty">Loading growth moves…</li></ol>
+    <script>
+    (function(){
+      var TIME=function(e){return e<=2?['Quick','time-quick']:e===3?['Moderate','time-mod']:['Involved','time-inv'];};
+      var ROI=function(i,e){var r=i/Math.max(1,e);return r>=2?['High','roi-high']:r>=1.2?['Solid','roi-solid']:['Low','roi-low'];};
+      function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
+      fetch('/api/admin/suggestions').then(function(r){return r.ok?r.json():[];}).then(function(list){
+        var el=document.getElementById('lhf-list');
+        list=(list||[]).filter(function(s){return s&&s.title;});
+        if(!list.length){el.innerHTML='<li class="empty">No growth moves yet — add ideas in the Growth Moves panel below.</li>';return;}
+        list.forEach(function(s){s._i=Number(s.impact)||0;s._e=Number(s.effort)||3;s._roi=s._i/Math.max(1,s._e);s._fruit=s._e<=2&&s._i>=4;});
+        list.sort(function(a,b){return b._roi-a._roi||a._e-b._e||b._i-a._i;});
+        el.innerHTML=list.map(function(s,idx){
+          var t=TIME(s._e),ro=ROI(s._i,s._e);
+          return `<li class="${s._fruit?'fruit':''}"><div class="rk">${idx+1}</div><div>`+
+            `<div class="ti">${esc(s.title)}</div>`+
+            `<div class="badges">${s._fruit?'<span class="b fruit">⚡ Quick win</span>':''}`+
+            `<span class="b ${ro[1]}">ROI ${s._roi.toFixed(1)}</span><span class="b ${t[1]}">⏱ ${t[0]}</span>`+
+            `${s.category?`<span class="b">${esc(s.category)}</span>`:''}`+
+            `<span class="b">impact ${s._i} · effort ${s._e}</span></div>`+
+            `${s.rationale?`<div class="rat">${esc(s.rationale)}</div>`:''}</div></li>`;
+        }).join('');
+      }).catch(function(){document.getElementById('lhf-list').innerHTML='<li class="empty">Could not load suggestions.</li>';});
+    })();
+    </script>
+  </section>
+
+  <div class="wrap" id="wrap"></div>
+
+<script>
+// Each panel is a live-editable view of one data bucket. Edit the JSON, Save,
+// and the server rewrites data/<bucket>.json. Script runners (competitors-scan,
+// best-times, ig-pull, places-read, gen-media) repopulate these from real data.
+const PANELS = [
+  ['socials','Connected Accounts','Instagram · Facebook · TikTok · YouTube · LinkedIn · GBP'],
+  ['best-times','Best Times to Post','per-platform posting cadence'],
+  ['content-calendar','Content Calendar','planned posts / campaigns — DRAFT ONLY'],
+  ['competitors','Competitor Radar','who else is in the market + their social/ads'],
+  ['ads','Ad Signals','which platforms competitors advertise on'],
+  ['directories','Listings & Directories','GBP · Yelp · Apple · Bing'],
+  ['suggestions','Growth Moves','impact / effort ranked ideas'],
+  ['media','Generated Media','reels / graphics produced for this client'],
+];
+const wrap = document.getElementById('wrap');
+async function j(u,opt){ const r=await fetch(u,opt); return r.ok?r.json():null; }
+for (const [key,title,tag] of PANELS){
+  const p = document.createElement('div'); p.className='panel';
+  p.innerHTML = '<h2>'+title+'<span class="tag">'+tag+'</span></h2>'
+    + '<textarea id="ta-'+key+'">loading…</textarea>'
+    + '<div><button data-k="'+key+'">Save '+key+'</button><span class="saved" id="ok-'+key+'"></span></div>';
+  wrap.appendChild(p);
+  j('/api/admin/'+key).then(d=>{ document.getElementById('ta-'+key).value = JSON.stringify(d ?? [], null, 2); });
+}
+wrap.addEventListener('click', async (e)=>{
+  const b = e.target.closest('button[data-k]'); if(!b) return;
+  const k = b.dataset.k; const ta = document.getElementById('ta-'+k); let body;
+  try { body = JSON.parse(ta.value); } catch { document.getElementById('ok-'+k).textContent='✗ invalid JSON'; return; }
+  const r = await fetch('/api/admin/'+k,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+  document.getElementById('ok-'+k).textContent = r.ok ? '✓ saved '+new Date().toLocaleTimeString() : '✗ error';
+  setTimeout(()=>{document.getElementById('ok-'+k).textContent='';},4000);
+});
+</script>
+</body>
+</html>
diff --git a/public/intake.html b/public/intake.html
new file mode 100644
index 0000000..f473f10
--- /dev/null
+++ b/public/intake.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>New Website + Social — Intake · Designer Wallcoverings</title>
+<style>
+  :root{--ink:#14110e;--paper:#f6f2ec;--gold:#a9884f;--line:#dcd3c5}
+  *{box-sizing:border-box}
+  body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:var(--paper);color:var(--ink);padding:0}
+  header{padding:40px 24px 20px;text-align:center;border-bottom:1px solid var(--line);background:#fff}
+  header .eyebrow{letter-spacing:.32em;text-transform:uppercase;font-size:11px;color:var(--gold)}
+  header h1{font-family:Georgia,serif;font-weight:400;font-size:30px;margin:.3em 0 .1em}
+  header p{color:#6b6357;margin:0}
+  form{max-width:760px;margin:0 auto;padding:28px 24px 80px}
+  fieldset{border:1px solid var(--line);background:#fff;margin:18px 0;padding:20px 22px}
+  legend{font-family:Georgia,serif;font-size:18px;padding:0 10px;color:var(--gold)}
+  label{display:block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#7a7266;margin:14px 0 5px;font-weight:600}
+  input,textarea,select{width:100%;padding:11px 12px;border:1px solid var(--line);background:#faf8f4;font-size:15px;font-family:inherit;border-radius:2px}
+  textarea{min-height:76px;resize:vertical}
+  .two{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}
+  .hint{font-size:12px;color:#9a9082;text-transform:none;letter-spacing:0;font-weight:400;margin-top:4px}
+  .check{display:flex;align-items:center;gap:8px;margin-top:10px}
+  .check input{width:auto}
+  button{background:var(--ink);color:var(--paper);border:0;padding:15px 34px;letter-spacing:.16em;text-transform:uppercase;font-size:13px;cursor:pointer;margin-top:8px}
+  button:hover{background:var(--gold)}
+  #done{display:none;background:#eef6ee;border:1px solid #b7d6b7;padding:18px;margin:18px 0;color:#25632a}
+</style>
+</head>
+<body>
+  <header>
+    <div class="eyebrow">Consulting Intake</div>
+    <h1>Let's build your website &amp; social</h1>
+    <p>Answer what you can — this spawns your new site concepts and a social growth plan. No current website required.</p>
+  </header>
+
+  <form id="f">
+    <div id="done"></div>
+
+    <fieldset><legend>The Business</legend>
+      <div class="two">
+        <div><label>Business name*</label><input name="business_name" required></div>
+        <div><label>Industry / what you do</label><input name="industry" placeholder="e.g. car wash, yacht charters, law firm"></div>
+      </div>
+      <div class="two">
+        <div><label>City / service area</label><input name="location"></div>
+        <div><label>In business since</label><input name="since" placeholder="year"></div>
+      </div>
+      <label>One-line pitch (tagline)</label><input name="tagline" placeholder="What makes you the obvious choice?">
+    </fieldset>
+
+    <fieldset><legend>Contact</legend>
+      <div class="two">
+        <div><label>Your name</label><input name="contact_name"></div>
+        <div><label>Role</label><input name="contact_role"></div>
+      </div>
+      <div class="two">
+        <div><label>Email</label><input name="contact_email" type="email"></div>
+        <div><label>Phone</label><input name="contact_phone"></div>
+      </div>
+    </fieldset>
+
+    <fieldset><legend>Current Presence</legend>
+      <div class="check"><input type="checkbox" id="nosite" name="no_website" value="1"><label for="nosite" style="margin:0;text-transform:none;letter-spacing:0;font-weight:400">We don't have a website yet — build one from scratch</label></div>
+      <label>Current website (if any)</label><input name="current_website" placeholder="https://">
+      <div class="hint">Social handles — leave blank what you don't have; we'll set up the rest.</div>
+      <div class="two">
+        <div><label>Instagram</label><input name="ig"></div>
+        <div><label>Facebook</label><input name="fb"></div>
+        <div><label>TikTok</label><input name="tt"></div>
+        <div><label>YouTube</label><input name="yt"></div>
+        <div><label>LinkedIn</label><input name="li"></div>
+        <div><label>Google Business Profile</label><input name="gbp"></div>
+      </div>
+    </fieldset>
+
+    <fieldset><legend>Goals &amp; Growth</legend>
+      <label>Top goals (one per line)</label><textarea name="goals" placeholder="More bookings&#10;Rank on Google&#10;Grow Instagram"></textarea>
+      <label>Who is your ideal customer?</label><textarea name="audience"></textarea>
+      <label>Direct competitors (one per line)</label><textarea name="competitors"></textarea>
+    </fieldset>
+
+    <fieldset><legend>Brand &amp; Aesthetic</legend>
+      <label>Aesthetic direction</label>
+      <select name="aesthetic">
+        <option value="">— pick a starting feel —</option>
+        <option>Luxury / editorial</option>
+        <option>Modern / minimal</option>
+        <option>Bold / energetic</option>
+        <option>Warm / approachable</option>
+        <option>Classic / trustworthy</option>
+      </select>
+      <div class="hint">We'll generate several concept versions of your site in different high-end looks to choose from.</div>
+      <label>Brand colors / anything you love or hate</label><textarea name="brand_notes"></textarea>
+      <label>Assets you have (logo, photos, video?)</label><textarea name="assets"></textarea>
+    </fieldset>
+
+    <button type="submit">Submit intake →</button>
+  </form>
+
+<script>
+document.getElementById('f').addEventListener('submit', async (e) => {
+  e.preventDefault();
+  const fd = new FormData(e.target);
+  const o = Object.fromEntries(fd.entries());
+  const payload = {
+    business_name: o.business_name, industry: o.industry, location: o.location, since: o.since,
+    tagline: o.tagline, contact_name: o.contact_name, contact_role: o.contact_role,
+    contact_email: o.contact_email, contact_phone: o.contact_phone,
+    current_website: o.no_website ? null : (o.current_website || null),
+    no_website: !!o.no_website,
+    socials: { instagram: o.ig||null, facebook: o.fb||null, tiktok: o.tt||null, youtube: o.yt||null, linkedin: o.li||null, google_business: o.gbp||null },
+    goals: (o.goals||'').split('\n').map(s=>s.trim()).filter(Boolean),
+    audience: o.audience, competitors: (o.competitors||'').split('\n').map(s=>s.trim()).filter(Boolean),
+    aesthetic: o.aesthetic, brand_notes: o.brand_notes, assets: o.assets,
+  };
+  const r = await fetch('/api/intake', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
+  const j = await r.json();
+  const d = document.getElementById('done');
+  d.textContent = j.message || 'Received — thank you.';
+  d.style.display = 'block';
+  d.scrollIntoView({ behavior:'smooth' });
+  e.target.reset();
+});
+</script>
+</body>
+</html>
diff --git a/public/portal.html b/public/portal.html
new file mode 100644
index 0000000..9c92d19
--- /dev/null
+++ b/public/portal.html
@@ -0,0 +1,80 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Designer Wallcoverings · Deliverable</title>
+<style>
+  :root{--ink:#14110e;--paper:#f6f2ec;--gold:#a9884f;--line:#dcd3c5}
+  *{box-sizing:border-box}
+  body{margin:0;font-family:Georgia,serif;background:var(--paper);color:var(--ink)}
+  nav{display:flex;justify-content:space-between;align-items:center;padding:16px 28px;background:#fff;border-bottom:1px solid var(--line);font-family:system-ui,sans-serif}
+  nav .brand{letter-spacing:.06em}
+  nav a{color:var(--gold);text-decoration:none;font-size:13px;letter-spacing:.1em;text-transform:uppercase;margin-left:18px}
+  .hero{padding:56px 28px 34px;text-align:center}
+  .hero .eyebrow{letter-spacing:.32em;text-transform:uppercase;font-size:11px;color:var(--gold);font-family:system-ui,sans-serif}
+  .hero h1{font-weight:400;font-size:44px;margin:.2em 0}
+  .hero p{font-style:italic;color:#6b6357;max-width:640px;margin:0 auto}
+  section{max-width:1080px;margin:0 auto;padding:28px}
+  h2{font-weight:400;font-size:26px;border-bottom:1px solid var(--line);padding-bottom:8px}
+  .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:18px;margin-top:18px}
+  .vcard{background:#fff;border:1px solid var(--line);text-decoration:none;color:inherit;transition:.2s}
+  .vcard:hover{box-shadow:0 16px 40px rgba(20,17,14,.1);transform:translateY(-2px)}
+  .vcard .swatch{height:120px;background:var(--gold)}
+  .vcard .body{padding:14px 16px}
+  .vcard b{font-family:system-ui,sans-serif;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#9a9082}
+  .list{font-family:system-ui,sans-serif;font-size:15px}
+  .list li{margin:8px 0}
+  .badge{display:inline-block;font-family:system-ui,sans-serif;font-size:11px;background:#faf7f1;border:1px solid var(--gold);color:var(--gold);padding:2px 9px;border-radius:20px;margin-left:8px}
+</style>
+</head>
+<body>
+  <nav>
+    <div class="brand" id="brand">Designer Wallcoverings</div>
+    <div><a href="/portal">Deliverable</a><a href="/admin">Growth &amp; Social</a><a href="/">Sign-in</a></div>
+  </nav>
+
+  <div class="hero">
+    <div class="eyebrow">Consulting Deliverable</div>
+    <h1 id="name">Designer Wallcoverings</h1>
+    <p id="tag"></p>
+  </div>
+
+  <section>
+    <h2>Website concept directions <span class="badge" id="vcount">—</span></h2>
+    <p class="list" style="color:#6b6357">Several high-end redesign concepts of your new site — pick the direction that feels right. (Generate/refresh with <code>node build.mjs</code>.)</p>
+    <div class="cards" id="versions"></div>
+  </section>
+
+  <section>
+    <h2>Priority growth moves</h2>
+    <ul class="list" id="sugs"></ul>
+  </section>
+
+  <section>
+    <h2>Integrated social snapshot</h2>
+    <ul class="list" id="social"></ul>
+  </section>
+
+<script>
+async function j(u){ const r = await fetch(u); return r.ok ? r.json() : null; }
+(async () => {
+  const c = await j('/api/client'); if (c && c.business_name){ document.getElementById('name').textContent = c.business_name; document.getElementById('brand').textContent = c.business_name; if(c.tagline) document.getElementById('tag').textContent = c.tagline; }
+  // version concepts (generated into /versions by build.mjs)
+  const known = ['flagship','editorial','modern','bold','warm','classic'];
+  const vc = document.getElementById('versions'); let found = 0;
+  for (const v of known){ const ok = await fetch('/versions/'+v+'.html',{method:'HEAD'}).then(r=>r.ok).catch(()=>false);
+    if(ok){ found++; const a=document.createElement('a'); a.className='vcard'; a.href='/versions/'+v+'.html';
+      a.innerHTML='<div class="swatch"></div><div class="body"><b>'+v+'</b></div>'; vc.appendChild(a); } }
+  document.getElementById('vcount').textContent = found + ' concepts';
+  if(!found) vc.innerHTML = '<p style="font-family:system-ui;color:#9a9082">No concepts generated yet — run <code>node build.mjs</code>.</p>';
+  // suggestions
+  const s = await j('/api/admin/suggestions')||[]; const su=document.getElementById('sugs');
+  su.innerHTML = s.length ? s.map(x=>'<li><b>'+(x.title||'')+'</b> <span class="badge">impact '+(x.impact||'?')+' / effort '+(x.effort||'?')+'</span><br><span style="color:#6b6357">'+(x.rationale||'')+'</span></li>').join('') : '<li style="color:#9a9082">No growth moves seeded yet.</li>';
+  // socials
+  const so = await j('/api/admin/socials')||{}; const el=document.getElementById('social');
+  const rows = Object.entries(so).filter(([k])=>!['connected','notes'].includes(k)).map(([k,v])=>'<li><b>'+k+'</b> — '+(v?('<a href="'+(String(v).startsWith('http')?v:'#')+'">'+v+'</a>'):'<span style="color:#c66">not set up yet</span>')+'</li>');
+  el.innerHTML = rows.join('') + (so.notes?'<li style="color:#9a9082;font-size:13px">'+so.notes+'</li>':'');
+})();
+</script>
+</body>
+</html>
diff --git a/public/signin.html b/public/signin.html
new file mode 100644
index 0000000..24e9cd4
--- /dev/null
+++ b/public/signin.html
@@ -0,0 +1,67 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Designer Wallcoverings · Consulting Portal</title>
+<style>
+  :root{--ink:#14110e;--paper:#f6f2ec;--gold:#a9884f;--line:#dcd3c5}
+  *{box-sizing:border-box}
+  body{margin:0;font-family:'Georgia',serif;background:var(--paper);color:var(--ink);min-height:100vh;display:grid;place-items:center;padding:32px}
+  .card{max-width:560px;width:100%;background:#fff;border:1px solid var(--line);box-shadow:0 24px 60px rgba(20,17,14,.08);padding:44px 48px}
+  .eyebrow{letter-spacing:.32em;text-transform:uppercase;font-size:11px;color:var(--gold);font-family:system-ui,sans-serif}
+  h1{font-size:34px;margin:.35em 0 .1em;font-weight:400;letter-spacing:.01em}
+  .sub{color:#6b6357;font-style:italic;margin:0 0 24px}
+  .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 22px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:20px 0;margin:20px 0;font-family:system-ui,sans-serif;font-size:14px}
+  .grid b{display:block;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#9a9082;font-weight:600;margin-bottom:2px}
+  .creds{background:#faf7f1;border:1px dashed var(--gold);padding:16px 18px;margin:18px 0;font-family:'SF Mono',ui-monospace,monospace;font-size:13px}
+  .creds .row{display:flex;justify-content:space-between;padding:3px 0}
+  .creds .k{color:#9a9082}
+  a.enter{display:inline-block;margin-top:10px;background:var(--ink);color:#f6f2ec;text-decoration:none;padding:13px 26px;letter-spacing:.16em;text-transform:uppercase;font-size:12px;font-family:system-ui,sans-serif}
+  a.enter:hover{background:var(--gold)}
+  .foot{margin-top:22px;font-size:12px;color:#9a9082;font-family:system-ui,sans-serif}
+  .foot a{color:var(--gold)}
+  .gwrap{margin-top:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-family:system-ui,sans-serif}
+  .gbtn{display:inline-flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);color:#3c4043;padding:11px 18px;font-size:13px;border-radius:4px;cursor:not-allowed;opacity:.65}
+  .soon{font-size:11px;color:#9a9082;letter-spacing:.04em;text-transform:uppercase}
+</style>
+</head>
+<body>
+  <div class="card">
+    <div class="eyebrow">Consulting Engagement</div>
+    <h1>Designer Wallcoverings</h1>
+    <p class="sub"></p>
+
+    <div class="grid" id="meta">
+      <div><b>Industry</b>Luxury wallcoverings & designer fabrics — e-commerce + to-the-trade</div>
+      <div><b>Location</b>Los Angeles, CA</div>
+      <div><b>Current Site</b>none yet</div>
+      <div><b>Engagement Opened</b>2026-07-26</div>
+      <div><b>Primary Contact</b>Steve Abrams</div>
+      <div><b>Reach</b>steve@designerwallcoverings.com · </div>
+    </div>
+
+    <!-- Steve's standing rule: the initial sign-in page CARRIES the credentials. -->
+    <!-- SECURITY: never render the fleet-master credential in HTML. On a PUBLIC
+         page the whole block is suppressed (scaffold sets CREDS_BLOCK to a note);
+         a gated per-client portal renders only the client login. -->
+    <div class="creds"><div class="row"><span class="k">Portal</span><span>/portal &amp; /admin</span></div><div class="row"><span class="k">Access</span><span>credentials delivered separately</span></div></div>
+
+    <a class="enter" href="/portal">Enter the deliverable →</a>
+    &nbsp;
+    <a class="enter" style="background:transparent;color:var(--ink);border:1px solid var(--ink)" href="/admin">Growth command center</a>
+
+    <!-- Google login is a FUTURE upgrade; standard protection (Basic Auth) is live now. -->
+    <div class="gwrap">
+      <button class="gbtn" disabled title="Google sign-in is coming soon — credentials delivered separately">
+        <svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true"><path fill="#4285F4" d="M45 24.5c0-1.6-.1-2.8-.4-4H24v7.3h11.9c-.2 1.9-1.5 4.7-4.3 6.6l6.6 5.1C42 41.9 45 33.9 45 24.5z"/><path fill="#34A853" d="M24 46c5.7 0 10.5-1.9 14-5.1l-6.6-5.1c-1.8 1.2-4.2 2.1-7.4 2.1-5.7 0-10.5-3.8-12.2-9l-6.8 5.3C8.5 41.2 15.7 46 24 46z"/><path fill="#FBBC05" d="M11.8 28.9C11.3 27.5 11 26 11 24.5s.3-3 0.8-4.4l-6.8-5.3C3.7 17.6 3 20.9 3 24.5s.7 6.9 2 9.7l6.8-5.3z"/><path fill="#EA4335" d="M24 12.5c3.2 0 5.3 1.4 6.6 2.5l5.8-5.7C33 5.9 28.4 4 24 4 15.7 4 8.5 8.8 5 15.1l6.8 5.3C13.5 15.3 18.3 12.5 24 12.5z"/></svg>
+        Continue with Google
+      </button>
+      <span class="soon">coming soon · standard protection is active now</span>
+    </div>
+
+    <div class="foot">
+      New client, or no website yet? <a href="/intake">Start the intake questionnaire →</a>
+    </div>
+  </div>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..a5f3ba6
--- /dev/null
+++ b/server.js
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * Designer Wallcoverings — Consulting portal.
+ * Generated by the `consulting` skill (fuses fantasea-consulting + prestige-car-wash).
+ *
+ * Routes:
+ *   GET  /              -> signin.html   (UNGATED) — client info + credentials
+ *   GET  /intake        -> intake.html   (UNGATED) — new-client questionnaire
+ *   POST /api/intake    -> append to data/intakes.json (UNGATED submit)
+ *   GET  /api/health    -> {ok:true}     (UNGATED)
+ *   GET  /portal        -> portal.html   (GATED) — the consulting deliverable
+ *   GET  /admin         -> admin/index.html (GATED) — integrated-social command center
+ *   GET  /api/admin/:bucket -> data/<bucket>.json (GATED read)
+ *   POST /api/admin/:bucket -> overwrite data/<bucket>.json (GATED write)
+ */
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+try { require('dotenv').config(); } catch { /* dotenv optional */ }
+
+const app = express();
+app.use(express.json({ limit: '1mb' }));
+const PORT = process.env.PORT || 9702;
+const PUB = path.join(__dirname, 'public');
+const DATA = path.join(__dirname, 'data');
+
+// ---- credentials -----------------------------------------------------------
+// Unified fleet login + this client's own login. Extra pairs via BASIC_AUTH_EXTRA
+// (comma-separated user:pass). These same creds are shown on the sign-in page.
+const CREDS = ['admin:DW2024!', 'Steve:Design2026!']
+  .concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map((s) => s.trim()).filter(Boolean));
+const ACCEPTED = new Set(CREDS.map((c) => 'Basic ' + Buffer.from(c).toString('base64')));
+function gate(req, res, next) {
+  if (ACCEPTED.has(req.headers.authorization || '')) return next();
+  res.set('WWW-Authenticate', 'Basic realm="Designer Wallcoverings Portal"');
+  return res.status(401).send('Authentication required');
+}
+
+// ---- data helpers ----------------------------------------------------------
+const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
+const writeJSON = (f, v) => fs.writeFileSync(path.join(DATA, f), JSON.stringify(v, null, 2));
+const BUCKETS = ['client', 'socials', 'suggestions', 'competitors', 'best-times', 'content-calendar', 'ads', 'directories', 'media', 'intakes'];
+
+// ---- ungated routes --------------------------------------------------------
+app.get('/', (_req, res) => res.sendFile(path.join(PUB, 'signin.html')));
+app.get('/intake', (_req, res) => res.sendFile(path.join(PUB, 'intake.html')));
+app.get('/api/health', (_req, res) => res.json({ ok: true, client: 'Designer Wallcoverings', at: new Date().toISOString() }));
+app.get('/api/client', (_req, res) => res.json(readJSON('client.json', {}))); // public-safe summary for signin page
+app.post('/api/intake', (req, res) => {
+  const all = readJSON('intakes.json', []);
+  all.push({ received_at: new Date().toISOString(), source: 'form', intake: req.body || {} });
+  writeJSON('intakes.json', all);
+  res.json({ ok: true, message: 'Thanks — your consulting intake was received.' });
+});
+
+// ---- gated routes ----------------------------------------------------------
+app.get('/portal', gate, (_req, res) => res.sendFile(path.join(PUB, 'portal.html')));
+app.get('/admin', gate, (_req, res) => res.sendFile(path.join(PUB, 'admin', 'index.html')));
+app.get('/api/admin/:bucket', gate, (req, res) => {
+  if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
+  res.json(readJSON(req.params.bucket + '.json', []));
+});
+app.post('/api/admin/:bucket', gate, (req, res) => {
+  if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
+  writeJSON(req.params.bucket + '.json', req.body);
+  res.json({ ok: true });
+});
+
+// gated static (portal assets + generated version concepts)
+app.use('/portal', gate, express.static(path.join(PUB), { extensions: ['html'] }));
+app.use('/versions', gate, express.static(path.join(PUB, 'versions'), { extensions: ['html'] }));
+app.use('/admin', gate, express.static(path.join(PUB, 'admin'), { extensions: ['html'] }));
+// ungated static (only the two entry pages + shared img)
+app.use('/img', express.static(path.join(PUB, 'img')));
+
+app.listen(PORT, () => console.log('Designer Wallcoverings consulting portal on ' + PORT));

(oldest)  ·  back to Consulting Designerwallcoverings Com  ·  DW intake scaffold on :9702 with verified auth gates cb206b6 →