[object Object]

← back to Consulting Intake

scaffold consulting portal for Consulting Intake

eb9a6247a05b7b991481d751eebabbc61cd23219 · 2026-07-17 07:56:06 -0700 · Steve

Files touched

Diff

commit eb9a6247a05b7b991481d751eebabbc61cd23219
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 17 07:56:06 2026 -0700

    scaffold consulting portal for Consulting Intake
---
 .deploy.conf               |  10 ++++
 .gitignore                 |  10 ++++
 build.mjs                  |  57 ++++++++++++++++++++
 data/ads.json              |   1 +
 data/best-times.json       |  16 ++++++
 data/client.json           |  19 +++++++
 data/competitors.json      |   1 +
 data/content-calendar.json |   1 +
 data/directories.json      |  18 +++++++
 data/intakes.json          |  21 ++++++++
 data/media.json            |   1 +
 data/socials.json          |  12 +++++
 data/suggestions.json      |  32 ++++++++++++
 package.json               |  17 ++++++
 public/admin/index.html    |  67 ++++++++++++++++++++++++
 public/intake.html         | 126 +++++++++++++++++++++++++++++++++++++++++++++
 public/portal.html         |  80 ++++++++++++++++++++++++++++
 public/signin.html         |  68 ++++++++++++++++++++++++
 server.js                  |  76 +++++++++++++++++++++++++++
 19 files changed, 633 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..dc1d9e5
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,10 @@
+# Consulting Intake consulting portal — deploy config (~/Projects/_shared/scripts/deploy.sh)
+# NOTE: deploy to Kamatera + DNS are Steve-gated — do NOT run autonomously.
+PROJECT_NAME=consulting-intake
+DEPLOY_HOST=45.61.58.125
+DEPLOY_PATH=/root/public-projects/consulting-intake
+HEALTH_URL=http://localhost:9700/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..d99dac7
--- /dev/null
+++ b/build.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+// Consulting Intake — 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..be05bd2
--- /dev/null
+++ b/data/client.json
@@ -0,0 +1,19 @@
+{
+  "business_name": "Consulting Intake",
+  "industry": "Website + integrated-social growth for local businesses",
+  "location": "",
+  "contact": {
+    "name": "",
+    "email": "",
+    "phone": ""
+  },
+  "current_website": null,
+  "tagline": "Tell us about your business — we'll build your new website and social presence.",
+  "goals": [
+    "Collect new-client intakes that spawn a website + social growth plan",
+    "Showcase the concept-version approach (several high-end directions to pick from)",
+    "Front door for the Consulting engine"
+  ],
+  "competitors": [],
+  "generated_at": "2026-07-17"
+}
\ No newline at end of file
diff --git a/data/competitors.json b/data/competitors.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/competitors.json
@@ -0,0 +1 @@
+[]
\ 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..52cc2f2
--- /dev/null
+++ b/data/intakes.json
@@ -0,0 +1,21 @@
+[
+  {
+    "received_at": "2026-07-17T14:56:06.152Z",
+    "source": "scaffold",
+    "intake": {
+      "slug": "intake",
+      "business_name": "Consulting Intake",
+      "industry": "Website + integrated-social growth for local businesses",
+      "location": "",
+      "tagline": "Tell us about your business — we'll build your new website and social presence.",
+      "current_website": null,
+      "no_website": true,
+      "goals": [
+        "Collect new-client intakes that spawn a website + social growth plan",
+        "Showcase the concept-version approach (several high-end directions to pick from)",
+        "Front door for the Consulting engine"
+      ],
+      "aesthetic": "Modern / minimal"
+    }
+  }
+]
\ 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..6075deb
--- /dev/null
+++ b/data/socials.json
@@ -0,0 +1,12 @@
+{
+  "instagram": null,
+  "facebook": null,
+  "tiktok": null,
+  "youtube": null,
+  "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..6559e41
--- /dev/null
+++ b/data/suggestions.json
@@ -0,0 +1,32 @@
+[
+  {
+    "id": "sug-1",
+    "title": "Collect new-client intakes that spawn a website + social growth plan",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-17T14:56:06.151Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-2",
+    "title": "Showcase the concept-version approach (several high-end directions to pick from)",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-17T14:56:06.151Z",
+    "source": "intake"
+  },
+  {
+    "id": "sug-3",
+    "title": "Front door for the Consulting engine",
+    "category": "Growth",
+    "impact": 4,
+    "effort": 2,
+    "rationale": "Seeded from the client intake goals — refine with market research.",
+    "created_at": "2026-07-17T14:56:06.151Z",
+    "source": "intake"
+  }
+]
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9d97c06
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "consulting-intake",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Consulting Intake — 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..d6ac709
--- /dev/null
+++ b/public/admin/index.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>Consulting Intake · 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>Consulting Intake — Command Center</h1></div>
+    <div><a href="/portal">← Deliverable</a> &nbsp; <a href="/">Sign-in</a></div>
+  </header>
+
+  <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..305aaf3
--- /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 · Consulting Intake</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..01c6a6e
--- /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>Consulting Intake · 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">Consulting Intake</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">Consulting Intake</h1>
+    <p id="tag">Tell us about your business — we'll build your new website and social presence.</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 = ['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..3cd570c
--- /dev/null
+++ b/public/signin.html
@@ -0,0 +1,68 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Consulting Intake · 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>Consulting Intake</h1>
+    <p class="sub">Tell us about your business — we'll build your new website and social presence.</p>
+
+    <div class="grid" id="meta">
+      <div><b>Industry</b>Website + integrated-social growth for local businesses</div>
+      <div><b>Location</b></div>
+      <div><b>Current Site</b>none yet</div>
+      <div><b>Engagement Opened</b>2026-07-17</div>
+      <div><b>Primary Contact</b></div>
+      <div><b>Reach</b> · </div>
+    </div>
+
+    <!-- Steve's standing rule: the initial sign-in page CARRIES the credentials. -->
+    <div class="creds">
+      <div class="row"><span class="k">Portal URL</span><span>/portal &amp; /admin</span></div>
+      <div class="row"><span class="k">Client login</span><span>client / Consul2026!</span></div>
+      <div class="row"><span class="k">Team login</span><span>admin / DW2024!</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 — use the credentials above for now">
+        <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..d39194c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * Consulting Intake — 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 || 9700;
+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!', 'client:Consul2026!']
+  .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="Consulting Intake 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: 'Consulting Intake', 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('Consulting Intake consulting portal on ' + PORT));

(oldest)  ·  back to Consulting Intake  ·  Consulting Intake template home — public questionnaire front cc8c413 →