[object Object]

← back to Ventura Claw Leads

yolo tick 26: per-neighborhood landing pages — /neighborhood/sherman-oaks etc.

f41ef50c993960f11342f15dc9a608d553ccf241 · 2026-05-07 02:07:29 -0700 · Steve Abrams

routes/public.js:
- GET /neighborhood/:slug — whitelist of 5 valid slugs (sherman-oaks,
  studio-city, encino, tarzana, woodland-hills); anything else 404s.
  Match in DB by slugified neighborhood OR slugified city, so /neighborhood/
  sherman-oaks catches both city='Sherman Oaks' and neighborhood='Sherman
  Oaks' rows. Same ORDER BY as /find: tier-priority + claimed-first +
  photo-having + alpha. 200-row LIMIT (matches /find).
- Sitemap.xml: 5 new URLs added (one per neighborhood) at priority 0.7,
  daily changefreq. Total now 50 URLs (was 45).

views/public/neighborhood.ejs: dedicated landing page —
  - BreadcrumbList JSON-LD (Home › Find › <Neighborhood>) for rich-results
  - <H1>Businesses in <Neighborhood></H1>
  - 8 vertical-filter chips that pre-fill the neighborhood (clicking 'Food'
    on /neighborhood/sherman-oaks goes to /find?vertical=food&city=Sherman+Oaks)
  - Same business-card grid as /find (photo or gradient hero)
  - Empty state for neighborhoods that haven't onboarded businesses yet

views/partials/footer.ejs: new 'Neighborhoods' column with 5 direct links
— internal-link signal that helps Google's crawler reach the new pages.

Verified live: 5 neighborhood URLs return 200, bogus slug 404s, sitemap
shows 5 /neighborhood/* entries, footer renders the column. Real
local-SEO landing-pages now exist for each segment of the corridor.
46/46 tests still pass.

Files touched

Diff

commit f41ef50c993960f11342f15dc9a608d553ccf241
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 7 02:07:29 2026 -0700

    yolo tick 26: per-neighborhood landing pages — /neighborhood/sherman-oaks etc.
    
    routes/public.js:
    - GET /neighborhood/:slug — whitelist of 5 valid slugs (sherman-oaks,
      studio-city, encino, tarzana, woodland-hills); anything else 404s.
      Match in DB by slugified neighborhood OR slugified city, so /neighborhood/
      sherman-oaks catches both city='Sherman Oaks' and neighborhood='Sherman
      Oaks' rows. Same ORDER BY as /find: tier-priority + claimed-first +
      photo-having + alpha. 200-row LIMIT (matches /find).
    - Sitemap.xml: 5 new URLs added (one per neighborhood) at priority 0.7,
      daily changefreq. Total now 50 URLs (was 45).
    
    views/public/neighborhood.ejs: dedicated landing page —
      - BreadcrumbList JSON-LD (Home › Find › <Neighborhood>) for rich-results
      - <H1>Businesses in <Neighborhood></H1>
      - 8 vertical-filter chips that pre-fill the neighborhood (clicking 'Food'
        on /neighborhood/sherman-oaks goes to /find?vertical=food&city=Sherman+Oaks)
      - Same business-card grid as /find (photo or gradient hero)
      - Empty state for neighborhoods that haven't onboarded businesses yet
    
    views/partials/footer.ejs: new 'Neighborhoods' column with 5 direct links
    — internal-link signal that helps Google's crawler reach the new pages.
    
    Verified live: 5 neighborhood URLs return 200, bogus slug 404s, sitemap
    shows 5 /neighborhood/* entries, footer renders the column. Real
    local-SEO landing-pages now exist for each segment of the corridor.
    46/46 tests still pass.
---
 routes/public.js              | 47 +++++++++++++++++++++++++-
 views/partials/footer.ejs     |  8 +++++
 views/public/neighborhood.ejs | 76 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 130 insertions(+), 1 deletion(-)

diff --git a/routes/public.js b/routes/public.js
index cfa97f6..cb14044 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -107,6 +107,47 @@ router.get('/find', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// Per-neighborhood landing pages. Match by slugified neighborhood OR city
+// so /neighborhood/sherman-oaks catches both city='Sherman Oaks' and
+// neighborhood='Sherman Oaks' rows. SEO win: Google can rank the URL for
+// '<neighborhood> businesses'-style searches.
+const NEIGHBORHOODS = [
+  { slug: 'sherman-oaks',   label: 'Sherman Oaks' },
+  { slug: 'studio-city',    label: 'Studio City' },
+  { slug: 'encino',         label: 'Encino' },
+  { slug: 'tarzana',        label: 'Tarzana' },
+  { slug: 'woodland-hills', label: 'Woodland Hills' }
+];
+router.get('/neighborhood/:slug', async (req, res, next) => {
+  try {
+    const slug = String(req.params.slug || '').toLowerCase().trim();
+    const meta = NEIGHBORHOODS.find(n => n.slug === slug);
+    if (!meta) return res.status(404).render('public/404', { title: 'Not found' });
+
+    const businesses = await db.many(`
+      SELECT id, slug, business_name, vertical, headline, neighborhood, city, state,
+             tier, claim_status, verified, photo_path
+        FROM businesses
+       WHERE status = 'active'
+         AND (LOWER(REPLACE(neighborhood, ' ', '-')) = $1
+              OR LOWER(REPLACE(city, ' ', '-')) = $1)
+       ORDER BY tier = 'premier' DESC, tier = 'standard' DESC,
+                claim_status IN ('self','claimed') DESC,
+                (photo_path IS NOT NULL) DESC,
+                business_name ASC
+       LIMIT 200
+    `, [slug]);
+
+    res.render('public/neighborhood', {
+      title: `${meta.label} businesses on Ventura Blvd · Ventura Claw`,
+      metaDescription: `${businesses.length} small businesses on Ventura Blvd in ${meta.label} — restaurants, salons, retail, fitness, pet, auto detail, cleaning, creative pros. Search and message a business directly via Ventura Claw.`,
+      neighborhood: meta,
+      businesses,
+      verticals: VERTICALS
+    });
+  } catch (err) { next(err); }
+});
+
 router.get('/business/:slug', async (req, res, next) => {
   try {
     const biz = await db.one(`
@@ -357,9 +398,13 @@ router.get('/sitemap.xml', async (req, res, next) => {
     const verticalPages = VERTICALS.map(v => ({
       loc: `/find?vertical=${v.key}`, changefreq: 'daily', priority: '0.7'
     }));
+    // One <url> per neighborhood landing page — strong local-SEO signal.
+    const neighborhoodPages = NEIGHBORHOODS.map(n => ({
+      loc: `/neighborhood/${n.slug}`, changefreq: 'daily', priority: '0.7'
+    }));
 
     let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
-    for (const p of [...staticPages, ...verticalPages]) {
+    for (const p of [...staticPages, ...verticalPages, ...neighborhoodPages]) {
       xml += `  <url><loc>${baseUrl}${escape(p.loc)}</loc><lastmod>${today}</lastmod><changefreq>${p.changefreq}</changefreq><priority>${p.priority}</priority></url>\n`;
     }
     for (const b of businesses) {
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
index d1e353e..d9d041f 100644
--- a/views/partials/footer.ejs
+++ b/views/partials/footer.ejs
@@ -14,6 +14,14 @@
       <a href="/find?vertical=pet_services">Pet services</a>
       <a href="/find?vertical=creative">Creative</a>
     </div>
+    <div class="footer-col">
+      <h4>Neighborhoods</h4>
+      <a href="/neighborhood/sherman-oaks">Sherman Oaks</a>
+      <a href="/neighborhood/studio-city">Studio City</a>
+      <a href="/neighborhood/encino">Encino</a>
+      <a href="/neighborhood/tarzana">Tarzana</a>
+      <a href="/neighborhood/woodland-hills">Woodland Hills</a>
+    </div>
     <div class="footer-col">
       <h4>Businesses</h4>
       <a href="/for-businesses">List your business</a>
diff --git a/views/public/neighborhood.ejs b/views/public/neighborhood.ejs
new file mode 100644
index 0000000..d5bdf37
--- /dev/null
+++ b/views/public/neighborhood.ejs
@@ -0,0 +1,76 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<%
+  // BreadcrumbList JSON-LD: Home › Find › Neighborhood
+  var _publicUrl = (typeof publicUrl !== 'undefined' && publicUrl) ? publicUrl.replace(/\/+$/, '') : 'https://leads.venturaclaw.com';
+  var _ldCrumbs = {
+    '@context': 'https://schema.org',
+    '@type': 'BreadcrumbList',
+    itemListElement: [
+      { '@type': 'ListItem', position: 1, name: 'Home',  item: _publicUrl + '/' },
+      { '@type': 'ListItem', position: 2, name: 'Find a business', item: _publicUrl + '/find' },
+      { '@type': 'ListItem', position: 3, name: neighborhood.label, item: _publicUrl + '/neighborhood/' + neighborhood.slug }
+    ]
+  };
+%>
+<script type="application/ld+json"><%- JSON.stringify(_ldCrumbs) %></script>
+
+<nav class="breadcrumb" aria-label="Breadcrumb" style="padding:16px 28px 0;max-width:1440px;margin:0 auto;font-size:12px;color:var(--fg-muted);letter-spacing:0.04em">
+  <a href="/" style="border:none;color:var(--fg-muted)">Home</a>
+  <span style="margin:0 6px;opacity:0.5">›</span>
+  <a href="/find" style="border:none;color:var(--fg-muted)">Find</a>
+  <span style="margin:0 6px;opacity:0.5">›</span>
+  <span style="color:var(--fg)"><%= neighborhood.label %></span>
+</nav>
+
+<section class="find-page">
+  <p class="kicker">On Ventura Blvd</p>
+  <h1 class="display-sm">Businesses in <%= neighborhood.label %></h1>
+  <p class="lede"><%= businesses.length %> business<%= businesses.length === 1 ? '' : 'es' %> in <%= neighborhood.label %>. Search by name, browse by category, or jump straight to a profile and send a message.</p>
+
+  <div class="grid-controls" role="region" aria-label="Filters" style="margin-top:24px">
+    <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
+      <% verticals.forEach(function(v){ %>
+        <a href="/find?vertical=<%= v.key %>&city=<%= encodeURIComponent(neighborhood.label) %>" class="btn btn-ghost btn-sm" style="font-size:12px"><%= v.icon %> <%= v.label %></a>
+      <% }); %>
+    </div>
+  </div>
+
+  <% if (businesses.length === 0) { %>
+    <div style="padding:40px 0;text-align:center">
+      <h3>No businesses listed in <%= neighborhood.label %> yet.</h3>
+      <p class="muted">We're adding new businesses every week. <a href="/find">Browse all listings</a>.</p>
+    </div>
+  <% } else { %>
+    <div class="business-grid" id="grid" style="margin-top:24px">
+      <% businesses.forEach(function(b){
+           var vMeta = verticals.find(function(vv){return vv.key===b.vertical});
+      %>
+        <a class="business-card" href="/business/<%= b.slug %>">
+          <% if (b.photo_path) { %>
+            <div class="biz-card-hero biz-card-hero-photo">
+              <img src="<%= b.photo_path %>" alt="<%= b.business_name %>" loading="lazy" decoding="async">
+            </div>
+          <% } else { %>
+            <div class="biz-card-hero vertical-hero-<%= b.vertical %>" aria-hidden="true">
+              <span class="biz-card-glyph"><%= vMeta ? vMeta.icon : '•' %></span>
+            </div>
+          <% } %>
+          <p class="biz-vertical"><%= vMeta ? vMeta.label : b.vertical %></p>
+          <p class="biz-name"><%= b.business_name %></p>
+          <p class="biz-loc"><%= [b.neighborhood, b.city, b.state].filter(Boolean).join(' · ') %></p>
+          <% if (b.headline) { %><p class="biz-headline"><%= b.headline %></p><% } %>
+          <div class="biz-badges">
+            <% if (b.tier === 'premier') { %><span class="biz-badge is-premier">Featured</span>
+            <% } else if (b.tier === 'standard') { %><span class="biz-badge is-standard">Standard</span><% } %>
+            <% if (b.claim_status === 'self' || b.claim_status === 'claimed') { %><span class="biz-badge is-claimed">Claimed</span><% } %>
+            <% if (b.verified) { %><span class="biz-badge">Verified</span><% } %>
+          </div>
+        </a>
+      <% }); %>
+    </div>
+  <% } %>
+</section>
+
+<%- include('../partials/footer') %>

← 870584a yolo tick 25: /find auto-suggest dropdown — debounced AJAX,  ·  back to Ventura Claw Leads  ·  yolo tick 27: 'More <vertical> nearby' rail at the bottom of f40cfda →