From cd7f34031b7d5a4f25f636d38b044dd2d34795aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:54:19 -0800 Subject: [PATCH 1/2] Crossing titles and descriptions match search queries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JB1Z5Hg5wC7KmJ6iJtBSsy --- CHANGELOG.md | 8 +++++ scripts/check-faq-scope.mjs | 2 +- scripts/prerender.mjs | 4 +-- scripts/test-crossing-snapshot.mjs | 7 +++-- src/lib/crossingNames.js | 9 ------ src/lib/crossingSeo.js | 47 ++++++++++++++++++++++++++++++ src/pages/CrossingDetail.jsx | 11 ++++--- 7 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/lib/crossingSeo.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e504c8082..f97a85afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2026-11-10 - Crossing titles and descriptions match how people search (draft) + +- Crossing page titles now follow the query shape from Search Console: " border wait times today" (EN) and "Tiempos de espera en hoy" (ES). Bridges and ports of entry use the name people type: "Anzalduas International Bridge (Hidalgo/Pharr)", "Santa Teresa Port of Entry", "Andrade (Los Algodones)", "Eagle Pass International Bridge II" and others. +- Descriptions are built per crossing instead of one shared template: the search name, the hour with the shortest typical wait over the last 30 days, and cameras where they exist. One function feeds the prerendered page and the client. +- Why: Google Search Console, last 28 days: 51.1K impressions, 1.2% click rate, average position 9.6. "santa teresa port of entry" had 342 impressions and no clicks under the old "Santa Teresa · San Jerónimo" title. +- New `src/lib/crossingSeo.js`, loaded only by crossing pages and prerender, so the entry bundle stays at 61.89 KB gzip (budget 62.25). +- Checks: full `npm test` passes after a prerendered build. Measure: click rate per crossing page in Search Console 2 to 4 weeks after deploy. + ## 2026-11-10 - Spanish and accessibility polish (Phase 4, draft) - Spanish: the mobile menu button is announced as "Abrir menú" / "Cerrar menú" instead of English. The blog index title, "Todos" tab, filter label and empty state follow the site language; all posts stay listed by default so Spanish readers can still open English guides. FAQ wording "job programado" is now "tarea programada". diff --git a/scripts/check-faq-scope.mjs b/scripts/check-faq-scope.mjs index a280701af..5f00c6f2b 100644 --- a/scripts/check-faq-scope.mjs +++ b/scripts/check-faq-scope.mjs @@ -106,7 +106,7 @@ export function checkBuiltFaqScope(distDir = path.join(root, 'dist')) { { route: '/metodologia/', title: 'Metodología |', lang: 'es', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') }, { route: '/privacy/', title: 'Privacy and advertising |', hreflangs: twins('/privacy/', '/privacidad/') }, { route: '/privacidad/', title: 'Privacidad y publicidad |', lang: 'es', hreflangs: twins('/privacy/', '/privacidad/') }, - { route: '/crossing/san-ysidro/', title: 'San Ysidro: CBP wait times', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, + { route: '/crossing/san-ysidro/', title: 'San Ysidro border wait times today', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, { route: '/blog/', title: 'Border Pulse Blog |', types: ['Blog', 'BreadcrumbList'] }, { route: enArticle, title: 'San Ysidro vs Otay Mesa:', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, { route: esArticle, title: 'San Ysidro vs Otay Mesa:', lang: 'es', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index b48af39aa..a648da2ee 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -11,7 +11,7 @@ import { COMPARE_PAIRS } from '../src/lib/comparePairs.js'; import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js'; import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs'; import { SPANISH_HOME } from '../src/lib/homeMeta.js'; -import { crossingSearchTitle } from '../src/lib/crossingNames.js'; +import { crossingSearchDescription, crossingSearchTitle } from '../src/lib/crossingSeo.js'; import { cameraSourceFor } from '../src/lib/cameras.js'; import { googlePrivacyNotice } from '../src/lib/googlePrivacyNotice.js'; import { privacyParagraphs } from '../src/lib/privacyParagraphs.js'; @@ -56,7 +56,7 @@ function renderCrossingHead(crossing, slug, aggregate) { // Title pattern matches what wins these SERPs: keyword-first, "today"/live // freshness signal, current year. Brand goes last where truncation is cheap. const title = crossingSearchTitle(crossing, 'en', cameraSourceFor(crossing.port_number)?.kind); - const desc = `Reported ${crossing.name} border wait time from U.S. Customs and Border Protection, plus 30-day hour-by-hour patterns, port hours, and historical standard-passenger wait patterns.`; + const desc = crossingSearchDescription(crossing, 'en', { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraSourceFor(crossing.port_number)?.kind) }); const canonical = `${BASE}/crossing/${slug}/`; const ogImage = `${BASE}/og/${slug}.png`; diff --git a/scripts/test-crossing-snapshot.mjs b/scripts/test-crossing-snapshot.mjs index 1fa5cd16d..84dbaebd0 100644 --- a/scripts/test-crossing-snapshot.mjs +++ b/scripts/test-crossing-snapshot.mjs @@ -6,7 +6,8 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs'; -import { crossingDisplayName, crossingSearchTerms, crossingSearchTitle } from '../src/lib/crossingNames.js'; +import { crossingDisplayName, crossingSearchTerms } from '../src/lib/crossingNames.js'; +import { crossingSearchTitle } from '../src/lib/crossingSeo.js'; const crossing = { port_number: '240202', name: 'El Paso - Paso Del Norte (PDN)', hours: '24 hrs/day', port_status: 'Open', lanes: { passenger_standard: { status: 'No Delay', lanes_open: 1, delay_minutes: 0, update_time: 'At 6:00 pm MDT' } } }; @@ -79,8 +80,8 @@ test('local naming helps search without changing official identity or canonical assert.match(crossingDisplayName(crossing), /Santa Fe/); assert.ok(crossingSearchTerms({ port_number: '240201', name: 'BOTA' }).includes('puente libre')); assert.ok(crossingSearchTerms({ port_number: '240801', name: 'Santa Teresa' }).includes('san jeronimo')); - assert.match(crossingSearchTitle(crossing, 'en', true), /CBP wait times & cameras/); - assert.match(crossingSearchTitle(crossing, 'es', true), /esperas CBP y cámaras/); + assert.match(crossingSearchTitle(crossing, 'en', true), /wait times today & cameras/); + assert.match(crossingSearchTitle(crossing, 'es', true), /hoy y cámaras/); }); test('built homepage and every crossing page contain their own useful visible snapshot', () => { if (!fs.existsSync('dist/index.html')) return; diff --git a/src/lib/crossingNames.js b/src/lib/crossingNames.js index 063992070..9f79cd920 100644 --- a/src/lib/crossingNames.js +++ b/src/lib/crossingNames.js @@ -16,12 +16,3 @@ export function crossingSearchTerms(crossing) { LOCAL_NAMES[String(crossing?.port_number)]?.terms].filter(Boolean).join(' ') .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); } - -export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) { - const name = crossingDisplayName(crossing, language); - const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : ''; - const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : ''; - return language === 'es' - ? `${name}: esperas CBP${esCamera} | Border Pulse` - : `${name}: CBP wait times${enCamera} | Border Pulse`; -} diff --git a/src/lib/crossingSeo.js b/src/lib/crossingSeo.js new file mode 100644 index 000000000..53dde666b --- /dev/null +++ b/src/lib/crossingSeo.js @@ -0,0 +1,47 @@ +import { crossingDisplayName } from './crossingNames.js'; + +// How people actually search for a crossing (Search Console queries), used only +// in titles and descriptions. Unlisted crossings fall back to the display name. +const SEARCH_NAMES = { + '250201': 'Andrade (Los Algodones)', + '230503': 'Anzalduas International Bridge (Hidalgo/Pharr)', + '230902': 'Donna International Bridge (Progreso)', + '230901': 'Progreso International Bridge', + '535503': 'Los Indios International Bridge (Brownsville)', + '535502': 'Veterans International Bridge (Brownsville)', + '535504': 'Gateway International Bridge (Brownsville)', + '535501': 'B&M Bridge (Brownsville)', + '230301': 'Eagle Pass International Bridge I', + '230302': 'Eagle Pass International Bridge II', + '240801': 'Santa Teresa Port of Entry', + '240401': 'Tornillo Port of Entry (Marcelino Serna)', + '240301': 'Presidio Port of Entry', + '260401': 'Nogales DeConcini', + '260402': 'Nogales Mariposa', + '260403': 'Nogales Morley Gate', +}; +export function crossingSearchName(crossing, language = 'en') { + return SEARCH_NAMES[String(crossing?.port_number)] || crossingDisplayName(crossing, language); +} +const hasPlaceWord = (name) => /bridge|port of entry|puente|garita/i.test(name); + +// Title mirrors the query shape: " border wait times today". +export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) { + const name = crossingSearchName(crossing, language); + const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : ''; + const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : ''; + return language === 'es' + ? `Tiempos de espera en ${name} hoy${esCamera} | Border Pulse` + : `${name}${hasPlaceWord(name) ? '' : ' border'} wait times today${enCamera} | Border Pulse`; +} + +// One description for the prerendered page and the client, built from the +// crossing's own data: its search name, its historical quietest hour, cameras. +export function crossingSearchDescription(crossing, language = 'en', { bestHour = null, hasCameras = false } = {}) { + const name = crossingSearchName(crossing, language); + const hour = Number.isInteger(bestHour) ? `${bestHour % 12 || 12} ${bestHour >= 12 ? 'PM' : 'AM'}` : null; + if (language === 'es') { + return `Tiempo de espera actual en ${name} según CBP, por carril.${hour ? ` En los últimos 30 días, la espera típica más corta fue cerca de las ${hour}.` : ''}${hasCameras ? ' Incluye cámaras de la zona.' : ''} Horarios del puerto y tendencias históricas por hora.`; + } + return `Current ${name} wait time from CBP, by lane.${hour ? ` Over the last 30 days, the shortest typical wait came around ${hour}.` : ''}${hasCameras ? ' Includes nearby road cameras.' : ''} Port hours and historical trends by hour.`; +} diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index bd2111879..c05b25346 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -11,7 +11,8 @@ import EmbedSnippetModal from '@/components/dashboard/EmbedSnippetModal'; import CrossingShare from '@/components/dashboard/CrossingShare'; import CrossingCameras from '@/components/dashboard/CrossingCameras'; import { cameraSourceFor } from '@/lib/cameras'; -import { crossingDisplayName, crossingSearchTitle } from '@/lib/crossingNames'; +import { crossingDisplayName } from '@/lib/crossingNames'; +import { crossingSearchDescription, crossingSearchTitle } from '@/lib/crossingSeo'; import SantaTeresaActions from '@/components/dashboard/SantaTeresaActions'; import SanYsidroDecision from '@/components/dashboard/SanYsidroDecision'; import { subscribeToBorderData } from '@/lib/liveDataSubscription'; @@ -392,11 +393,9 @@ export default function CrossingDetail() { useEffect(() => { if (!crossing) return; - const hasHistoricalPattern = Array.isArray(aggregate?.by_hour) && aggregate.by_hour.length > 0; - const title = crossingSearchTitle(crossing, language, cameraSourceFor(crossing.port_number)?.kind); - const desc = language === 'en' - ? `Reported ${crossing.name} border wait times, refreshed regularly. Official CBP data${hasHistoricalPattern ? ', historical trends, and best crossing times' : ', lane status, and port hours'}.` - : `Tiempos de espera en ${crossing.name}, actualizados con regularidad. Datos oficiales de CBP${hasHistoricalPattern ? ', tendencias históricas y mejores horarios para cruzar' : ', estado de carriles y horarios del puerto'}.`; + const cameraKind = cameraSourceFor(crossing.port_number)?.kind; + const title = crossingSearchTitle(crossing, language, cameraKind); + const desc = crossingSearchDescription(crossing, language, { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraKind) }); const url = `https://borderpulse.com/crossing/${canonicalSlug}/`; updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, canonical: url }); return () => resetPageMeta(); From 713c42ed7ac7c1043ed73124ad6f271b32cc5ce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 19:01:20 -0800 Subject: [PATCH 2/2] Sponsored listings with click tracking; first sponsor in Juarez Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JB1Z5Hg5wC7KmJ6iJtBSsy --- CHANGELOG.md | 8 ++++ src/components/blog/CrossingWaitLinks.jsx | 5 +++ src/components/sponsors/SponsoredCard.jsx | 49 +++++++++++++++++++++++ src/lib/analytics.js | 3 ++ src/lib/privacyParagraphs.js | 4 +- src/lib/sponsors.js | 42 +++++++++++++++++++ src/pages/CrossingDetail.jsx | 4 ++ 7 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/components/sponsors/SponsoredCard.jsx create mode 100644 src/lib/sponsors.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e504c8082..f66db5cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2026-11-10 - Sponsored listings, first sponsor (draft) + +- New `SponsoredCard` and `src/lib/sponsors.js`: one labeled sponsor per placement, set by port number or guide slug, with optional `since`/`until` dates. Each card is marked Sponsored/Patrocinado and says Border Pulse has not evaluated the business's care. +- First sponsor: Constandse Esthetic & Dent (Ciudad Juárez dentist), on the five El Paso/Juárez crossing pages (above the ad) and on both medication guides (under the current-wait links). Links carry UTM tags; clicks log `sponsor-click` with sponsor, placement and action (site or WhatsApp), no free text. +- Privacy notice (EN/ES) now says clicks on sponsored listings are counted with Umami and that sponsored listings are labeled placements from local businesses. +- Checks: full `npm test` passes after a prerendered build; entry bundle 62.02 KB gzip (budget 62.25). Headless Chrome 390px: card on PDN and both guides, none on San Ysidro, click logs the event, no overflow. +- Open: the site link points at the clinic's preview deployment until its own domain is live. + ## 2026-11-10 - Spanish and accessibility polish (Phase 4, draft) - Spanish: the mobile menu button is announced as "Abrir menú" / "Cerrar menú" instead of English. The blog index title, "Todos" tab, filter label and empty state follow the site language; all posts stay listed by default so Spanish readers can still open English guides. FAQ wording "job programado" is now "tarea programada". diff --git a/src/components/blog/CrossingWaitLinks.jsx b/src/components/blog/CrossingWaitLinks.jsx index 777265898..a2452b7da 100644 --- a/src/components/blog/CrossingWaitLinks.jsx +++ b/src/components/blog/CrossingWaitLinks.jsx @@ -3,6 +3,8 @@ import { Link, useParams } from 'react-router-dom'; import { ArrowRight } from 'lucide-react'; import { LangContext } from '@/lib/LangContext'; import { track } from '@/lib/analytics'; +import SponsoredCard from '@/components/sponsors/SponsoredCard'; +import { sponsorForGuide } from '@/lib/sponsors'; // Compact current-wait links near the top of a guide, for readers who skim. // items: [[crossingSlug, label], ...]. Labels are display text only. @@ -11,6 +13,7 @@ export default function CrossingWaitLinks({ items = [] }) { const { slug } = useParams(); const es = lang === 'es'; return ( + <> + + ); } diff --git a/src/components/sponsors/SponsoredCard.jsx b/src/components/sponsors/SponsoredCard.jsx new file mode 100644 index 000000000..56997ee0f --- /dev/null +++ b/src/components/sponsors/SponsoredCard.jsx @@ -0,0 +1,49 @@ +import { ExternalLink, MessageCircle } from 'lucide-react'; +import { sponsorUrl } from '@/lib/sponsors'; +import { track } from '@/lib/analytics'; + +// A labeled paid placement. Clicks log `sponsor-click` (sponsor id, placement, +// action) so each sponsor gets a monthly count. No free text is sent. +export default function SponsoredCard({ sponsor, placement, language = 'en' }) { + if (!sponsor) return null; + const es = language === 'es'; + const log = action => track('sponsor-click', { sponsor: sponsor.id, placement, action }); + return ( + + ); +} diff --git a/src/lib/analytics.js b/src/lib/analytics.js index cb38d1277..875e71ef6 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -29,6 +29,9 @@ // blog-share guide distribution; slug and method=copy|whatsapp // blog-to-waits guide reader opening current waits; crossing + placement=top for the in-guide links // +// Sponsors: +// sponsor-click sponsor id, placement (crossing- | guide-), action=site|whatsapp +// // Diagnostics (not audited ad-network statistics): // ad-slot-diagnostic slot=dashboard-native; state=script-ready|script-error| // container-populated|slot-visible. At most once per diff --git a/src/lib/privacyParagraphs.js b/src/lib/privacyParagraphs.js index 85c3b8f54..9a267712f 100644 --- a/src/lib/privacyParagraphs.js +++ b/src/lib/privacyParagraphs.js @@ -9,8 +9,8 @@ const paragraphs = { "en": "Border Pulse works without an account. Language, theme, and filter preferences may be saved in your browser’s local storage. Wait times come from the public U.S. Customs and Border Protection feed." }, "analytics": { - "es": "Las vistas de página se miden con Umami, una analítica sin cookies. Border Pulse no recibe documentos de viaje ni ubicación precisa.", - "en": "Page views are measured with Umami, a cookieless analytics service. Border Pulse does not receive travel documents or precise location." + "es": "Las vistas de página y los clics en anuncios patrocinados se miden con Umami, una analítica sin cookies. Los anuncios patrocinados son espacios de negocios locales y están marcados como tales. Border Pulse no recibe documentos de viaje ni ubicación precisa.", + "en": "Page views and clicks on sponsored listings are measured with Umami, a cookieless analytics service. Sponsored listings are placements from local businesses and are labeled as such. Border Pulse does not receive travel documents or precise location." }, "adsterra": { "es": "Border Pulse muestra anuncios de terceros para ayudar a mantener la plataforma. El panel principal, las páginas de cruces y las guías cargan el script publicitario de Adsterra; los anuncios pueden no mostrarse si el servicio o el navegador los bloquea.", diff --git a/src/lib/sponsors.js b/src/lib/sponsors.js new file mode 100644 index 000000000..2d7aa8b46 --- /dev/null +++ b/src/lib/sponsors.js @@ -0,0 +1,42 @@ +// Sponsored placements from local businesses. One sponsor per placement; every card +// is labeled Sponsored. Keep entries factual: name, city, links. No medical or +// price claims in Border Pulse's voice; the business owns its own claims. +// Remove an entry (or set `until`) to end a placement. +export const SPONSORS = [ + { + id: 'constandse-dental', + name: 'Constandse Esthetic & Dent', + category: { en: 'Dentist', es: 'Dentista' }, + city: 'Ciudad Juárez', + blurb: { + en: 'Dental clinic in Ciudad Juárez, a short trip from the El Paso bridges.', + es: 'Clínica dental en Ciudad Juárez, a poca distancia de los puentes de El Paso.', + }, + url: 'https://dr-constandse-preview.vercel.app/', + whatsapp: '19153007751', + // Ciudad Juárez crossings: PDN, BOTA, Ysleta, Stanton DCL, El Paso (combined). + ports: ['240202', '240201', '240104', '240204', '240221'], + guides: ['bringing-prescription-medication-back-from-mexico', 'traer-medicamentos-con-receta-de-mexico'], + since: '2026-11-10', + until: null, + }, +]; + +const active = (s, today) => (!s.since || s.since <= today) && (!s.until || today <= s.until); +const todayIso = () => new Date().toISOString().slice(0, 10); + +export function sponsorForPort(portNumber, today = todayIso()) { + return SPONSORS.find(s => active(s, today) && s.ports?.includes(String(portNumber))) || null; +} +export function sponsorForGuide(slug, today = todayIso()) { + return SPONSORS.find(s => active(s, today) && s.guides?.includes(slug)) || null; +} + +// Outbound link with UTM so the clinic can see Border Pulse in its own analytics. +export function sponsorUrl(sponsor, placement) { + const u = new URL(sponsor.url); + u.searchParams.set('utm_source', 'borderpulse'); + u.searchParams.set('utm_medium', 'sponsored'); + u.searchParams.set('utm_campaign', placement); + return u.toString(); +} diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index bd2111879..33f19c51b 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -30,6 +30,8 @@ import { hasPedestrianLane } from '@/lib/crossingAvailability'; import { pickLightestHour } from '@/lib/recommendations'; import { track } from '@/lib/analytics'; import AdsterraBanner from '@/components/ads/AdsterraBanner'; +import SponsoredCard from '@/components/sponsors/SponsoredCard'; +import { sponsorForPort } from '@/lib/sponsors'; const DAY_LABELS = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], @@ -588,6 +590,8 @@ export default function CrossingDetail() { ); })()} + + {aggregate && aggregate.by_hour && aggregate.by_hour.length > 0 && (