← 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
A src/lib/interview-questions.jsM src/render/home.jsA src/render/profile.jsM src/server/index.js
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>
+
+ <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 →