← back to Small Business Builder

src/render/profile.js

591 lines

// 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, escJsonLd } 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',
};

// (escJsonLd lives in src/lib/escape.js — single source of truth.)

// Safely extract a website hostname; the DB may contain bare domains like "foo.com"
// without a scheme, which would cause `new URL(...)` to throw.
function safeHostname(raw) {
  if (!raw) return '';
  const s = String(raw).trim();
  try {
    return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`).hostname.replace(/^www\./, '');
  } catch {
    return s.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('/')[0];
  }
}

// Return a safe http(s) URL for use in href, or '' if scheme is not allowed.
// Without this, a stored `javascript:` URL would render as a clickable XSS.
function safeHref(raw) {
  if (!raw) return '';
  const s = String(raw).trim();
  if (!s) return '';
  // No scheme → assume https so bare-domain entries still link.
  if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return `https://${s}`;
  return /^https?:\/\//i.test(s) ? s : '';
}

// 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: safeHref(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);
}

// Map a platform key to a display label + icon glyph. Single source of
// truth so the social row stays consistent with /biz/:slug/links UX.
const PLATFORM_LABEL = {
  instagram:'Instagram', tiktok:'TikTok', facebook:'Facebook', x:'X', twitter:'X',
  bluesky:'Bluesky', pinterest:'Pinterest', yelp:'Yelp', threads:'Threads',
  youtube:'YouTube', linkedin:'LinkedIn', website:'Website',
};

export function renderProfile({ business, interview, otherShopsInHood = [], socials = [], articles = [], reviewSources = [] } = {}) {
  const b = business;
  const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
  const cat = CATEGORY_LABEL[b.category] || 'Salon';
  const hoodSlug = neighborhoodSlug(b.neighborhood);
  // qa_json may legitimately come back as either a JS object (jsonb) or a JSON
  // string (legacy rows). Coerce so qa[q.id] is always a string answer.
  let qa = interview?.qa_json || {};
  if (typeof qa === 'string') {
    try { qa = JSON.parse(qa); } catch { qa = {}; }
  }
  if (!qa || typeof qa !== 'object' || Array.isArray(qa)) qa = {};
  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">
${(() => {
  const dba = (b.source_data_json && typeof b.source_data_json === 'object' && b.source_data_json.dba_name) ? String(b.source_data_json.dba_name).trim() : '';
  const displayName = dba || b.name;
  const desc = (b.about_text || philosophy || `${displayName} — ${cat} in ${b.neighborhood || 'Los Angeles'}${articles.length ? ` · covered in ${articles.length} press article${articles.length === 1 ? '' : 's'}` : ''}${b.source_data_json?.source === 'dca_bbcb' ? ' · State-licensed' : ''}`).slice(0, 200);
  return `<title>${esc(displayName)} · ${esc(b.neighborhood || 'Los Angeles')} · LA Salon Directory</title>
<meta name="description" content="${esc(desc.slice(0, 160))}">
<link rel="alternate" type="application/rss+xml" title="Press feed" href="/feed.xml">
<meta property="og:type" content="business.business">
<meta property="og:title" content="${esc(displayName)} · ${esc(b.neighborhood || 'LA')}">
<meta property="og:description" content="${esc(desc)}">
<meta property="og:site_name" content="LA Salon Directory">
<meta property="og:url" content="/biz/${esc(b.slug)}">
<meta property="og:image" content="/api/biz/${esc(b.slug)}/og.svg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(displayName)}">
<meta name="twitter:description" content="${esc(desc)}">
<meta name="twitter:image" content="/api/biz/${esc(b.slug)}/og.svg">`;
})()}
<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 type="application/ld+json">${escJsonLd(localBusinessJsonLd(b))}</script>
${filled.length ? `<script type="application/ld+json">${escJsonLd(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);white-space:pre-wrap}
.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 && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : (b.neighborhood ? `<span>${esc(b.neighborhood)}</span>` : '')}
    <a href="/biz/${esc(b.slug)}/template/1">Mockup</a>
  </nav>
</header>

<section class="hero">
  <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : (b.neighborhood ? `${esc(b.neighborhood)} · ` : '')}${esc(cat)}</div>
  <div class="hero-grid">
    <div class="hero-meta">
      <span class="cat">${esc(cat)}</span>
      ${(() => {
        const dba = (b.source_data_json && typeof b.source_data_json === 'object' && b.source_data_json.dba_name) ? String(b.source_data_json.dba_name).trim() : '';
        // When the BBCB licensee name and the public DBA differ, lead with
        // the DBA — that's the brand customers know — and keep the licensee
        // name in a smaller "registered as" label below.
        if (dba && dba.toLowerCase() !== (b.name || '').toLowerCase()) {
          return `<h1>${esc(dba)}</h1><div style="font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-top:6px;margin-bottom:4px">Registered as · ${esc(b.name)}</div>`;
        }
        return `<h1>${esc(b.name)}</h1>`;
      })()}
      <div class="loc">${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : (b.neighborhood ? `${esc(b.neighborhood)}, ` : '')}${esc(b.city || 'Los Angeles')}</div>
      <div class="hero-stats">
        ${Number.isFinite(Number(b.years_in_business)) && Number(b.years_in_business) > 0 ? `<div>Established<b>${esc(String(Number(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>` : ''}
        ${(() => {
          const href = safeHref(b.website);
          if (!href) return '';
          return `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(href)}" target="_blank" rel="noopener">${esc(safeHostname(href) || href)}</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/live" style="margin-right:10px;background:var(--accent);color:var(--paper);border-color:var(--accent)">🎙 Start live AI interview</a>
  <a href="/biz/${esc(b.slug)}/interview">Or fill the form →</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">
    ${(() => {
      if (!interview?.recorded_at) return interview?.recorded_by ? `Recorded · by ${esc(interview.recorded_by)}` : '';
      const d = new Date(interview.recorded_at);
      // Format in UTC so server TZ doesn't make "Recorded January 2026" flicker
      // by region.
      const dateStr = isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US',{month:'long',year:'numeric',timeZone:'UTC'});
      return `Recorded ${dateStr}${interview?.recorded_by ? ` · by ${esc(interview.recorded_by)}` : ''}`;
    })()}
  </div>
</article>
`}

${(socials.length || (b.website && b.website.trim())) ? `
<section class="online" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Find them online</h2>
    <a href="/biz/${esc(b.slug)}/links" style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">+ add a link →</a>
  </div>
  <div style="display:flex;flex-wrap:wrap;gap:8px">
    ${(() => { const w = safeHref(b.website); return w ? `<a href="${esc(w)}" target="_blank" rel="noopener" style="padding:10px 14px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease">Website ↗</a>` : ''; })()}
    ${socials.map(s => { const u = safeHref(s.url); if (!u) return ''; return `<a href="${esc(u)}" target="_blank" rel="noopener" style="padding:10px 14px;border:1px solid var(--rule);background:#fff;font-family:monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease">${esc(PLATFORM_LABEL[s.platform] || s.platform)}${s.handle ? ' · @' + esc(s.handle) : ''} ↗</a>`; }).join('')}
  </div>
</section>
` : ''}

${articles.length ? `
<section class="press" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:24px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Press &amp; mentions</h2>
    <span style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">${articles.length} article${articles.length === 1 ? '' : 's'}</span>
  </div>
  <ul style="list-style:none;display:flex;flex-direction:column;gap:14px">
    ${articles.map(a => `
      ${(() => { const u = safeHref(a.url); if (!u) return ''; return `<li style="padding:14px 16px;border:1px solid var(--rule);background:#fff;transition:border-color 200ms ease">
        <a href="${esc(u)}" target="_blank" rel="noopener" style="display:block;color:var(--ink);text-decoration:none">
          <div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px">
            <span style="font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)">${esc(a.publisher || 'press')}</span>
            ${a.published_at ? `<span style="font-family:monospace;font-size:9px;color:var(--mute);letter-spacing:.06em">${new Date(a.published_at).toLocaleDateString('en-US',{month:'short',year:'numeric'})}</span>` : ''}
          </div>
          <div style="font-family:Cormorant Garamond,serif;font-size:19px;font-weight:500;line-height:1.3;margin-bottom:${a.excerpt ? '6px' : '0'}">${esc(a.title || a.url)}</div>
          ${a.excerpt ? `<p style="font-size:13px;color:#3a3a3a;line-height:1.5">${esc(a.excerpt)}</p>` : ''}
        </a>
      </li>`; })()}
    `).join('')}
  </ul>
</section>
` : ''}

${reviewSources.length ? `
<section class="community" style="padding:48px 32px;border-top:1px solid var(--rule);max-width:980px;margin:0 auto">
  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px;padding-bottom:8px;border-bottom:1px solid var(--rule)">
    <h2 style="font-family:Cormorant Garamond,serif;font-size:28px;font-weight:500;letter-spacing:-.01em">Community discussion</h2>
    <span style="font-family:monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)">${reviewSources.length} thread${reviewSources.length === 1 ? '' : 's'}</span>
  </div>
  <ul style="list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">
    ${reviewSources.map(r => { const u = safeHref(r.url); if (!u) return ''; return `
      <li style="padding:11px 14px;border:1px solid var(--rule);background:#fff">
        <a href="${esc(u)}" target="_blank" rel="noopener" style="display:block;color:var(--ink);text-decoration:none">
          <span style="font-family:monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-right:6px">${esc(r.source_type)}</span>
          <span style="font-family:Cormorant Garamond,serif;font-size:15px">${esc(r.label || u.replace(/^https?:\/\//, '').slice(0, 40))}</span>
        </a>
      </li>`; }).join('')}
  </ul>
</section>
` : ''}

<section class="cta-strip" style="background:#1a1a1a;color:#facc15;padding:32px;border-top:1px solid var(--rule);text-align:center">
  <a href="/biz/${esc(b.slug)}/analyze" style="display:inline-block;background:#facc15;color:#1a1a1a;padding:14px 28px;font-family:Cormorant Garamond,serif;font-size:22px;font-weight:500;letter-spacing:-.005em;text-decoration:none;border:3px solid #1a1a1a">★ View Analysis &amp; Suggestions →</a>
  <div style="font-family:'Space Mono',monospace;font-size:11px;color:#888;letter-spacing:.16em;text-transform:uppercase;margin-top:14px">14-signal SEO score · top hood leaders · prioritized action list · price comparison</div>
</section>

${otherShopsInHood.length ? `
<section class="more">
  <div class="more-head">
    <h2>More in ${esc(b.neighborhood || 'Los Angeles')}</h2>
    ${hoodSlug ? `<a href="/best/${esc(hoodSlug)}">See best in ${esc(b.neighborhood || 'LA')} →</a>` : ''}
  </div>
  ${hoodSlug ? `<div style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--mute);margin-bottom:18px;display:flex;gap:14px;flex-wrap:wrap"><a href="/best/${esc(hoodSlug)}/salons" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Salons in ${esc(b.neighborhood)}</a> · <a href="/best/${esc(hoodSlug)}/barbershops" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Barbers in ${esc(b.neighborhood)}</a> · <a href="/best/${esc(hoodSlug)}/spas" style="color:var(--accent);border-bottom:1px solid var(--accent)">Best Spas in ${esc(b.neighborhood)}</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>

    <div style="margin:0 0 32px;padding:14px 16px;background:#fff;border:1px dashed var(--accent);border-radius:4px;display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
      <div style="font-family:var(--mono);font-size:11px;letter-spacing:.04em;color:var(--mute);line-height:1.5">
        <strong style="color:var(--ink);text-transform:uppercase;letter-spacing:.18em">Cold start?</strong>
        Have local AI draft starter answers from what we know about ${esc(b.name)}.
        It only fills empty fields — anything you typed stays. Edit each <code>[VERIFY]</code> tag before saving.
      </div>
      <button type="button" id="aiDraftBtn" style="padding:11px 18px;background:var(--accent);color:var(--paper);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-weight:600;cursor:pointer;white-space:nowrap;border-radius:3px">⚡ Draft with AI</button>
    </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] || '';
        const maxChars = Number.isFinite(q.max_chars) && q.max_chars > 0 ? q.max_chars : 500;
        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="${maxChars}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
          <div class="hint">~${maxChars} chars · ${esc(q.seo_intent || '')}</div>
        </div>`;
      }).join('')}
    `).join('')}

    <div style="margin-top:16px;text-align:right">
      <button type="submit" style="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">Save interview</button>
    </div>
  </form>

  <div class="bar">
    <span class="saved" id="status">Edit anywhere — save when ready.</span>
    <span>
      <a href="/biz/${esc(b.slug)}/interview/live">🎙 Live mode</a>
      &nbsp;&nbsp;
      <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');

// AI draft — fills empty textareas with starter answers from local Ollama.
// Won't overwrite anything the owner has already typed.
const draftBtn = document.getElementById('aiDraftBtn');
if (draftBtn) draftBtn.addEventListener('click', async () => {
  if (!confirm('Draft starter answers with AI? Empty fields only — anything you typed stays.')) return;
  draftBtn.disabled = true;
  draftBtn.textContent = '⏳ Drafting (≈30-60s)…';
  status.textContent = 'Drafting interview from what we know…';
  try {
    const res = await fetch('/api/biz/interview/draft', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ slug: form.querySelector('[name=slug]').value }),
    });
    const r = await res.json();
    if (!r.ok) throw new Error(r.error || 'unknown');
    let filled = 0, skipped = 0;
    for (const [id, val] of Object.entries(r.qa || {})) {
      const ta = document.getElementById('q_' + id);
      if (!ta) continue;
      if (ta.value && ta.value.trim()) { skipped++; continue; }
      ta.value = val;
      filled++;
    }
    draftBtn.textContent = '✓ Drafted ' + filled + ' fields';
    status.textContent = 'AI filled ' + filled + ' fields (' + skipped + ' kept your text). Edit any [VERIFY] tags before saving — those need real local detail.';
    setTimeout(() => { draftBtn.disabled = false; draftBtn.textContent = '⚡ Draft with AI'; }, 4500);
  } catch (e) {
    draftBtn.disabled = false;
    draftBtn.textContent = '⚡ Draft with AI';
    status.textContent = 'Draft failed: ' + e.message;
  }
});

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>`;
}