diff --git a/CHANGELOG.md b/CHANGELOG.md index 162d25b9..1756fb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2026-11-11 - City pages (Phase 3); one checkout link per spot + +- New bilingual city pages `/city/:id/` and `/ciudad/:id/` for Ciudad Juárez, Tijuana, Los Algodones, Nogales and Nuevo Progreso: links to that city's crossing waits, then one section per category with its sponsor card or an open-spot link to checkout. Prerendered, hreflang-paired, in the sitemap. No network ad on these pages. Crossing pages in those cities link to their city page under the sponsor or ad spot; the feature page links each city name. +- Spot protection: each open spot now has its own Stripe Payment Link limited to one completed purchase, so Stripe retires the link after the sale and a second buyer sees "this spot was just taken". Links live in `SLOT_CHECKOUT` in `src/lib/sponsors.js`. A spot with no link shows as taken on the feature page and as "reserved, in review" on its city page. The earlier shared dropdown link is retired. +- Checks: full `npm test` passes after a prerendered build. Entry JS 62.16 KB gzip (budget 62.25), CSS 19.93 KB (budget 20). Headless Chrome 390px: Juárez city page shows Juan's card and 5 open spots; Tijuana (ES) shows 6; PDN shows the city link. + ## 2026-11-11 - Sponsor card stands out; self-serve "Feature your business" page - Sponsor card redesign: green-tinted card with an accent edge, larger business name, the services the business lists (as chips), an optional business offer with an end date (hidden after it), and WhatsApp as the main filled button. Still labeled Sponsored/Patrocinado with the "has not evaluated" line, still below the answer and never alongside an ad. diff --git a/DECISIONS.md b/DECISIONS.md index 9a98c4d4..034a9805 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -589,3 +589,7 @@ Sponsor reporting counts what the existing Umami property records: clicks on the ## 2026-11-11 - Self-serve sponsor listings before 2 to 3 paying sponsors The roadmap said to wait for 2 to 3 paying sponsors before a self-serve page. Sebastian approved building it now so businesses can buy without a call. A listing is one business per category per city, $99/month through a Stripe Payment Link. Every paid listing is reviewed before it is added to `src/lib/sponsors.js`, and refunded if not approved. Rotation is by day, not per view, so prerendered pages and the client agree. Business offers are shown attributed to the business and with an end date. + +## 2026-11-11 - City pages carry several labeled sponsors; one checkout link per spot + +City pages are directories, so they list one labeled sponsor per category instead of following the one-spot-per-page rule, and they carry no network ad. Each open spot has its own Stripe Payment Link with a one-purchase limit, so a spot cannot be sold twice without a server or webhook. After a sale, remove that slot from `SLOT_CHECKOUT` (it then shows as reserved), review the listing, then add the sponsor or refund. If a sponsor cancels, create a fresh link for the slot. Rare edge: two checkouts already open at the same moment can both complete; the second is refunded under the review promise. diff --git a/ROADMAP.md b/ROADMAP.md index e1ef69f3..127711f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -345,7 +345,8 @@ Snapshot of features live on borderpulse.com today. - Pharmacy sponsor for the medication guides (highest-traffic slot). Not being pitched yet; needs a trusted contact and a vetting pass first. - City guides as sponsor inventory: Tijuana, Los Algodones, Nuevo Progreso, Nogales. One featured business per category section. - [x] Self-serve "feature your business" page (2026-11-11, shipped early at Sebastian's call). -- Next: a city directory page per city listing every active sponsor by category (Phase 3), then the return-trip guide (Phase 4). +- [x] City directory pages, one per sponsor city (Phase 3, 2026-11-11). +- Next: the return-trip guide (Phase 4): what to declare, return wait. - Swap the first sponsor's link to its own domain once it has one (currently constandse-dental.vercel.app). ### Shipped 2026-11-10 diff --git a/scripts/build-sitemap.mjs b/scripts/build-sitemap.mjs index 27de5bf5..d2e35267 100644 --- a/scripts/build-sitemap.mjs +++ b/scripts/build-sitemap.mjs @@ -78,6 +78,16 @@ async function main() { { loc: `${BASE}/feature-your-business/`, changefreq: 'monthly', priority: '0.4', lastmod: today, alternates: featureAlternates }, { loc: `${BASE}/anunciate/`, changefreq: 'monthly', priority: '0.4', lastmod: today, alternates: featureAlternates }, ]; + const { CITIES } = await import('../src/lib/sponsors.js'); + for (const city of CITIES) { + const alternates = [ + { lang: 'en', href: `${BASE}/city/${city.id}/` }, + { lang: 'es', href: `${BASE}/ciudad/${city.id}/` }, + { lang: 'x-default', href: `${BASE}/city/${city.id}/` }, + ]; + urls.push({ loc: `${BASE}/city/${city.id}/`, changefreq: 'weekly', priority: '0.6', lastmod: today, alternates }); + urls.push({ loc: `${BASE}/ciudad/${city.id}/`, changefreq: 'weekly', priority: '0.6', lastmod: today, alternates }); + } for (const c of crossings) { const slug = portToSlug[c.port_number]; if (!slug) continue; diff --git a/scripts/check-hreflang.mjs b/scripts/check-hreflang.mjs index 720bb246..098a6764 100644 --- a/scripts/check-hreflang.mjs +++ b/scripts/check-hreflang.mjs @@ -26,6 +26,10 @@ export function checkBuiltHreflangs(dist = path.join(root, 'dist')) { ['/privacidad/', pair('/privacy/', '/privacidad/')], ['/feature-your-business/', pair('/feature-your-business/', '/anunciate/')], ['/anunciate/', pair('/feature-your-business/', '/anunciate/')], + ...['juarez', 'tijuana', 'algodones', 'nogales', 'progreso'].flatMap((c) => [ + [`/city/${c}/`, pair(`/city/${c}/`, `/ciudad/${c}/`)], + [`/ciudad/${c}/`, pair(`/city/${c}/`, `/ciudad/${c}/`)], + ]), ]); for (const post of posts) { const twins = post.translationKey ? posts.filter((p) => p.translationKey === post.translationKey) : []; diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 5e63855b..553e2203 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -723,6 +723,39 @@ async function main() { } } + // /city/:id + /ciudad/:id — one directory page per sponsor city, hreflang-paired. + { + const { CITIES } = await import(path.resolve(root, 'src/lib/sponsors.js')); + for (const city of CITIES) { + const enCanonical = `${BASE}/city/${city.id}/`; + const esCanonical = `${BASE}/ciudad/${city.id}/`; + const hreflangs = [ + { lang: 'en', href: enCanonical }, + { lang: 'es', href: esCanonical }, + { lang: 'x-default', href: enCanonical }, + ]; + const heads = [ + [['city', city.id], { + lang: 'en', + title: `${city.name}: border crossings and local businesses | Border Pulse`, + desc: `Wait times for the crossings into ${city.name} from ${city.from.en}, and local businesses by category.`, + canonical: enCanonical, ogImage: `${BASE}/og-card.png`, jsonLd: [], hreflangs, + }], + [['ciudad', city.id], { + lang: 'es', + title: `${city.name}: cruces y negocios locales | Border Pulse`, + desc: `Tiempos de espera de los cruces a ${city.name} desde ${city.from.es}, y negocios locales por categoría.`, + canonical: esCanonical, ogImage: `${BASE}/og-card.png`, jsonLd: [], hreflangs, + }], + ]; + for (const [parts, head] of heads) { + const outDir = path.resolve(distDir, ...parts); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.resolve(outDir, 'index.html'), rewriteIndex(indexWithLinks, head)); + } + } + } + // /about — trust signal page with methodology + public stats + privacy { const aboutHead = { diff --git a/src/App.jsx b/src/App.jsx index 98c920ad..1db274df 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -24,6 +24,7 @@ const Privacy = lazy(() => import('@/pages/Privacy')); const CrossingConsole = lazy(() => import('@/pages/CorridorRedirect')); const LaneFlip = lazy(() => import('@/pages/LaneFlip')); const FeatureBusiness = lazy(() => import('@/pages/FeatureBusiness')); +const CityGuide = lazy(() => import('@/pages/CityGuide')); function RouteFallback() { return ( @@ -58,6 +59,8 @@ function LayoutRoutes() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/Layout.jsx b/src/Layout.jsx index 71a9d62c..021bcf29 100644 --- a/src/Layout.jsx +++ b/src/Layout.jsx @@ -48,6 +48,7 @@ export default function Layout({ children }) { useEffect(() => { const pageOwnsLanguage = /^\/blog\/[^/]+\/?$/.test(location.pathname) || + /^\/(city|ciudad)\/[^/]+\/?$/.test(location.pathname) || /^\/(methodology|metodologia|privacy|privacidad|feature-your-business|anunciate|es)\/?$/.test(location.pathname); if (pageOwnsLanguage) return; document.documentElement.lang = language; diff --git a/src/lib/sponsors.js b/src/lib/sponsors.js index c7f1cbc0..ff263d8d 100644 --- a/src/lib/sponsors.js +++ b/src/lib/sponsors.js @@ -6,16 +6,42 @@ // Cities a business can sponsor, and the crossing pages that show its card. export const CITIES = [ - // PDN, BOTA, Ysleta, Stanton DCL, El Paso (combined). - { id: 'juarez', name: 'Ciudad Juárez', ports: ['240202', '240201', '240104', '240204', '240221'] }, - // San Ysidro, Otay Mesa. - { id: 'tijuana', name: 'Tijuana', ports: ['250401', '250601'] }, - // Andrade. - { id: 'algodones', name: 'Los Algodones', ports: ['250201'] }, - // DeConcini, Mariposa, Morley Gate. - { id: 'nogales', name: 'Nogales', ports: ['260401', '260402', '260403'] }, - // Progreso International Bridge, Donna. - { id: 'progreso', name: 'Nuevo Progreso', ports: ['230901', '230902'] }, + { + id: 'juarez', name: 'Ciudad Juárez', from: { en: 'El Paso', es: 'El Paso' }, + // PDN, BOTA, Ysleta, Stanton DCL, El Paso (combined). + ports: ['240202', '240201', '240104', '240204', '240221'], + crossings: [ + ['el-paso-paso-del-norte-pdn', 'Paso del Norte (PDN)'], + ['el-paso-bridge-of-the-americas-bota', 'Bridge of the Americas (BOTA)'], + ['ysleta', 'Ysleta (Zaragoza)'], + ['el-paso-stanton-dcl', 'Stanton DCL'], + ], + }, + { + id: 'tijuana', name: 'Tijuana', from: { en: 'San Diego', es: 'San Diego' }, + ports: ['250401', '250601'], + crossings: [['san-ysidro', 'San Ysidro'], ['otay-mesa', 'Otay Mesa']], + }, + { + id: 'algodones', name: 'Los Algodones', from: { en: 'Yuma', es: 'Yuma' }, + ports: ['250201'], + crossings: [['andrade', 'Andrade']], + }, + { + id: 'nogales', name: 'Nogales', from: { en: 'Nogales, Arizona', es: 'Nogales, Arizona' }, + ports: ['260401', '260402', '260403'], + crossings: [ + ['nogales-deconcini', 'DeConcini'], ['nogales-mariposa', 'Mariposa'], ['nogales-morley-gate', 'Morley Gate'], + ], + }, + { + id: 'progreso', name: 'Nuevo Progreso', from: { en: 'the Rio Grande Valley', es: 'el Valle del Río Grande' }, + ports: ['230901', '230902'], + crossings: [ + ['progreso-progreso-international-bridge', 'Progreso International Bridge'], + ['progreso-donna-international-bridge', 'Donna International Bridge'], + ], + }, ]; export const CATEGORIES = [ @@ -27,10 +53,43 @@ export const CATEGORIES = [ { id: 'beauty', en: 'Salon & beauty', es: 'Salón y belleza' }, ]; -// Stripe Payment Link for the self-serve listing. The buyer picks city and -// category there; Border Pulse reviews each listing before adding it below. +// One Stripe Payment Link per open spot, each limited to one completed +// purchase. Stripe deactivates a link after its sale, so a spot cannot be sold +// twice; a second buyer sees "this spot was just taken". Border Pulse reviews +// each listing before adding it below, then removes that slot from this map. export const LISTING_PRICE_USD = 99; -export const CHECKOUT_URL = 'https://buy.stripe.com/dRmaEW9c7fm97TMdLF9AA03'; +export const SLOT_CHECKOUT = { + 'juarez-pharmacy': 'https://buy.stripe.com/aFa28qagb7TH6PI22X9AA04', + 'juarez-medical': 'https://buy.stripe.com/00weVc73Zei50rk8rl9AA05', + 'juarez-food': 'https://buy.stripe.com/fZudR8agbde18XQ22X9AA06', + 'juarez-auto': 'https://buy.stripe.com/cNi14m4VR5Lz8XQePJ9AA07', + 'juarez-beauty': 'https://buy.stripe.com/6oUaEW8830rf5LEdLF9AA08', + 'tijuana-dental': 'https://buy.stripe.com/14AcN4dsn2zn0rk5f99AA09', + 'tijuana-pharmacy': 'https://buy.stripe.com/6oUcN44VRgqd8XQ3719AA0a', + 'tijuana-medical': 'https://buy.stripe.com/6oUdR8coj6PD8XQcHB9AA0b', + 'tijuana-food': 'https://buy.stripe.com/eVqaEWagb8XL7TMePJ9AA0c', + 'tijuana-auto': 'https://buy.stripe.com/cNiaEWdsn5Lz3DwcHB9AA0d', + 'tijuana-beauty': 'https://buy.stripe.com/00w4gy0FB1vja1Uazt9AA0e', + 'algodones-dental': 'https://buy.stripe.com/6oU8wObkf6PD7TM5f99AA0f', + 'algodones-pharmacy': 'https://buy.stripe.com/14A6oGcojc9X1voePJ9AA0g', + 'algodones-medical': 'https://buy.stripe.com/dRmdR85ZVei5eiadLF9AA0h', + 'algodones-food': 'https://buy.stripe.com/eVqdR83RNb5Tca2fTN9AA0i', + 'algodones-auto': 'https://buy.stripe.com/6oU8wObkfc9Xde63719AA0j', + 'algodones-beauty': 'https://buy.stripe.com/fZueVc4VR6PDde6ePJ9AA0k', + 'nogales-dental': 'https://buy.stripe.com/cNi14mewr1vj0rkgXR9AA0l', + 'nogales-pharmacy': 'https://buy.stripe.com/14A28q3RN7THca25f99AA0m', + 'nogales-medical': 'https://buy.stripe.com/8x2bJ0gEz6PD5LE8rl9AA0n', + 'nogales-food': 'https://buy.stripe.com/6oU5kCgEz0rfca2ePJ9AA0o', + 'nogales-auto': 'https://buy.stripe.com/aFa7sKagbde1fme7nh9AA0p', + 'nogales-beauty': 'https://buy.stripe.com/5kQ5kCcoj8XL1vo8rl9AA0q', + 'progreso-dental': 'https://buy.stripe.com/dRmeVc9c7a1P4HAfTN9AA0r', + 'progreso-pharmacy': 'https://buy.stripe.com/7sY4gy9c7a1P5LE22X9AA0s', + 'progreso-medical': 'https://buy.stripe.com/dRm3cu3RN0rffme6jd9AA0t', + 'progreso-food': 'https://buy.stripe.com/4gMeVc4VR1vj0rkdLF9AA0u', + 'progreso-auto': 'https://buy.stripe.com/14A00i883gqdfmecHB9AA0v', + 'progreso-beauty': 'https://buy.stripe.com/4gM7sK73Za1P8XQ4b59AA0w', +}; +export const checkoutFor = slot => SLOT_CHECKOUT[slot] || null; export const SPONSORS = [ { @@ -69,6 +128,11 @@ const active = (s, today) => (!s.since || s.since <= today) && (!s.until || toda const todayIso = () => new Date().toISOString().slice(0, 10); const dayNumber = today => Math.floor(Date.parse(`${today}T00:00:00Z`) / 86400000); +export function sponsorFor(cityId, categoryId, today = todayIso()) { + return activeSponsors(today).find(s => s.city === cityId && s.category === categoryId) || null; +} + +export const cityForPort = portNumber => CITIES.find(c => c.ports.includes(String(portNumber))) || null; export const cityById = id => CITIES.find(c => c.id === id) || null; export const categoryById = id => CATEGORIES.find(c => c.id === id) || null; @@ -90,8 +154,10 @@ export function sponsorForGuide(slug, today = todayIso()) { return activeSponsors(today).find(s => s.guides?.includes(slug)) || null; } +// Taken: a live sponsor holds it, or its checkout link is retired (sold, in review). export function slotTaken(cityId, categoryId, today = todayIso()) { - return activeSponsors(today).some(s => s.city === cityId && s.category === categoryId); + return activeSponsors(today).some(s => s.city === cityId && s.category === categoryId) + || !checkoutFor(`${cityId}-${categoryId}`); } export function offerFor(sponsor, today = todayIso()) { diff --git a/src/pages/CityGuide.jsx b/src/pages/CityGuide.jsx new file mode 100644 index 00000000..51fa0c5a --- /dev/null +++ b/src/pages/CityGuide.jsx @@ -0,0 +1,130 @@ +import React, { useEffect } from 'react'; +import { Link, Navigate, useParams } from 'react-router-dom'; +import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'; +import { updatePageMeta, resetPageMeta } from '@/lib/seo'; +import { track } from '@/lib/analytics'; +import SponsoredCard from '@/components/sponsors/SponsoredCard'; +import { CITIES, CATEGORIES, checkoutFor, cityById, sponsorFor } from '@/lib/sponsors'; + +// One page per border city: the crossings that serve it, then one section per +// business category. A category shows its sponsor (labeled) or an open spot. +// No network ad on these pages; the listings are the commercial content. + +export function cityMeta(city, es) { + const title = es + ? `${city.name}: cruces y negocios locales | Border Pulse` + : `${city.name}: border crossings and local businesses | Border Pulse`; + const desc = es + ? `Tiempos de espera de los cruces a ${city.name} desde ${city.from.es}, y negocios locales por categoría.` + : `Wait times for the crossings into ${city.name} from ${city.from.en}, and local businesses by category.`; + return { title, desc }; +} + +function checkoutHref(slot) { + const u = new URL(checkoutFor(slot)); + u.searchParams.set('client_reference_id', slot); + return u.toString(); +} + +export default function CityGuide({ lang = 'en' }) { + const { city: cityId } = useParams(); + const city = cityById(cityId); + const es = lang === 'es'; + const l = es ? 'es' : 'en'; + + useEffect(() => { + if (!city) return undefined; + const { title, desc } = cityMeta(city, es); + const canonical = `https://borderpulse.com/${es ? 'ciudad' : 'city'}/${city.id}/`; + updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: canonical, canonical, lang: l }); + return () => resetPageMeta(); + }, [city, es, l]); + + if (!city) return ; + + const section = 'rounded-xl border border-slate-200 bg-white/80 p-4 dark:border-gray-700 dark:bg-gray-900/50 sm:p-5'; + const others = CITIES.filter(c => c.id !== city.id); + + return ( +
+
+ + {es ? 'Todos los cruces' : 'All crossings'} + +
+ +
+

+ {es ? `${city.name}: cruces y negocios locales` : `${city.name}: crossings and local businesses`} +

+

+ {es + ? `Revisa la espera antes de salir de ${city.from.es}, y encuentra negocios en ${city.name} por categoría.` + : `Check the wait before you leave ${city.from.en}, and find businesses in ${city.name} by category.`} +

+
+ +
+
+

+ {es ? 'Tiempos de espera ahora' : 'Wait times now'} +

+
    + {city.crossings.map(([slug, label]) => ( +
  • + track('city-to-waits', { city: city.id, crossing: slug })} + className="flex min-h-11 items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-900 hover:bg-slate-50 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-800" + > + {label}
  • + ))} +
+
+ + {CATEGORIES.map(cat => { + const sponsor = sponsorFor(city.id, cat.id); + const slot = `${city.id}-${cat.id}`; + return ( +
+

{cat[l]}

+ {sponsor ? ( + + ) : !checkoutFor(slot) ? ( +

+ {es ? 'Espacio reservado. El anuncio está en revisión.' : 'Spot reserved. The listing is in review.'} +

+ ) : ( + track('sponsor-checkout', { slot, placement: 'city' })} + className="mt-2 flex min-h-11 items-center gap-2 rounded-md border border-dashed border-slate-300 px-3 py-3 text-sm text-slate-700 hover:bg-slate-50 dark:border-gray-700 dark:text-slate-300 dark:hover:bg-gray-800" + > + + )} +
+ ); + })} + +

+ {es + ? 'Los negocios en esta página son anuncios patrocinados. Border Pulse no califica ni recomienda negocios. ' + : 'Businesses on this page are sponsored listings. Border Pulse does not rate or recommend businesses. '} + {es ? 'Cómo funciona' : 'How it works'} +

+ + +
+
+ ); +} diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index b925ce74..e606285f 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -32,7 +32,7 @@ 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'; +import { sponsorForPort, cityForPort } from '@/lib/sponsors'; const DAY_LABELS = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], @@ -593,6 +593,13 @@ export default function CrossingDetail() { {sponsorForPort(crossing.port_number) ? : } + {cityForPort(crossing.port_number) && ( +

+ + {language === 'es' ? `Negocios en ${cityForPort(crossing.port_number).name}` : `Businesses in ${cityForPort(crossing.port_number).name}`} + +

+ )} {aggregate && aggregate.by_hour && aggregate.by_hour.length > 0 && (