[object Object]

← back to Small Business Builder

yolo: iter 3 trivial patches from claude-codex debate

035afe281ea0e119a5958faf33ebf54cd9ada7cc · 2026-05-04 10:20:20 -0700 · yolo

Files touched

Diff

commit 035afe281ea0e119a5958faf33ebf54cd9ada7cc
Author: yolo <yolo@local>
Date:   Mon May 4 10:20:20 2026 -0700

    yolo: iter 3 trivial patches from claude-codex debate
---
 src/lib/interview-questions.js | 199 ++++++++++++++++++++
 src/render/home.js             |   1 +
 src/render/profile.js          | 405 +++++++++++++++++++++++++++++++++++++++++
 src/server/index.js            | 109 ++++++++++-
 4 files changed, 713 insertions(+), 1 deletion(-)

diff --git a/src/lib/interview-questions.js b/src/lib/interview-questions.js
new file mode 100644
index 0000000..3c679ae
--- /dev/null
+++ b/src/lib/interview-questions.js
@@ -0,0 +1,199 @@
+// Interview question bank for shop owner profiles.
+//
+// Goals (in priority order):
+//   1. Capture the owner's voice — the thing Booksy/Yelp can never replicate.
+//   2. Name nearby businesses (restaurants, coffee, bars) — long-tail local SEO.
+//      "best coffee near Fellow Barber" / "where to eat after a haircut in Silver Lake"
+//   3. Surface neighborhood-specific knowledge — turns each profile into a mini
+//      neighborhood guide, which is exactly what visitors are actually searching for.
+//   4. Be FUN. Owners hate filling out forms. These should feel like a magazine
+//      profile interview, not a customer-onboarding wizard.
+//
+// Sections group questions visually on both the admin form and the public profile.
+//
+// Each question:
+//   - id          — stable key, used in the JSONB blob (don't rename casually)
+//   - section     — 'story' | 'craft' | 'neighborhood' | 'fun'
+//   - q           — the prompt the owner sees
+//   - placeholder — short example to unblock writer's block
+//   - seo_intent  — one-line note on which search query this question is fishing for
+//                   (we don't show this to the owner; it's a comment for us)
+//   - max_chars   — soft limit; the form lets them go over but warns
+
+export const QUESTIONS = [
+  // ----- THE STORY -------------------------------------------------------
+  {
+    id: 'origin_story',
+    section: 'story',
+    q: "What's the story? How did this place come to be?",
+    placeholder: "We took over this space in 2014 after the original owner retired. The chairs are still original from 1962…",
+    seo_intent: "captures historical/legacy queries; '[shop] history', 'oldest barber [neighborhood]'",
+    max_chars: 600,
+  },
+  {
+    id: 'who_walks_in',
+    section: 'story',
+    q: "Who's the person who walks in here? Describe your regulars.",
+    placeholder: "A lot of post-production sound editors from the studios up the street, some Eastsider creatives, and the occasional kid getting his first real haircut…",
+    seo_intent: "demographic match-making; people self-identify against the description",
+    max_chars: 400,
+  },
+  {
+    id: 'one_thing_known_for',
+    section: 'story',
+    q: "What's the one thing — service, cut, treatment — you're known for?",
+    placeholder: "The hot-towel straight razor shave. We're one of three shops in LA still using a real strop…",
+    seo_intent: "service-specific search; '[service] in [neighborhood]'",
+    max_chars: 300,
+  },
+
+  // ----- THE CRAFT -------------------------------------------------------
+  {
+    id: 'philosophy',
+    section: 'craft',
+    q: "Your philosophy in one sentence — what do you stand for?",
+    placeholder: "Old-school respect, modern technique, and we'll never rush you out the door.",
+    seo_intent: "brand voice signal — used for opening editorial blockquote",
+    max_chars: 180,
+  },
+  {
+    id: 'mistake_clients_make',
+    section: 'craft',
+    q: "What's the most common thing people get wrong when they walk in?",
+    placeholder: "They show me a photo of a 25-year-old with totally different hair texture. I'd rather know YOUR hair than copy someone else's…",
+    seo_intent: "expertise-positioning; reads like an advice column, surfaces in 'how to ask for a haircut' searches",
+    max_chars: 400,
+  },
+  {
+    id: 'what_takes_pride',
+    section: 'craft',
+    q: "What's a small detail you take pride in that most clients never notice?",
+    placeholder: "I sterilize the comb between every client. Most shops just use a Barbicide jar for show…",
+    seo_intent: "trust signal; 'clean barber [neighborhood]', hygiene-aware searches",
+    max_chars: 300,
+  },
+
+  // ----- THE NEIGHBORHOOD ------------------------------------------------
+  // These are the SEO workhorses. Owners naming actual local businesses by
+  // name lights up local-graph queries we'd never rank for organically.
+  {
+    id: 'fav_restaurant',
+    section: 'neighborhood',
+    q: "Favorite restaurant within walking distance — where do you actually eat?",
+    placeholder: "Night + Market Song on Sunset. The pad see ew is dangerous. Tell Justin we sent you.",
+    seo_intent: "WORKHORSE — 'restaurants near [shop]', '[restaurant] [neighborhood]' both reverse-link here",
+    max_chars: 280,
+  },
+  {
+    id: 'best_coffee',
+    section: 'neighborhood',
+    q: "Best coffee in the neighborhood? Be honest, not diplomatic.",
+    placeholder: "Maru in Silver Lake. The hojicha latte is unreal. Don't tell them I sent you, they're already too busy.",
+    seo_intent: "WORKHORSE — 'best coffee [neighborhood]' is one of the highest-volume local searches",
+    max_chars: 280,
+  },
+  {
+    id: 'after_the_chair',
+    section: 'neighborhood',
+    q: "Someone just got off your chair, has 30 minutes to kill — where do they go?",
+    placeholder: "Walk down to Sunset Junction, grab a drink at Bar Stella, sit outside and watch the neighborhood pass. Or hit Skylight Books across the street.",
+    seo_intent: "high-intent capture — 'things to do near [shop]', the literal moment the user is reading our page",
+    max_chars: 400,
+  },
+  {
+    id: 'date_night',
+    section: 'neighborhood',
+    q: "Where do you take a date in this neighborhood?",
+    placeholder: "Dinner at Botanica, drinks at the bar at Speranza after. Both BYO-good-conversation.",
+    seo_intent: "'[neighborhood] date night', 'romantic restaurants [neighborhood]' — high-conversion local searches",
+    max_chars: 280,
+  },
+  {
+    id: 'happy_hour',
+    section: 'neighborhood',
+    q: "Best happy hour walking distance from the shop?",
+    placeholder: "Bar Henry, 4-7pm. $8 martinis, $1 oysters Tuesdays. They know me.",
+    seo_intent: "happy-hour searches are insanely high-volume in LA",
+    max_chars: 280,
+  },
+  {
+    id: 'hidden_gem',
+    section: 'neighborhood',
+    q: "What's a hidden gem in the neighborhood — a place tourists don't know about?",
+    placeholder: "The little Cuban window at Mama's Hot Tamales on Tuesday afternoons. No sign. Cash only. You'll figure it out.",
+    seo_intent: "'hidden gems [neighborhood]' — listicle-bait, picks up linkbacks",
+    max_chars: 320,
+  },
+  {
+    id: 'piece_of_history',
+    section: 'neighborhood',
+    q: "Tell me a piece of [neighborhood] history nobody else knows.",
+    placeholder: "This whole block used to be a streetcar yard. The terrazzo floors in our shop are from a dance hall that was here in 1948.",
+    seo_intent: "'[neighborhood] history' — magnet for local-history-curious traffic; great for inbound links",
+    max_chars: 500,
+  },
+
+  // ----- FOR FUN ---------------------------------------------------------
+  {
+    id: 'overrated_underrated',
+    section: 'fun',
+    q: "What's overrated in this neighborhood? What's underrated?",
+    placeholder: "Overrated: brunch lines on Sunday. Underrated: the empanadas at the gas station on Glendale.",
+    seo_intent: "voicey content; surfaces as quote in editorial roundups",
+    max_chars: 320,
+  },
+  {
+    id: 'soundtrack',
+    section: 'fun',
+    q: "What's playing in the shop right now?",
+    placeholder: "Today: D'Angelo, Voodoo. Always: anything but the radio.",
+    seo_intent: "vibe signal; humanizes the profile",
+    max_chars: 200,
+  },
+  {
+    id: 'best_thing_heard',
+    section: 'fun',
+    q: "Best thing a client ever said to you in this chair?",
+    placeholder: "An older guy tipped me 50 bucks and said 'this haircut got me my divorce settlement.' Made my year.",
+    seo_intent: "social-share-bait; this is the quote that ends up screenshotted on Instagram",
+    max_chars: 400,
+  },
+  {
+    id: 'if_not_this',
+    section: 'fun',
+    q: "If you weren't doing this, what would you be doing?",
+    placeholder: "Probably running a record store, badly.",
+    seo_intent: "human-interest tail; closes the profile warmly",
+    max_chars: 200,
+  },
+];
+
+// Section metadata for layout.
+export const SECTIONS = {
+  story:        { label: 'The Story',        order: 1 },
+  craft:        { label: 'The Craft',        order: 2 },
+  neighborhood: { label: 'The Neighborhood', order: 3 },
+  fun:          { label: 'For Fun',          order: 4 },
+};
+
+// Group questions by section for rendering.
+export function questionsBySection() {
+  const out = {};
+  for (const q of QUESTIONS) {
+    (out[q.section] = out[q.section] || []).push(q);
+  }
+  // Return in section order
+  return Object.entries(SECTIONS)
+    .sort((a, b) => a[1].order - b[1].order)
+    .map(([key, meta]) => ({ key, ...meta, questions: out[key] || [] }));
+}
+
+// Apply per-shop substitutions to question text (e.g., "[neighborhood]" → "Silver Lake").
+export function localizeQuestion(q, business) {
+  const hood = business?.neighborhood || 'the neighborhood';
+  return {
+    ...q,
+    q: q.q.replace(/\[neighborhood\]/g, hood),
+    placeholder: q.placeholder.replace(/\[neighborhood\]/g, hood),
+  };
+}
diff --git a/src/render/home.js b/src/render/home.js
index 8ba0b51..e2cae90 100644
--- a/src/render/home.js
+++ b/src/render/home.js
@@ -131,6 +131,7 @@ export function renderHome({ featuredBarbers = [], otherShops = [], neighborhood
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
 <style>
 :root {
   --ink:#1a1a1a;
diff --git a/src/render/profile.js b/src/render/profile.js
new file mode 100644
index 0000000..c6700b5
--- /dev/null
+++ b/src/render/profile.js
@@ -0,0 +1,405 @@
+// Editorial profile page for a single shop — /biz/:slug
+//
+// Replaces the per-business landing-page mockup as the public-facing slug.
+// (Mockups still live at /biz/:slug/template/:id.)
+//
+// Layout follows the design north-star: Kinfolk × Soho House. The interview
+// Q&A is the centerpiece — that's the editorial moat. Each Q&A pair gets
+// schema.org FAQ markup so Google indexes them as featured-snippet candidates.
+
+import { esc } from '../lib/escape.js';
+import { questionsBySection, localizeQuestion } from '../lib/interview-questions.js';
+
+const NEIGHBORHOOD_ACCENT = {
+  'Silver Lake':'#1f6f70','Echo Park':'#c95a2c','Koreatown':'#c9a23a','K-Town':'#c9a23a',
+  'Highland Park':'#7a3b3b','DTLA':'#3a3a3a','West Hollywood':'#b6395f','Venice':'#2c5d8a',
+  'Beverly Hills':'#8a7448','Hollywood':'#7a2530','Sherman Oaks':'#5a6e3a','Santa Monica':'#3a5a6e',
+  'Culver City':'#6e5a3a','Los Feliz':'#4a6e3a','Westwood':'#5a3a6e','West Adams':'#6e3a4a',
+  'Los Angeles':'#3a3a3a',
+};
+
+function neighborhoodSlug(n) {
+  return String(n || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+}
+
+const CATEGORY_LABEL = {
+  barbershop:'Barber & Beard', beard:'Barber & Beard', salon:'Hair', hair:'Hair',
+  nail:'Nails', nail_salon:'Nails', lash:'Lash', brow:'Brow', spa:'Spa',
+  blowout:'Blowout', waxing:'Waxing', generic:'Salon',
+};
+
+// Build the FAQ schema.org JSON-LD blob from filled answers.
+function faqJsonLd(filled) {
+  const mainEntity = filled.map(({ q, a }) => ({
+    '@type': 'Question',
+    name: q,
+    acceptedAnswer: { '@type': 'Answer', text: a },
+  }));
+  return JSON.stringify({
+    '@context': 'https://schema.org',
+    '@type': 'FAQPage',
+    mainEntity,
+  });
+}
+
+// Build local-business schema.org JSON-LD.
+function localBusinessJsonLd(b) {
+  const obj = {
+    '@context': 'https://schema.org',
+    '@type': 'HairSalon',  // closest schema.org type; covers barber/salon
+    name: b.name,
+    address: b.address ? {
+      '@type': 'PostalAddress',
+      streetAddress: b.address,
+      addressLocality: b.city || 'Los Angeles',
+      addressRegion: b.state || 'CA',
+      postalCode: b.zip || undefined,
+    } : undefined,
+    telephone: b.phone || undefined,
+    url: b.website || undefined,
+    image: b.hero_image_url || undefined,
+    description: b.about_text || undefined,
+  };
+  // Strip undefined keys for cleanliness
+  for (const k of Object.keys(obj)) if (obj[k] === undefined) delete obj[k];
+  return JSON.stringify(obj);
+}
+
+export function renderProfile({ business, interview, otherShopsInHood = [] } = {}) {
+  const b = business;
+  const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
+  const cat = CATEGORY_LABEL[b.category] || 'Salon';
+  const hoodSlug = neighborhoodSlug(b.neighborhood);
+  const qa = interview?.qa_json || {};
+  const sections = questionsBySection();
+
+  // Pull the "philosophy" answer up as the editorial pull-quote.
+  const philosophy = qa.philosophy || '';
+
+  // Filled answers, in section order, for FAQ schema.
+  const filled = [];
+  for (const sec of sections) {
+    for (const q of sec.questions) {
+      const a = (qa[q.id] || '').trim();
+      if (a) filled.push({ q: localizeQuestion(q, b).q, a });
+    }
+  }
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(b.name)} · ${esc(b.neighborhood || 'Los Angeles')} · LA Salon Directory</title>
+<meta name="description" content="${esc((b.about_text || philosophy || `${b.name} — ${cat} in ${b.neighborhood || 'Los Angeles'}`).slice(0, 160))}">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
+<script type="application/ld+json">${localBusinessJsonLd(b)}</script>
+${filled.length ? `<script type="application/ld+json">${faqJsonLd(filled)}</script>` : ''}
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:${accent};
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased}
+a{color:inherit;text-decoration:none}
+img{display:block;max-width:100%;height:auto}
+
+.topbar{padding:22px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);position:sticky;top:0;background:var(--paper);z-index:50}
+.brand{font-family:var(--serif);font-size:22px;letter-spacing:-.01em;font-weight:500}
+.brand em{font-style:italic;color:var(--accent)}
+.nav-mini{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);display:flex;gap:24px}
+.nav-mini a:hover{color:var(--ink)}
+
+/* HERO ===================================================== */
+.hero{padding:60px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
+.crumb{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}
+.crumb a{color:var(--accent)}
+.hero-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:48px;align-items:end}
+.hero-meta .cat{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px;display:inline-block}
+.hero-meta h1{font-family:var(--serif);font-weight:500;font-size:clamp(40px,6vw,84px);line-height:.98;letter-spacing:-.02em;margin-bottom:24px}
+.hero-meta .loc{font-family:var(--serif);font-style:italic;font-size:24px;color:var(--mute);margin-bottom:32px}
+.hero-meta .loc a{color:var(--ink);border-bottom:1px solid var(--accent);padding-bottom:1px}
+.hero-stats{display:flex;gap:32px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);flex-wrap:wrap}
+.hero-stats b{display:block;font-family:var(--serif);font-style:italic;font-size:24px;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}
+.hero-img{aspect-ratio:4/5;background:#222;overflow:hidden;position:relative}
+.hero-img img{width:100%;height:100%;object-fit:cover}
+.hero-img-placeholder{position:absolute;inset:0;background:linear-gradient(135deg,var(--ink) 0%,#2a2a2a 100%);display:flex;align-items:center;justify-content:center}
+.hero-img-placeholder::before{content:"";width:1px;height:36%;background:var(--accent)}
+
+/* PHILOSOPHY PULL ========================================== */
+.pull{padding:80px 32px;max-width:880px;margin:0 auto;text-align:center}
+.pull p{font-family:var(--serif);font-style:italic;font-size:clamp(28px,3.5vw,44px);line-height:1.2;letter-spacing:-.01em;font-weight:400}
+.pull .sig{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--mute);margin-top:24px}
+
+/* INTERVIEW ================================================ */
+.interview{padding:48px 32px 80px;max-width:880px;margin:0 auto}
+.section-head{margin-bottom:28px;padding-bottom:14px;border-bottom:1px solid var(--rule)}
+.section-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(28px,3vw,40px);letter-spacing:-.015em}
+.section-head .kicker{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;display:block}
+
+.qa{margin-bottom:36px;padding-bottom:24px;border-bottom:1px dashed var(--rule)}
+.qa:last-child{border-bottom:none}
+.qa .q{font-family:var(--serif);font-weight:500;font-size:21px;line-height:1.25;letter-spacing:-.005em;margin-bottom:14px;color:var(--ink)}
+.qa .q::before{content:"Q.";font-family:var(--mono);font-size:11px;letter-spacing:.16em;color:var(--accent);margin-right:10px;vertical-align:1px}
+.qa .a{font-size:17px;line-height:1.6;color:#2a2a2a;padding-left:32px;border-left:2px solid var(--accent)}
+.qa .a::before{content:"";display:none}
+.qa-empty{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);font-style:italic;padding-left:32px}
+
+.no-interview{padding:60px 32px;max-width:880px;margin:0 auto;text-align:center;border:1px dashed var(--rule);background:#fff}
+.no-interview p{font-family:var(--serif);font-style:italic;font-size:22px;color:var(--mute);margin-bottom:18px}
+.no-interview a{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);border:1px solid var(--accent);padding:12px 20px;display:inline-block}
+.no-interview a:hover{background:var(--accent);color:var(--paper)}
+
+/* "MORE IN [HOOD]" RAIL ==================================== */
+.more{padding:60px 32px;max-width:1240px;margin:0 auto;border-top:1px solid var(--rule)}
+.more-head{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:32px;padding-bottom:14px;border-bottom:1px solid var(--rule)}
+.more-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(24px,2.5vw,34px);letter-spacing:-.01em}
+.more-head a{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
+.more-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px}
+.shop{display:block;color:var(--ink)}
+.shop-img{aspect-ratio:4/5;overflow:hidden;background:#222;margin-bottom:12px;position:relative}
+.shop-img-placeholder{position:absolute;inset:0;background:linear-gradient(135deg,var(--ink) 0%,#2a2a2a 100%)}
+.shop-cat{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:4px;display:inline-block}
+.shop-name{font-family:var(--serif);font-size:19px;font-weight:500;letter-spacing:-.01em;line-height:1.15}
+
+/* FOOTER =================================================== */
+.footer{padding:40px 32px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
+
+@media (max-width:880px){
+  .hero-grid{grid-template-columns:1fr;gap:32px}
+  .qa .a{padding-left:18px}
+  .interview,.pull,.more{padding-left:24px;padding-right:24px}
+  .topbar{padding:16px 20px}
+  .nav-mini{display:none}
+}
+</style>
+</head>
+<body>
+
+<header class="topbar">
+  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a></div>
+  <nav class="nav-mini">
+    <a href="/">Home</a>
+    ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : ''}
+    <a href="/biz/${esc(b.slug)}/template/1">Mockup</a>
+  </nav>
+</header>
+
+<section class="hero">
+  <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : ''}${esc(cat)}</div>
+  <div class="hero-grid">
+    <div class="hero-meta">
+      <span class="cat">${esc(cat)}</span>
+      <h1>${esc(b.name)}</h1>
+      <div class="loc">${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : ''}${esc(b.city || 'Los Angeles')}</div>
+      <div class="hero-stats">
+        ${b.years_in_business ? `<div>Established<b>${b.years_in_business} yrs</b></div>` : ''}
+        ${b.address ? `<div>Address<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0">${esc(b.address)}</b></div>` : ''}
+        ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc((new URL(b.website)).hostname.replace('www.',''))}</a></b></div>` : ''}
+      </div>
+    </div>
+    <div class="hero-img">
+      ${b.hero_image_url ? `<img src="${esc(b.hero_image_url)}" alt="${esc(b.name)}">` : `<div class="hero-img-placeholder"></div>`}
+    </div>
+  </div>
+</section>
+
+${philosophy ? `
+<section class="pull">
+  <p>"${esc(philosophy)}"</p>
+  <div class="sig">— ${esc(b.owner_name || b.name)}</div>
+</section>
+` : ''}
+
+${filled.length === 0 ? `
+<section class="no-interview">
+  <p>This profile is waiting for its interview.</p>
+  <a href="/biz/${esc(b.slug)}/interview">Add the interview →</a>
+</section>
+` : `
+<article class="interview" itemscope itemtype="https://schema.org/FAQPage">
+  ${sections.map(sec => {
+    const localQs = sec.questions.map(q => localizeQuestion(q, b));
+    const filledInSec = localQs.filter(q => (qa[q.id] || '').trim());
+    if (!filledInSec.length) return '';
+    return `
+    <div class="section-block">
+      <div class="section-head">
+        <span class="kicker">Section ${sec.order}</span>
+        <h2>${esc(sec.label)}</h2>
+      </div>
+      ${filledInSec.map(q => `
+        <div class="qa" itemscope itemprop="mainEntity" itemtype="https://schema.org/Question">
+          <div class="q" itemprop="name">${esc(q.q)}</div>
+          <div class="a" itemscope itemprop="acceptedAnswer" itemtype="https://schema.org/Answer">
+            <div itemprop="text">${esc(qa[q.id])}</div>
+          </div>
+        </div>
+      `).join('')}
+    </div>`;
+  }).join('')}
+  <div style="margin-top:32px;padding-top:18px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);text-align:right">
+    Recorded ${interview?.recorded_at ? new Date(interview.recorded_at).toLocaleDateString('en-US',{month:'long',year:'numeric'}) : ''}
+    ${interview?.recorded_by ? `· by ${esc(interview.recorded_by)}` : ''}
+  </div>
+</article>
+`}
+
+${otherShopsInHood.length ? `
+<section class="more">
+  <div class="more-head">
+    <h2>More in ${esc(b.neighborhood || 'Los Angeles')}</h2>
+    <a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>
+  </div>
+  <div class="more-grid">
+    ${otherShopsInHood.slice(0, 4).map(s => `
+      <a class="shop" href="/biz/${esc(s.slug)}">
+        <div class="shop-img">${s.hero_image_url ? `<img loading="lazy" src="${esc(s.hero_image_url)}" alt="${esc(s.name)}">` : `<div class="shop-img-placeholder"></div>`}</div>
+        <span class="shop-cat">${esc(CATEGORY_LABEL[s.category] || 'Salon')}</span>
+        <div class="shop-name">${esc(s.name)}</div>
+      </a>
+    `).join('')}
+  </div>
+</section>
+` : ''}
+
+<footer class="footer">
+  <span>LA Salon Directory · ${new Date().getFullYear()}</span>
+  <span><a href="/biz/${esc(b.slug)}/interview">Edit interview</a></span>
+  <span><a href="/admin">Admin</a></span>
+</footer>
+
+</body>
+</html>`;
+}
+
+
+// ===========================================================================
+// Interview-form admin page — /biz/:slug/interview
+// ===========================================================================
+
+export function renderInterviewForm({ business, interview }) {
+  const b = business;
+  const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
+  const qa = interview?.qa_json || {};
+  const sections = questionsBySection();
+
+  return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Interview · ${esc(b.name)}</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{
+  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:${accent};
+  --serif:"Cormorant Garamond",Georgia,serif;
+  --sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
+  --mono:ui-monospace,"SF Mono",Menlo,monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400}
+a{color:var(--accent)}
+.wrap{max-width:780px;margin:0 auto;padding:48px 32px 120px}
+.crumb{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:24px}
+h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em;margin-bottom:8px;line-height:1}
+.sub{color:var(--mute);font-size:15px;margin-bottom:36px}
+.section-head{margin:48px 0 18px;padding-bottom:10px;border-bottom:1px solid var(--rule)}
+.section-head h2{font-family:var(--serif);font-weight:500;font-size:26px;letter-spacing:-.01em}
+.section-head .kicker{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);display:block;margin-bottom:4px}
+.qa{margin-bottom:24px}
+.qa label{display:block;font-family:var(--serif);font-weight:500;font-size:18px;line-height:1.3;margin-bottom:8px}
+.qa label::before{content:"Q.";font-family:var(--mono);font-size:11px;letter-spacing:.16em;color:var(--accent);margin-right:8px;vertical-align:1px}
+.qa textarea{width:100%;min-height:90px;padding:14px 16px;background:#fff;border:1px solid var(--rule);border-radius:4px;font-family:var(--sans);font-size:15px;line-height:1.55;resize:vertical;color:var(--ink)}
+.qa textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(31,111,112,.12)}
+.qa .hint{font-family:var(--mono);font-size:11px;color:var(--mute);margin-top:4px;line-height:1.4;letter-spacing:.02em}
+.byline{margin:24px 0 32px;padding:14px 16px;background:#fff;border:1px solid var(--rule);border-radius:4px}
+.byline label{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);display:block;margin-bottom:6px}
+.byline input{width:100%;border:0;font-family:var(--serif);font-size:18px;color:var(--ink)}
+.byline input:focus{outline:none}
+.bar{position:fixed;bottom:0;left:0;right:0;padding:14px 32px;background:var(--ink);color:var(--paper);display:flex;justify-content:space-between;align-items:center;font-family:var(--mono);font-size:12px;letter-spacing:.12em;text-transform:uppercase;z-index:50}
+.bar .saved{opacity:.6;font-style:italic;text-transform:none;letter-spacing:0;font-family:var(--serif);font-size:14px}
+.bar button{padding:11px 24px;background:var(--accent);color:var(--paper);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-weight:500}
+.bar button:hover{background:#fff;color:var(--ink)}
+.bar a{color:var(--paper);opacity:.6}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <div class="crumb"><a href="/">Directory</a> · <a href="/biz/${esc(b.slug)}">${esc(b.name)}</a> · Interview</div>
+  <h1>Interview</h1>
+  <p class="sub">Capture ${esc(b.name)}'s voice. The questions are designed to surface the kind of detail that turns a directory listing into something readers actually share. Skip any question that doesn't fit — better to leave blank than to write filler.</p>
+
+  <form id="form" method="POST" action="/api/biz/interview">
+    <input type="hidden" name="slug" value="${esc(b.slug)}">
+
+    <div class="byline">
+      <label>Recorded by</label>
+      <input name="recorded_by" placeholder="Your name (optional)" value="${esc(interview?.recorded_by || '')}">
+    </div>
+
+    ${sections.map(sec => `
+      <div class="section-head">
+        <span class="kicker">Section ${sec.order}</span>
+        <h2>${esc(sec.label)}</h2>
+      </div>
+      ${sec.questions.map(q => {
+        const lq = localizeQuestion(q, b);
+        const v = qa[q.id] || '';
+        return `
+        <div class="qa">
+          <label for="q_${esc(q.id)}">${esc(lq.q)}</label>
+          <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${q.max_chars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
+          <div class="hint">~${q.max_chars} chars · ${esc(q.seo_intent)}</div>
+        </div>`;
+      }).join('')}
+    `).join('')}
+
+  </form>
+
+  <div class="bar">
+    <span class="saved" id="status">Edit anywhere — save when ready.</span>
+    <span>
+      <a href="/biz/${esc(b.slug)}">View profile</a>
+      &nbsp;&nbsp;
+      <button type="submit" form="form">Save interview</button>
+    </span>
+  </div>
+</div>
+
+<script>
+const form = document.getElementById('form');
+const status = document.getElementById('status');
+form.addEventListener('submit', async (e) => {
+  e.preventDefault();
+  status.textContent = 'Saving…';
+  const data = new FormData(form);
+  const obj = { slug: data.get('slug'), recorded_by: data.get('recorded_by') || '', qa: {} };
+  for (const [k, v] of data.entries()) {
+    if (k.startsWith('qa_')) obj.qa[k.slice(3)] = (v || '').trim();
+  }
+  const res = await fetch('/api/biz/interview', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(obj),
+  });
+  const r = await res.json();
+  if (r.ok) {
+    status.textContent = 'Saved · redirecting to profile…';
+    setTimeout(() => location.href = '/biz/' + encodeURIComponent(obj.slug), 700);
+  } else {
+    status.textContent = 'Error: ' + (r.error || 'unknown');
+  }
+});
+</script>
+</body>
+</html>`;
+}
diff --git a/src/server/index.js b/src/server/index.js
index 5fa12fe..56afb79 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -27,6 +27,8 @@ import { slugify } from '../lib/slug.js';
 import { log } from '../lib/log.js';
 import { renderGrid } from '../render/grid.js';
 import { renderEdit } from '../render/edit.js';
+import { renderHome } from '../render/home.js';
+import { renderProfile, renderInterviewForm } from '../render/profile.js';
 import { renderTemplate } from '../render/templates/index.js';
 import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
 import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
@@ -74,7 +76,32 @@ app.get('/healthz', async (_req, res) => {
   }
 });
 
+// Public editorial homepage — neighborhood-led, barber/beard featured first.
 app.get('/', async (_req, res) => {
+  const all = await many(
+    `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
+     FROM businesses
+     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC`
+  );
+  const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
+  const featuredBarbers = all.filter(isBarber).slice(0, 8);
+  const featuredIds = new Set(featuredBarbers.map(b => b.slug));
+  const otherShops = all.filter(b => !featuredIds.has(b.slug)).slice(0, 24);
+
+  // neighborhood counts
+  const hoodMap = new Map();
+  for (const b of all) {
+    if (!b.neighborhood) continue;
+    hoodMap.set(b.neighborhood, (hoodMap.get(b.neighborhood) || 0) + 1);
+  }
+  const neighborhoods = [...hoodMap.entries()].map(([neighborhood, count]) => ({ neighborhood, count }));
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, totalCount: all.length }));
+});
+
+// Original mockup-grid (admin/builder UI) lives at /builder now.
+app.get('/builder', async (_req, res) => {
   const businesses = await many(
     `SELECT slug,name,category,city,state,tier,hero_image_url,color_primary,color_accent
      FROM businesses ORDER BY updated_at DESC LIMIT 12`
@@ -83,11 +110,91 @@ app.get('/', async (_req, res) => {
   res.send(renderGrid({ businesses }));
 });
 
+// Neighborhood detail page — list the shops in one LA neighborhood, editorially.
+app.get('/n/:slug', async (req, res) => {
+  const slug = String(req.params.slug || '').toLowerCase();
+  // resolve slug → neighborhood name (case-insensitive, dash → space)
+  const all = await many(
+    `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
+     FROM businesses WHERE neighborhood IS NOT NULL
+     ORDER BY ranking_score DESC NULLS LAST`
+  );
+  const match = all.find(b => b.neighborhood &&
+    b.neighborhood.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
+  if (!match) return res.status(404).send('Neighborhood not found');
+  const neighborhood = match.neighborhood;
+  const shops = all.filter(b => b.neighborhood === neighborhood);
+  const otherHoods = [...new Set(all.map(b => b.neighborhood))].filter(n => n !== neighborhood);
+  // We render via renderHome with featured=barbers-in-this-hood + other=rest, slightly repurposed.
+  const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
+  const featuredBarbers = shops.filter(isBarber);
+  const featuredIds = new Set(featuredBarbers.map(b => b.slug));
+  const otherShops = shops.filter(b => !featuredIds.has(b.slug));
+  const neighborhoods = otherHoods.map(n => ({
+    neighborhood: n, count: all.filter(b => b.neighborhood === n).length,
+  }));
+
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderHome({
+    featuredBarbers, otherShops, neighborhoods,
+    totalCount: shops.length,
+  }));
+});
+
+// Editorial profile — interview Q&A is the centerpiece.
+// (Mockup landing-pages still live at /biz/:slug/template/:id.)
 app.get('/biz/:slug', async (req, res) => {
   const b = await loadBySlug(req.params.slug);
   if (!b) return res.status(404).send('Not found');
+  const interview = await one(
+    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
+     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
+  );
+  const otherShopsInHood = b.neighborhood ? await many(
+    `SELECT slug,name,category,neighborhood,hero_image_url FROM businesses
+     WHERE neighborhood=$1 AND id<>$2
+     ORDER BY ranking_score DESC NULLS LAST, updated_at DESC LIMIT 4`,
+    [b.neighborhood, b.id]
+  ) : [];
+  res.set('content-type', 'text/html; charset=utf-8');
+  res.send(renderProfile({ business: b, interview, otherShopsInHood }));
+});
+
+// Interview admin form — fill in the Q&A.
+app.get('/biz/:slug/interview', async (req, res) => {
+  const b = await loadBySlug(req.params.slug);
+  if (!b) return res.status(404).send('Not found');
+  const interview = await one(
+    `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
+     WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
+  );
   res.set('content-type', 'text/html; charset=utf-8');
-  res.send(renderTemplate(1, b));
+  res.send(renderInterviewForm({ business: b, interview }));
+});
+
+// Save (newest-wins; we keep history rows).
+app.post('/api/biz/interview', async (req, res) => {
+  try {
+    const { slug, qa, recorded_by } = req.body || {};
+    if (!slug || !qa || typeof qa !== 'object') return res.status(400).json({ error: 'slug + qa required' });
+    const b = await loadBySlug(slug);
+    if (!b) return res.status(404).json({ error: 'business not found' });
+    // Strip empty answers so the JSONB stays clean.
+    const cleanQa = {};
+    for (const [k, v] of Object.entries(qa)) {
+      const s = String(v || '').trim();
+      if (s) cleanQa[k] = s.slice(0, 4000);
+    }
+    await query(
+      `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
+      [b.id, JSON.stringify(cleanQa), (recorded_by || '').slice(0, 120) || null]
+    );
+    log.info('interview saved', { slug, fields: Object.keys(cleanQa).length });
+    res.json({ ok: true, slug, fields: Object.keys(cleanQa).length });
+  } catch (e) {
+    log.err('interview save failed', { error: String(e.message || e) });
+    res.status(500).json({ error: String(e.message || e) });
+  }
 });
 
 app.get('/biz/:slug/edit', async (req, res) => {

← 980062b yolo: iter 2 trivial patches from claude-codex debate  ·  back to Small Business Builder  ·  [morning-review] small-business-builder: harden public profi b789cdc →