Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions scripts/build-sitemap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions scripts/check-hreflang.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) : [];
Expand Down
33 changes: 33 additions & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -58,6 +59,8 @@ function LayoutRoutes() {
<Route path="/privacidad" element={<Privacy lang="es" />} />
<Route path="/feature-your-business" element={<FeatureBusiness lang="en" />} />
<Route path="/anunciate" element={<FeatureBusiness lang="es" />} />
<Route path="/city/:city" element={<CityGuide lang="en" />} />
<Route path="/ciudad/:city" element={<CityGuide lang="es" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
Expand Down
1 change: 1 addition & 0 deletions src/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
94 changes: 80 additions & 14 deletions src/lib/sponsors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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;

Expand All @@ -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()) {
Expand Down
Loading
Loading