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