← back to Small Business Builder

src/render/category.js

286 lines

// /c/:category — category landing page. Editorial layout: hero with one-line
// pitch ("Every barbershop in LA County, sorted by neighborhood"), then the
// neighborhood breakdown ("Top neighborhoods for {category}"), then a grid of
// top shops, then a long alphabetical roll. Wired with ItemList JSON-LD so
// "best barbers in LA" / "nail salons LA" queries surface this page.

import { esc, escJsonLd } from '../lib/escape.js';

function slugify(s) {
  return String(s || '').toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
}

const CAT_META = {
  barbershop: {
    title: 'Barbershops',
    pitch: 'Every state-licensed barbershop in LA + Orange + Riverside + San Bernardino + Ventura counties.',
    eyebrow: 'Barber · Beard · Fade · Cuts',
    accent: '#1a1a1a',
  },
  salon: {
    title: 'Salons',
    pitch: 'Hair, nails, lash, brow, color, blowout — every state-licensed salon across the five-county LA region.',
    eyebrow: 'Hair · Nails · Color · Blowout',
    accent: '#5a6e3a',
  },
  spa: {
    title: 'Spas & Massage',
    pitch: 'Day spas, massage parlors, and treatment rooms across Los Angeles, Orange County, and the Inland Empire.',
    eyebrow: 'Massage · Facials · Treatments',
    accent: '#6b3a8e',
  },
  beard: {
    title: 'Beard & Grooming',
    pitch: 'Specialty beard shops and grooming-only barbers across the LA region.',
    eyebrow: 'Beard · Grooming',
    accent: '#1a1a1a',
  },
  nail: {
    title: 'Nail Salons',
    pitch: 'Manicure, pedicure, and nail art studios across the five-county LA region.',
    eyebrow: 'Manicure · Pedicure · Nail Art',
    accent: '#c2410c',
  },
  lash: {
    title: 'Lash Studios',
    pitch: 'Eyelash extensions and lash treatments across Los Angeles.',
    eyebrow: 'Lashes · Extensions · Lift',
    accent: '#c2410c',
  },
  brow: {
    title: 'Brow Studios',
    pitch: 'Eyebrow shaping, threading, and microblading across LA.',
    eyebrow: 'Brows · Threading · Tinting',
    accent: '#c2410c',
  },
  restaurant: {
    title: 'Restaurants',
    pitch: 'Restaurants on the corridors and through the LA neighborhoods we cover.',
    eyebrow: 'Dining · Eat · Sit',
    accent: '#a16207',
  },
  cafe: {
    title: 'Cafés & Coffee',
    pitch: 'Coffee shops, cafés, and coffee bars across Los Angeles.',
    eyebrow: 'Coffee · Café · Espresso',
    accent: '#a16207',
  },
  bar: {
    title: 'Bars',
    pitch: 'Bars, lounges, and cocktail rooms across LA.',
    eyebrow: 'Bars · Cocktails · Lounges',
    accent: '#7c2d12',
  },
  bakery: {
    title: 'Bakeries',
    pitch: 'Bakeries, patisseries, and pastry shops across LA.',
    eyebrow: 'Bread · Pastries · Cakes',
    accent: '#a16207',
  },
  retail: {
    title: 'Retail',
    pitch: 'Boutiques, specialty retailers, and storefronts across the LA corridors.',
    eyebrow: 'Shops · Boutiques · Storefronts',
    accent: '#5a6e3a',
  },
  health: {
    title: 'Health & Wellness',
    pitch: 'Health clinics, wellness studios, and medical offices across LA.',
    eyebrow: 'Clinics · Wellness · Medical',
    accent: '#5a6e3a',
  },
};

function metaFor(cat) {
  return CAT_META[cat] || {
    title: (cat || 'Business')[0].toUpperCase() + (cat || 'business').slice(1),
    pitch: `Every ${cat || 'business'} we've catalogued across the five-county LA region.`,
    eyebrow: cat || 'Business',
    accent: '#5a6e3a',
  };
}

export function renderCategory({ category, businesses = [], hoodCounts = [], totalLicensed = 0 } = {}) {
  const meta = metaFor(category);
  const featured = businesses.slice(0, 24);
  const baseUrl = process.env.SITE_URL || 'https://localhost';
  const itemList = {
    '@context': 'https://schema.org',
    '@type': 'ItemList',
    name: `${meta.title} · ${businesses.length}`,
    description: meta.pitch,
    url: `${baseUrl}/c/${esc(category)}`,
    numberOfItems: businesses.length,
    itemListElement: businesses.slice(0, 100).map((b, i) => ({
      '@type': 'ListItem',
      position: i + 1,
      item: {
        '@type': b.source_data_json?.source === 'dca_bbcb' ? 'HairSalon' : 'LocalBusiness',
        name: b.name,
        url: `${baseUrl}/biz/${b.slug}`,
        ...(b.address ? { address: { '@type': 'PostalAddress', streetAddress: b.address, addressLocality: b.city || undefined, addressRegion: 'CA', postalCode: b.zip || undefined } } : {}),
      },
    })),
  };
  const breadcrumbs = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: [
      { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl + '/' },
      { '@type': 'ListItem', position: 2, name: meta.title, item: `${baseUrl}/c/${esc(category)}` },
    ],
  };

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(meta.title)} · LA Salon Directory · ${businesses.length} listed</title>
<meta name="description" content="${esc(meta.pitch).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 type="application/ld+json">${escJsonLd(JSON.stringify(breadcrumbs))}</script>
<script type="application/ld+json">${escJsonLd(JSON.stringify(itemList))}</script>
<style>
:root{
  --ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
  --accent:${meta.accent};--gold:#b8945a;
  --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:18px 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:60;flex-wrap:wrap;gap:12px}
.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
.brand em{font-style:italic;color:var(--accent)}
.topnav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.topnav a:hover{color:var(--ink)}

.hero{padding:80px 32px 48px;border-bottom:1px solid var(--rule);max-width:1240px;margin:0 auto}
.hero-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:18px}
.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,7vw,96px);line-height:.96;letter-spacing:-.02em;margin-bottom:20px;max-width:14ch}
.hero h1 em{font-style:italic;color:var(--accent)}
.hero-lede{font-size:18px;line-height:1.55;color:#3a3a3a;max-width:62ch;margin-bottom:32px}
.hero-stats{display:flex;gap:48px;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:34px;letter-spacing:-.01em;color:var(--ink);font-weight:500;margin-top:4px;text-transform:none}

.section{padding:64px 32px;max-width:1240px;margin:0 auto}
.section + .section{border-top:1px solid var(--rule)}
.section-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin-bottom:24px;padding-bottom:6px;border-bottom:1px solid var(--rule)}

/* Hood breakdown grid */
.hood-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
.hood-cell{background:var(--paper);padding:22px 20px;display:flex;flex-direction:column;gap:2px;transition:background 200ms ease}
.hood-cell:hover{background:#fff}
.hood-cell-name{font-family:var(--serif);font-size:24px;font-weight:500;letter-spacing:-.005em;line-height:1.1}
.hood-cell-count{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--accent)}

/* Featured shop grid */
.shop-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px}
.shop-card{display:block;color:var(--ink);background:#fff;padding:18px 20px;border:1px solid var(--rule);transition:border-color 180ms ease,transform 180ms ease}
.shop-card:hover{border-color:var(--accent);transform:translateY(-2px)}
.sc-cat{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
.sc-cat .lic{background:var(--gold);color:var(--ink);padding:2px 6px;font-weight:700;letter-spacing:.18em}
.sc-name{font-family:var(--serif);font-size:21px;font-weight:500;letter-spacing:-.005em;line-height:1.15;margin-bottom:4px}
.sc-where{font-family:var(--mono);font-size:10px;letter-spacing:.04em;color:var(--mute);text-transform:uppercase;line-height:1.5}

/* Long alphabetical roll */
.roll-list{column-count:3;column-gap:48px;column-rule:1px solid var(--rule)}
@media (max-width:880px){.roll-list{column-count:1}}
.roll-row{break-inside:avoid;display:flex;justify-content:space-between;align-items:baseline;padding:8px 0;border-bottom:1px dotted var(--rule);transition:padding 150ms ease}
.roll-row:hover{padding-left:6px}
.roll-name{font-family:var(--serif);font-size:17px;font-weight:500;letter-spacing:-.005em}
.roll-where{font-family:var(--mono);font-size:9px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase}

.footer{padding:48px 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){
  .topbar{padding:14px 20px}
  .hero{padding:48px 20px 32px}
  .section{padding:48px 20px}
}
</style>
</head>
<body>
<header class="topbar">
  <div class="brand"><a href="/">LA <em>Salon</em> Directory</a> <span style="color:var(--mute);font-style:italic">/ ${esc(meta.title.toLowerCase())}</span></div>
  <nav class="topnav">
    <a href="/neighborhoods">Neighborhoods</a>
    <a href="/proprietors">Proprietors</a>
    <a href="/press">Press</a>
    <a href="/fresh">Just Opened</a>
    <a href="/map">Map</a>
    <a href="/">Home</a>
  </nav>
</header>

<section class="hero">
  <div class="hero-eyebrow">${esc(meta.eyebrow)}</div>
  <h1>${esc(meta.title.split(' ')[0])} <em>${esc(meta.title.split(' ').slice(1).join(' ') || 'in LA')}.</em></h1>
  <p class="hero-lede">${esc(meta.pitch)}</p>
  <div class="hero-stats">
    <div>Listings<b>${businesses.length.toLocaleString()}</b></div>
    <div>State-licensed<b>${totalLicensed.toLocaleString()}</b></div>
    <div>Neighborhoods<b>${hoodCounts.length}</b></div>
  </div>
</section>

${hoodCounts.length ? `
<section class="section">
  <div class="section-label">Top Neighborhoods for ${esc(meta.title)}</div>
  <div class="hood-grid">
    ${hoodCounts.slice(0, 24).map(h => `
      <a class="hood-cell" href="/n/${esc(slugify(h.neighborhood))}">
        <div class="hood-cell-name">${esc(h.neighborhood)}</div>
        <div class="hood-cell-count">${(h.count || 0).toLocaleString()} ${esc(meta.title.toLowerCase())}</div>
      </a>
    `).join('')}
  </div>
</section>
` : ''}

${featured.length ? `
<section class="section">
  <div class="section-label">Featured · State-Licensed First · By Neighborhood</div>
  <div class="shop-grid">
    ${featured.map(b => {
      const lic = b.source_data_json?.source === 'dca_bbcb';
      return `
      <a class="shop-card" href="/biz/${esc(b.slug)}">
        <div class="sc-cat"><span>${esc(b.neighborhood || b.city || '')}</span>${lic ? '<span class="lic">◆ Licensed</span>' : ''}</div>
        <div class="sc-name">${esc(b.name)}</div>
        ${b.address ? `<div class="sc-where">${esc(b.address)}</div>` : ''}
      </a>`;
    }).join('')}
  </div>
</section>
` : ''}

${businesses.length > 24 ? `
<section class="section">
  <div class="section-label">All ${businesses.length.toLocaleString()} ${esc(meta.title)} · A–Z</div>
  <div class="roll-list">
    ${[...businesses].sort((a,b) => (a.name||'').localeCompare(b.name||'')).slice(0, 600).map(b => `
      <a class="roll-row" href="/biz/${esc(b.slug)}">
        <span class="roll-name">${esc(b.name)}</span>
        <span class="roll-where">${esc(b.neighborhood || b.city || '')}</span>
      </a>
    `).join('')}
  </div>
</section>
` : ''}

<footer class="footer">
  <span>${esc(meta.title)} · LA Salon Directory · ${new Date().getFullYear()}</span>
  <span>${businesses.length.toLocaleString()} listings · ${totalLicensed.toLocaleString()} state-licensed</span>
  <span><a href="/">← Home</a></span>
</footer>
</body>
</html>`;
}