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