← back to Small Business Builder

src/render/corridor.js

405 lines

// Corridor page — long, linear stretch of road treated as the spine of a
// curated neighborhood directory. West-to-east scrollable. Neighborhoods
// as sticky anchors. Licensed salons featured at the top of each cluster.
//
// First instance: /corridor/ventura-blvd — the 20-mile Ventura Blvd
// corridor through Calabasas/Woodland Hills/Tarzana/Encino/Sherman Oaks/
// Studio City. Tagged in source_data_json as 'ventura-blvd-20mi'.

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

// Build BreadcrumbList + ItemList JSON-LD for a corridor. Google uses
// ItemList to surface rich "list of N items" search results — meaningful
// CTR lift for long-tail queries like "salons on ventura blvd".
function corridorJsonLd({ meta, businesses, corridorTag }) {
  const baseUrl = process.env.SITE_URL || 'https://localhost';
  const corridorPath = `/corridor/${corridorTag.replace(/-\d+mi$/, '')}`;
  const itemListElements = 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: b.state || 'CA', postalCode: b.zip || undefined } } : {}),
      ...(b.latitude && b.longitude ? { geo: { '@type': 'GeoCoordinates', latitude: Number(b.latitude), longitude: Number(b.longitude) } } : {}),
    },
  }));
  return [
    {
      '@context': 'https://schema.org',
      '@type': 'BreadcrumbList',
      itemListElement: [
        { '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl + '/' },
        { '@type': 'ListItem', position: 2, name: 'Corridors', item: baseUrl + '/' },
        { '@type': 'ListItem', position: 3, name: meta.title, item: baseUrl + corridorPath },
      ],
    },
    {
      '@context': 'https://schema.org',
      '@type': 'ItemList',
      name: `${meta.title} · ${businesses.length} businesses`,
      description: meta.subtitle,
      url: baseUrl + corridorPath,
      numberOfItems: businesses.length,
      itemListElement: itemListElements,
    },
  ];
}

// Editorial titling per corridor + the city order along the strip.
const CORRIDOR_META = {
  'ventura-blvd-20mi': {
    title:    'Ventura Boulevard',
    subtitle: 'A twenty-mile corridor of state-licensed salons, restaurants, and storefronts through the San Fernando Valley.',
    eyebrow:  'The Ventura Blvd Corridor · West to East',
    citySequence: [
      'Woodland Hills',
      'Tarzana',
      'Encino',
      'Sherman Oaks',
      'Studio City',
    ],
    cityTagline: {
      'Woodland Hills': 'Western terminus · The Westfield end',
      'Tarzana':        'Middle Valley · medical-spa belt',
      'Encino':         'Encino flats · old-school dining row',
      'Sherman Oaks':   'Galleria-adjacent · the busiest mile',
      'Studio City':    'Eastern terminus · CBS Radford-adjacent',
    },
    accent: '#5a6e3a', // olive — same value as Sherman Oaks neighborhood accent
  },
  'sunset-blvd-22mi': {
    title:    'Sunset Boulevard',
    subtitle: 'A twenty-two-mile corridor from Echo Park to the Pacific Ocean — every licensed salon, restaurant, and storefront on the strip that built Hollywood.',
    eyebrow:  'The Sunset Blvd Corridor · East to West',
    // Editorial sequence reads east-to-west (Echo Park first, the ocean last).
    // Within each city the byWestEast comparator still applies, which is fine
    // for individual blocks. City names match `city` field in the DB
    // (case-insensitive comparison in renderCorridor).
    citySequence: [
      'Los Angeles',     // covers Echo Park / Silver Lake / Hollywood / Bel-Air / Brentwood / Pac Pal east side
      'Hollywood',       // OSM-tagged variant for the Hollywood stretch
      'West Hollywood',  // The Sunset Strip
      'Beverly Hills',   // brief crossing
      'Pacific Palisades', // western terminus toward PCH
    ],
    cityTagline: {
      'Los Angeles':       'Echo Park to the Palisades · the LA stretch',
      'Hollywood':         'Walk of Fame mile · the marquee strip',
      'West Hollywood':    'The Sunset Strip · Whisky, Roxy, Viper',
      'Beverly Hills':     'Brief crossing · the boutique mile',
      'Pacific Palisades': 'Western terminus · PCH and the ocean',
    },
    accent: '#c2410c', // burnt orange — distinct from Ventura's olive
  },
  'wilshire-blvd-16mi': {
    title:    'Wilshire Boulevard',
    subtitle: 'Sixteen miles of LA\'s main street — from the towers of Downtown through Koreatown, the Miracle Mile museums, Beverly Hills, Westwood, and Brentwood, ending at the Santa Monica ocean.',
    eyebrow:  'The Wilshire Blvd Corridor · East to West',
    // Wilshire crosses fewer named cities than Sunset — most of its 16 miles
    // are inside the City of LA proper, then a brief Beverly Hills crossing,
    // then Santa Monica at the western terminus. Westwood + Brentwood are
    // City-of-LA neighborhoods, so OSM `addr:city` tends to read 'Los Angeles'
    // for those. The renderCorridor matcher is case-insensitive so the
    // sequence here is canonical-cased.
    citySequence: [
      'Los Angeles',     // DTLA / MacArthur Park / Koreatown / Mid-Wilshire / Miracle Mile / Westwood / Brentwood
      'Beverly Hills',   // brief crossing through the Golden Triangle
      'Santa Monica',    // western terminus at the ocean
    ],
    cityTagline: {
      'Los Angeles':   'DTLA to Brentwood · the long LA stretch',
      'Beverly Hills': 'The Golden Triangle · Rodeo-adjacent crossing',
      'Santa Monica':  'Western terminus · the ocean mile',
    },
    accent: '#6b3a8e', // purple — distinct from Ventura olive + Sunset burnt-orange
  },
  'hollywood-blvd-5mi': {
    title:    'Hollywood Boulevard',
    subtitle: 'Five miles of marquees, sound stages, and salons on the strip the world thinks of when it thinks of LA — from East Hollywood through the Walk of Fame to the Hollywood Hills.',
    eyebrow:  'The Hollywood Blvd Corridor · East to West',
    // Hollywood Blvd is short and runs almost entirely inside the City of LA.
    // OSM `addr:city` tags split between 'Los Angeles' (catch-all), 'Hollywood'
    // (central stretch), 'East Hollywood' (Vermont side), and 'Hollywood Hills'
    // (the western frontage toward Laurel Canyon). citySequence reads east-to-
    // west so reading the page literally walks the strip.
    citySequence: [
      'Thai Town',       // Vermont / East Hollywood end
      'Hollywood',       // central · Walk of Fame · Vine · Highland
      'Los Angeles',     // umbrella-city catch-all (covers the bulk of the strip)
    ],
    cityTagline: {
      'Thai Town':       'Eastern end · Vermont · LA\'s only official Thai Town',
      'Hollywood':       'Walk of Fame · Vine · Highland · the marquee mile',
      'Los Angeles':     'The umbrella-city stretch · running through the Hills',
    },
    accent: '#9b1c1c', // deep marquee red — distinct from olive/orange/purple
  },
};

const CATEGORY_LABEL = {
  barbershop:'Barber & Beard', beard:'Barber & Beard', salon:'Salon · Hair · Nails',
  hair:'Hair', nail:'Nails', nail_salon:'Nails', lash:'Lash', brow:'Brow',
  spa:'Spa', blowout:'Blowout', waxing:'Waxing',
  restaurant:'Restaurant', cafe:'Café', bar:'Bar', bakery:'Bakery', deli:'Deli',
  retail:'Retail', boutique:'Boutique', grocery:'Grocery',
  pharmacy:'Pharmacy', health:'Health', finance:'Bank · Finance',
  fitness:'Fitness', service:'Service', office:'Office',
  entertainment:'Entertainment',
  generic:'Business',
};

// Categories that are "salon-adjacent" and get the gold-accent treatment.
const SALON_CATS = new Set(['salon','barbershop','beard','hair','nail','nail_salon','lash','brow','spa','blowout','waxing']);

function categoryLabel(c) {
  return CATEGORY_LABEL[c] || (c ? c[0].toUpperCase() + c.slice(1) : 'Business');
}

// Render a tight business card. Bigger for salons, compact strip for others.
function renderBizCard(b, { variant = 'salon' } = {}) {
  const cat = categoryLabel(b.category);
  const isSalon = SALON_CATS.has(b.category);
  const licensed = b.source_data_json?.source === 'dca_bbcb';
  const licNum = b.source_data_json?.license_number || '';
  // DCA verify link — sole-proprietor establishment licenses use license_type
  // 'EST'/'BBR'; the public search will resolve any of them via lic-number alone.
  const licUrl = licNum ? `https://search.dca.ca.gov/results?lic=${encodeURIComponent(licNum)}` : '';
  if (variant === 'strip') {
    // Compact horizontal-strip card for non-salon businesses
    return `
    <a class="strip-card" href="/biz/${esc(b.slug)}">
      <span class="sc-cat">${esc(cat)}</span>
      <span class="sc-name">${esc(b.name)}</span>
      ${b.address ? `<span class="sc-addr">${esc(b.address)}</span>` : ''}
    </a>`;
  }
  // Salon variant. Outer is the biz-card link; the license badge is a SECOND
  // <a> sibling positioned absolutely over the image so the click goes outbound
  // (DCA verify) rather than inward (the biz profile).
  return `
  <div class="biz-card${isSalon ? ' is-salon' : ''}">
    <a class="bc-link" href="/biz/${esc(b.slug)}"><div class="bc-img">
      ${b.hero_image_url
        ? `<img loading="lazy" src="${esc(b.hero_image_url)}" alt="${esc(b.name)}">`
        : `<div class="bc-img-placeholder"></div>`}
    </div></a>
    ${licensed && licUrl ? `<a class="bc-badge" href="${esc(licUrl)}" target="_blank" rel="noopener" title="Verify state cosmetology license #${esc(licNum)} on DCA">◆ Licensed · ${esc(licNum)}</a>` : ''}
    <a class="bc-link" href="/biz/${esc(b.slug)}"><div class="bc-meta">
      <span class="bc-cat">${esc(cat)}</span>
      <h3 class="bc-name">${esc(b.name)}</h3>
      ${b.address ? `<p class="bc-addr">${esc(b.address)}</p>` : ''}
    </div></a>
  </div>`;
}

// Build one city section (e.g., "Sherman Oaks") with featured salons + business strip.
function renderCitySection(cityName, businesses, meta) {
  const tagline = meta.cityTagline?.[cityName] || '';
  // West-to-east comparator: rows with longitude lead (sorted ascending), then
  // ungeocoded fall to the bottom alphabetically. Negative longitudes are
  // expected for LA so smaller (more negative) = westernmost = first.
  const byWestEast = (a, b) => {
    const al = a.longitude == null ? Infinity : Number(a.longitude);
    const bl = b.longitude == null ? Infinity : Number(b.longitude);
    if (al !== bl) return al - bl;
    return (a.name || '').localeCompare(b.name || '');
  };
  // Salons: licensed first, then west-to-east within each tier.
  const salons = businesses
    .filter(b => SALON_CATS.has(b.category))
    .sort((a, b) => {
      const aLic = a.source_data_json?.source === 'dca_bbcb' ? 0 : 1;
      const bLic = b.source_data_json?.source === 'dca_bbcb' ? 0 : 1;
      if (aLic !== bLic) return aLic - bLic;
      return byWestEast(a, b);
    });
  // Others: pure west-to-east. Reading down the strip-list literally walks
  // the block from the western end of the city to the eastern end.
  const others = businesses
    .filter(b => !SALON_CATS.has(b.category))
    .sort(byWestEast);

  const slug = cityName.toLowerCase().replace(/\s+/g, '-');
  return `
  <section class="city" id="${esc(slug)}">
    <div class="city-head">
      <span class="city-num">${businesses.length} businesses · ${salons.length} licensed salons</span>
      <h2>${esc(cityName)}</h2>
      ${tagline ? `<p class="city-tagline">${esc(tagline)}</p>` : ''}
    </div>

    ${salons.length ? `
      <div class="city-section-label">Salons & Barbers · State-Licensed First</div>
      <div class="biz-grid">
        ${salons.slice(0, 24).map(b => renderBizCard(b, { variant: 'salon' })).join('')}
      </div>
    ` : ''}

    ${others.length ? `
      <div class="city-section-label">The Rest of the Block</div>
      <div class="strip-list">
        ${others.map(b => renderBizCard(b, { variant: 'strip' })).join('')}
      </div>
    ` : ''}
  </section>`;
}

export function renderCorridor({ corridorTag = 'ventura-blvd-20mi', businesses = [] } = {}) {
  const meta = CORRIDOR_META[corridorTag];
  if (!meta) return '<!doctype html><title>Unknown corridor</title><h1>Unknown corridor</h1>';

  // Group by city. We sort cities according to the editorial sequence; any
  // unrecognized city (e.g., "Los Angeles" generic, or a typo'd entry) lands
  // at the bottom under "Other".
  const byCity = new Map();
  for (const b of businesses) {
    const c = (b.city || '').trim();
    const key = meta.citySequence.find(seq => seq.toLowerCase() === c.toLowerCase()) || (c || 'Other');
    if (!byCity.has(key)) byCity.set(key, []);
    byCity.get(key).push(b);
  }
  // Order: editorial sequence first, then any extras alphabetically.
  const orderedKeys = [
    ...meta.citySequence.filter(c => byCity.has(c)),
    ...[...byCity.keys()].filter(c => !meta.citySequence.includes(c)).sort(),
  ];

  const totalLicensed = businesses.filter(b => b.source_data_json?.source === 'dca_bbcb').length;
  const totalSalons = businesses.filter(b => SALON_CATS.has(b.category)).length;
  const totalGeocoded = businesses.filter(b => b.latitude != null).length;

  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)} · A Salon & Business Corridor · LA Salon Directory</title>
<meta name="description" content="${esc(meta.subtitle).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">
<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 =================================================== */
.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}
.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
.brand em{font-style:italic;color:var(--accent)}
.brand a{display:inline}

/* CITY-ANCHOR NAV ========================================== */
.anchor-nav{display:flex;gap:18px;font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--mute);overflow-x:auto;white-space:nowrap}
.anchor-nav a{padding:4px 0;border-bottom:1px solid transparent;transition:border-color 200ms ease,color 200ms ease}
.anchor-nav a:hover{color:var(--ink);border-bottom-color:var(--accent)}

/* HERO ===================================================== */
.hero{padding:80px 32px 56px;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:20px}
.hero h1{font-family:var(--serif);font-weight:500;font-size:clamp(48px,8vw,108px);line-height:.96;letter-spacing:-.02em;margin-bottom:24px;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:60ch;margin-bottom:48px}
.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}

/* CITY SECTION ============================================= */
.city{padding:96px 32px;max-width:1240px;margin:0 auto;scroll-margin-top:72px}
.city + .city{border-top:1px solid var(--rule)}
.city-head{margin-bottom:48px}
.city-num{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
.city-head h2{font-family:var(--serif);font-weight:500;font-size:clamp(56px,8vw,108px);line-height:.96;letter-spacing:-.02em;margin:6px 0 12px}
.city-tagline{font-family:var(--serif);font-style:italic;font-size:22px;color:var(--mute);max-width:50ch}
.city-section-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);margin:32px 0 18px;padding-bottom:6px;border-bottom:1px solid var(--rule)}

/* BIZ CARD GRID (for salons) =============================== */
.biz-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:24px}
.biz-card{display:block;color:var(--ink)}
.bc-img{aspect-ratio:4/5;overflow:hidden;background:#222;margin-bottom:12px;position:relative}
.bc-img img{width:100%;height:100%;object-fit:cover;transition:transform 600ms ease}
.biz-card:hover .bc-img img{transform:scale(1.04)}
.bc-img-placeholder{position:absolute;inset:0;background:linear-gradient(135deg,var(--ink) 0%,#2a2a2a 100%)}
.bc-badge{position:absolute;top:10px;left:10px;background:var(--gold);color:var(--ink);font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;padding:4px 8px;border-radius:2px;font-weight:700}
.biz-card.is-salon .bc-img-placeholder{background:linear-gradient(135deg,#1a1a1a 0%,${meta.accent} 140%)}
.bc-cat{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;display:inline-block}
.bc-name{font-family:var(--serif);font-size:21px;font-weight:500;letter-spacing:-.005em;line-height:1.15;margin-bottom:4px}
.bc-addr{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.4}

/* STRIP-CARD LIST (for non-salon businesses) =============== */
.strip-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0;border-top:1px solid var(--rule)}
.strip-card{display:flex;flex-direction:column;padding:14px 18px;border-bottom:1px solid var(--rule);border-right:1px solid var(--rule);transition:background 150ms ease}
.strip-card:hover{background:#fff}
.sc-cat{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-bottom:4px}
.sc-name{font-family:var(--serif);font-size:17px;font-weight:500;letter-spacing:-.005em;line-height:1.2;margin-bottom:2px}
.sc-addr{font-family:var(--mono);font-size:10px;color:var(--mute);letter-spacing:.04em}

/* MANIFESTO ================================================= */
.manifesto{padding:96px 32px;background:var(--ink);color:var(--paper);text-align:center}
.manifesto p{font-family:var(--serif);font-style:italic;font-size:clamp(28px,4vw,48px);line-height:1.2;letter-spacing:-.01em;font-weight:400;max-width:880px;margin:0 auto}
.manifesto p em{font-style:normal;color:var(--gold)}
.manifesto .sig{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#888;margin-top:32px}

/* FOOTER ==================================================== */
.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{flex-direction:column;align-items:flex-start;gap:12px;padding:14px 20px}
  .anchor-nav{width:100%}
  .hero{padding:48px 20px 32px}
  .city{padding:48px 20px}
  .strip-list{grid-template-columns:1fr}
}
</style>
${corridorJsonLd({ meta, businesses, corridorTag }).map(blob => `<script type="application/ld+json">${escJsonLd(JSON.stringify(blob))}</script>`).join('\n')}
</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)}</span></div>
  <nav class="anchor-nav">
    ${orderedKeys.map(c => `<a href="#${esc(c.toLowerCase().replace(/\s+/g, '-'))}">${esc(c)}</a>`).join('')}
    <a href="/corridor/${corridorTag.replace(/-\d+mi$/, '')}/map" style="margin-left:auto;background:var(--accent);color:var(--paper);padding:4px 12px;border-radius:0;font-weight:600">📍 Drive Map →</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(' '))}.</em></h1>
  <p class="hero-lede">${esc(meta.subtitle)}</p>
  <div class="hero-stats">
    <div>Businesses on the strip<b>${businesses.length}</b></div>
    <div>State-licensed salons<b>${totalLicensed}</b></div>
    <div>All salon-types<b>${totalSalons}</b></div>
    <div>Geocoded<b>${totalGeocoded}</b></div>
    <div>Cities covered<b>${orderedKeys.length}</b></div>
  </div>
</section>

${orderedKeys.map(c => renderCitySection(c, byCity.get(c) || [], meta)).join('')}

<section class="manifesto">
  <p>One street. <em>Twenty miles.</em> Every shop with a license, every restaurant worth a stop, every business that registered with the city — west to east, in order.</p>
  <div class="sig">— The ${esc(meta.title)} Corridor</div>
</section>

<footer class="footer">
  <span>${esc(meta.title)} Corridor · ${new Date().getFullYear()}</span>
  <span>${businesses.length} businesses · ${orderedKeys.length} cities · sourced from BBCB + OSM</span>
  <span><a href="/">← Full directory</a></span>
</footer>

</body>
</html>`;
}