← back to Small Business Builder

src/render/neighborhoods.js

166 lines

// /neighborhoods — full grid of every neighborhood in the directory, sorted
// by shop count. Editorial side-of-the-paper layout: huge serif title, mono
// subtitle, then a two-column dense list (count · neighborhood) that reads
// like an index in the back of a magazine.
//
// Why this page matters: after the ZIP backfill we have 160+ distinct
// neighborhoods. The homepage only shows the top ~30. This is the
// crawler-friendly + reader-friendly index of all of them, each linked to
// /n/:slug.

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

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

export function renderNeighborhoods({ neighborhoods = [], totalShops = 0, totalLicensed = 0 } = {}) {
  // neighborhoods = [{ neighborhood, count, licensed_count }, …] already sorted desc.
  // Group by first-letter for the alphabet anchor.
  const sorted = [...neighborhoods].sort((a, b) => (a.neighborhood || '').localeCompare(b.neighborhood || ''));
  const byLetter = new Map();
  for (const n of sorted) {
    const k = (n.neighborhood?.[0] || '#').toUpperCase();
    if (!byLetter.has(k)) byLetter.set(k, []);
    byLetter.get(k).push(n);
  }
  const letters = [...byLetter.keys()].sort();

  // Top-20 by count for the masthead "headliners" section.
  const top20 = [...neighborhoods].sort((a, b) => (b.count || 0) - (a.count || 0)).slice(0, 20);

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>All Neighborhoods · ${neighborhoods.length} · LA Salon Directory</title>
<meta name="description" content="Every neighborhood we cover — ${neighborhoods.length} of them, ${totalShops.toLocaleString()} shops total, ${totalLicensed.toLocaleString()} state-licensed. Sorted by size, indexed alphabetically.">
<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:#5a6e3a;--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}

.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)}
.crumb{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute)}
.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:18ch}
.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}

/* HEADLINERS (top-20 by shop count) */
.headliners{padding:64px 32px;max-width:1240px;margin:0 auto;border-bottom: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)}
.head-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
.head-card{background:var(--paper);padding:24px 22px;display:flex;flex-direction:column;gap:6px;transition:background 200ms ease}
.head-card:hover{background:#fff}
.head-rank{font-family:var(--mono);font-size:10px;letter-spacing:.18em;color:var(--mute)}
.head-name{font-family:var(--serif);font-size:30px;font-weight:500;letter-spacing:-.01em;line-height:1.1}
.head-count{font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--accent);margin-top:6px}

/* INDEX A-Z */
.index-section{padding:64px 32px;max-width:1240px;margin:0 auto}
.alpha-anchor{display:flex;flex-wrap:wrap;gap:6px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);margin-bottom:48px;padding-bottom:18px;border-bottom:1px solid var(--rule)}
.alpha-anchor a{padding:4px 10px;border:1px solid var(--rule);transition:all 150ms ease}
.alpha-anchor a:hover{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.letter-block{margin-bottom:48px;scroll-margin-top:90px}
.letter-mark{font-family:var(--serif);font-size:64px;font-weight:500;color:var(--accent);font-style:italic;letter-spacing:-.02em;margin-bottom:8px;line-height:1}
.hood-list{column-count:3;column-gap:48px;column-rule:1px solid var(--rule)}
@media (max-width:880px){.hood-list{column-count:1}}
.hood-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}
.hood-row:hover{padding-left:6px}
.hood-name{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
.hood-count{font-family:var(--mono);font-size:10px;letter-spacing:.08em;color:var(--mute)}
.hood-row .hood-count b{font-family:var(--mono);color:var(--accent);font-weight:500}

.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}
  .headliners,.index-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">/ neighborhoods</span></div>
  <nav class="topnav">
    <a href="/proprietors">Proprietors</a>
    <a href="/press">Press</a>
    <a href="/fresh">Just Opened</a>
    <a href="/corridor/ventura-blvd">Ventura</a>
    <a href="/map">Map</a>
    <a href="/">Home</a>
  </nav>
</header>

<section class="hero">
  <div class="hero-eyebrow">Index of Neighborhoods · A–Z</div>
  <h1>Every <em>neighborhood</em> we cover.</h1>
  <p class="hero-lede">Sorted alphabetically, indexed by shop count. ${neighborhoods.length.toLocaleString()} neighborhoods across LA + Orange + Riverside + San Bernardino + Ventura counties — every one with at least one state-licensed salon, barber, or storefront we've catalogued.</p>
  <div class="hero-stats">
    <div>Neighborhoods<b>${neighborhoods.length.toLocaleString()}</b></div>
    <div>Total shops<b>${totalShops.toLocaleString()}</b></div>
    <div>State-licensed<b>${totalLicensed.toLocaleString()}</b></div>
  </div>
</section>

<section class="headliners">
  <div class="section-label">The 20 Largest · By Shop Count</div>
  <div class="head-grid">
    ${top20.map((n, i) => `
      <a class="head-card" href="/n/${esc(slugify(n.neighborhood))}">
        <div class="head-rank">№ ${String(i + 1).padStart(2, '0')}</div>
        <div class="head-name">${esc(n.neighborhood)}</div>
        <div class="head-count">${(n.count || 0).toLocaleString()} shops${n.licensed_count ? ` · ${(n.licensed_count).toLocaleString()} licensed` : ''}</div>
      </a>
    `).join('')}
  </div>
</section>

<section class="index-section">
  <div class="alpha-anchor">
    ${letters.map(L => `<a href="#letter-${esc(L)}">${esc(L)}</a>`).join('')}
  </div>
  ${letters.map(L => `
    <div class="letter-block" id="letter-${esc(L)}">
      <div class="letter-mark">${esc(L)}</div>
      <div class="hood-list">
        ${byLetter.get(L).map(n => `
          <a class="hood-row" href="/n/${esc(slugify(n.neighborhood))}">
            <span class="hood-name">${esc(n.neighborhood)}</span>
            <span class="hood-count"><b>${n.count || 0}</b> shops</span>
          </a>
        `).join('')}
      </div>
    </div>
  `).join('')}
</section>

<footer class="footer">
  <span>LA Salon Directory · ${new Date().getFullYear()}</span>
  <span>${neighborhoods.length} neighborhoods · ${totalShops.toLocaleString()} businesses</span>
  <span><a href="/">← Home</a></span>
</footer>
</body>
</html>`;
}