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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# CHANGELOG

## 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.
- First sponsor now shows as "Dr. J Constandse" (the name on his site and signage) with implants, veneers and smile design, crowns and full oral rehabilitation, per his site, and his 25% offer through Nov 30, attributed to the clinic.
- New bilingual page `/feature-your-business/` and `/anunciate/` (hreflang-paired, in the sitemap, linked from the footer and each sponsor card). Five cities (Ciudad Juárez, Tijuana, Los Algodones, Nogales, Nuevo Progreso) by six categories (dentist, pharmacy, medical clinic, food, auto, salon and beauty). Open spots link to a $99/month Stripe Payment Link with `client_reference_id` set to the slot; taken spots show as taken. Clicks log `sponsor-checkout` with the slot id only.
- Sponsors are now set by city and category in `src/lib/sponsors.js`. A city's crossing pages show one sponsor; with several in a city, they rotate by UTC calendar day.
- Checks: full `npm test` passes after a prerendered build. Entry JS 62.05 KB gzip (budget 62.25), CSS 19.92 KB (budget 20). Headless Chrome 390px: card on PDN and the Juárez dentist guide; feature page renders EN and ES.

## 2026-11-10 - Sponsor WhatsApp message names Border Pulse

- The sponsor card's WhatsApp button now opens a prefilled message saying the patient found the clinic on Border Pulse (EN/ES). The visitor can edit it before sending. This lets the business see the source in its own inbox, which analytics cannot show.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,7 @@ A page carries a sponsor card or a network ad, never both, and always below the
## 2026-11-10 - Sponsor traffic is measured in three steps, and only those are claimed

Sponsor reporting counts what the existing Umami property records: clicks on the sponsor card (`sponsor-click`), visits to the sponsor's site that arrive with `utm_source=borderpulse`, and taps on that site's contact buttons by source. The WhatsApp button opens a prefilled, editable message naming Border Pulse so the business can see the source in its own inbox. Booked appointments and revenue are outside what is measured, so reports do not state them. The sponsor's site reports into the same Umami property (the plan allows one site); filter by hostname when reading Border Pulse numbers.

## 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.
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,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.
- Self-serve "feature your business" page, after 2 to 3 paying sponsors and a real click number to show.
- [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).
- 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
7 changes: 7 additions & 0 deletions scripts/build-sitemap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ async function main() {
{ lang: 'es', href: `${BASE}/privacidad/` },
{ lang: 'x-default', href: `${BASE}/privacy/` },
];
const featureAlternates = [
{ lang: 'en', href: `${BASE}/feature-your-business/` },
{ lang: 'es', href: `${BASE}/anunciate/` },
{ lang: 'x-default', href: `${BASE}/feature-your-business/` },
];
const urls = [
{ loc: `${BASE}/`, changefreq: 'hourly', priority: '1.0', lastmod: today, alternates: homeHreflangs() },
{ loc: `${BASE}/es/`, changefreq: 'hourly', priority: '0.9', lastmod: today, alternates: homeHreflangs() },
Expand All @@ -70,6 +75,8 @@ async function main() {
{ loc: `${BASE}/privacidad/`, changefreq: 'yearly', priority: '0.2', lastmod: today, alternates: privacyAlternates },
{ loc: `${BASE}/methodology/`, changefreq: 'monthly', priority: '0.5', lastmod: today, alternates: methodologyAlternates },
{ loc: `${BASE}/metodologia/`, changefreq: 'monthly', priority: '0.5', lastmod: today, alternates: methodologyAlternates },
{ 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 },
];
for (const c of crossings) {
const slug = portToSlug[c.port_number];
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-document-language.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export async function checkDocumentLanguage(page, context, baseUrl) {
await click('/');
await inspect('/', 'es');
// Explicit EN/ES routes must not inherit a contradictory UI preference.
for (const [route, lang] of [['/methodology/', 'en'], ['/metodologia/', 'es'], ['/privacy/', 'en'], ['/privacidad/', 'es']]) {
for (const [route, lang] of [['/methodology/', 'en'], ['/metodologia/', 'es'], ['/privacy/', 'en'], ['/privacidad/', 'es'], ['/feature-your-business/', 'en'], ['/anunciate/', 'es']]) {
await page.goto(new URL(route, baseUrl).href);
await inspect(route, lang);
await switchTo(lang === 'en' ? 'es' : 'en');
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-hreflang.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export function checkBuiltHreflangs(dist = path.join(root, 'dist')) {
['/metodologia/', pair('/methodology/', '/metodologia/')],
['/privacy/', pair('/privacy/', '/privacidad/')],
['/privacidad/', pair('/privacy/', '/privacidad/')],
['/feature-your-business/', pair('/feature-your-business/', '/anunciate/')],
['/anunciate/', pair('/feature-your-business/', '/anunciate/')],
]);
for (const post of posts) {
const twins = post.translationKey ? posts.filter((p) => p.translationKey === post.translationKey) : [];
Expand Down
30 changes: 30 additions & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,36 @@ async function main() {
fs.writeFileSync(path.resolve(esDir, 'index.html'), esHtml);
}

// /feature-your-business + /anunciate — self-serve sponsor page, hreflang-paired.
{
const enCanonical = `${BASE}/feature-your-business/`;
const esCanonical = `${BASE}/anunciate/`;
const hreflangs = [
{ lang: 'en', href: enCanonical },
{ lang: 'es', href: esCanonical },
{ lang: 'x-default', href: enCanonical },
];
const heads = [
['feature-your-business', {
lang: 'en',
title: 'Feature your business | Border Pulse',
desc: 'A labeled spot on Border Pulse crossing pages for one business per category in each border city. $99 a month.',
canonical: enCanonical, ogImage: `${BASE}/og-card.png`, jsonLd: [], hreflangs,
}],
['anunciate', {
lang: 'es',
title: 'Anuncia tu negocio | Border Pulse',
desc: 'Un espacio patrocinado en las páginas de cruces de Border Pulse para un negocio por categoría en cada ciudad fronteriza. $99 dólares al mes.',
canonical: esCanonical, ogImage: `${BASE}/og-card.png`, jsonLd: [], hreflangs,
}],
];
for (const [route, head] of heads) {
const outDir = path.resolve(distDir, route);
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 @@ -23,6 +23,7 @@ const WalkOrDrive = lazy(() => import('@/pages/WalkOrDrive'));
const Privacy = lazy(() => import('@/pages/Privacy'));
const CrossingConsole = lazy(() => import('@/pages/CorridorRedirect'));
const LaneFlip = lazy(() => import('@/pages/LaneFlip'));
const FeatureBusiness = lazy(() => import('@/pages/FeatureBusiness'));

function RouteFallback() {
return (
Expand Down Expand Up @@ -55,6 +56,8 @@ function LayoutRoutes() {
<Route path="/lane-flip/:slug" element={<LaneFlip />} />
<Route path="/privacy" element={<Privacy lang="en" />} />
<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="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
Expand Down
8 changes: 7 additions & 1 deletion src/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function Layout({ children }) {
useEffect(() => {
const pageOwnsLanguage =
/^\/blog\/[^/]+\/?$/.test(location.pathname) ||
/^\/(methodology|metodologia|privacy|privacidad|es)\/?$/.test(location.pathname);
/^\/(methodology|metodologia|privacy|privacidad|feature-your-business|anunciate|es)\/?$/.test(location.pathname);
if (pageOwnsLanguage) return;
document.documentElement.lang = language;
}, [language, location.pathname]);
Expand Down Expand Up @@ -221,6 +221,12 @@ export default function Layout({ children }) {
<Link to="/about/" className="hover:text-slate-900 dark:hover:text-white transition-colors">
{language === 'en' ? 'About' : 'Acerca de'}
</Link>
<Link
to={language === 'es' ? '/anunciate/' : '/feature-your-business/'}
className="hover:text-slate-900 dark:hover:text-white transition-colors"
>
{language === 'es' ? 'Anuncia tu negocio' : 'Feature your business'}
</Link>
<span>·</span>
<Link
to={language === 'es' ? '/metodologia/' : '/methodology/'}
Expand Down
90 changes: 61 additions & 29 deletions src/components/sponsors/SponsoredCard.jsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,83 @@
import { ExternalLink, MessageCircle } from 'lucide-react';
import { sponsorUrl } from '@/lib/sponsors';
import { Link } from 'react-router-dom';
import { ExternalLink, MessageCircle, MapPin } from 'lucide-react';
import { sponsorUrl, whatsappUrl, cityById, categoryById, offerFor } 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 lang = es ? 'es' : 'en';
const log = action => track('sponsor-click', { sponsor: sponsor.id, placement, action });
const city = cityById(sponsor.city)?.name || sponsor.city;
const category = categoryById(sponsor.category)?.[lang] || '';
const services = sponsor.services?.[lang] || [];
const offer = offerFor(sponsor);
const wa = whatsappUrl(sponsor, language);
return (
<aside
className="not-prose my-6 rounded-xl border border-slate-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
className="not-prose my-6 overflow-hidden rounded-xl border border-emerald-300 bg-emerald-50/60 shadow-sm dark:border-emerald-800 dark:bg-emerald-950/20"
aria-label={es ? 'Anuncio patrocinado' : 'Sponsored listing'}
data-sponsor={sponsor.id}
>
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-400">
{es ? 'Patrocinado' : 'Sponsored'} · {sponsor.category[es ? 'es' : 'en']} · {sponsor.city}
</p>
<p className="mt-1 text-base font-semibold text-slate-900 dark:text-white">{sponsor.name}</p>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{sponsor.blurb[es ? 'es' : 'en']}</p>
<div className="mt-3 flex flex-wrap gap-2">
<a
href={sponsorUrl(sponsor, placement)}
target="_blank"
rel="sponsored noopener"
onClick={() => log('site')}
aria-label={`${es ? 'Ver sitio' : 'Visit site'}: ${sponsor.name}`}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50 dark:border-gray-700 dark:text-slate-100 dark:hover:bg-gray-800"
>
{es ? 'Ver sitio' : 'Visit site'}<ExternalLink aria-hidden="true" className="h-3.5 w-3.5" />
</a>
{sponsor.whatsapp && (
<div className="border-l-4 border-emerald-600 p-4 sm:p-5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-emerald-800 dark:text-emerald-300">
{es ? 'Patrocinado' : 'Sponsored'} · {category}
</p>
<p className="mt-1 text-lg font-bold leading-snug text-slate-950 dark:text-white">{sponsor.name}</p>
<p className="mt-0.5 flex items-center gap-1 text-sm text-slate-700 dark:text-slate-300">
<MapPin aria-hidden="true" className="h-3.5 w-3.5" />{city}
</p>

{services.length > 0 && (
<div className="mt-3">
<p className="text-xs text-slate-600 dark:text-slate-400">{es ? 'Servicios que anuncia el negocio:' : 'Services the business lists:'}</p>
<ul className="mt-1.5 flex flex-wrap gap-1.5">
{services.map(s => (
<li key={s} className="rounded-full border border-emerald-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-800 dark:border-emerald-900 dark:bg-gray-900 dark:text-slate-100">{s}</li>
))}
</ul>
</div>
)}

{offer && (
<p className="mt-3 text-sm font-medium text-slate-900 dark:text-white">{offer[lang]}</p>
)}

<div className="mt-4 flex flex-wrap gap-2">
{wa && (
<a
href={wa}
target="_blank"
rel="sponsored noopener"
onClick={() => log('whatsapp')}
aria-label={`WhatsApp: ${sponsor.name}`}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />{es ? 'Escribir por WhatsApp' : 'Message on WhatsApp'}
</a>
)}
<a
href={`https://wa.me/${sponsor.whatsapp}${sponsor.whatsappText ? `?text=${encodeURIComponent(sponsor.whatsappText[es ? 'es' : 'en'])}` : ''}`}
href={sponsorUrl(sponsor, placement)}
target="_blank"
rel="sponsored noopener"
onClick={() => log('whatsapp')}
aria-label={`WhatsApp: ${sponsor.name}`}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-emerald-700 px-3 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-950/40"
onClick={() => log('site')}
aria-label={`${es ? 'Ver sitio' : 'Visit site'}: ${sponsor.name}`}
className="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50 dark:border-gray-700 dark:bg-gray-900 dark:text-slate-100 dark:hover:bg-gray-800"
>
<MessageCircle aria-hidden="true" className="h-3.5 w-3.5" />WhatsApp
{es ? 'Ver sitio' : 'Visit site'}<ExternalLink aria-hidden="true" className="h-3.5 w-3.5" />
</a>
)}
</div>

<p className="mt-3 text-xs text-slate-600 dark:text-slate-400">
{es ? 'Espacio patrocinado. Border Pulse no ha evaluado la atención de este negocio.' : 'Sponsored placement. Border Pulse has not evaluated this business’s care.'}
{' '}
<Link to={es ? '/anunciate/' : '/feature-your-business/'} className="underline hover:text-slate-900 dark:hover:text-white">
{es ? 'Anuncia tu negocio' : 'Feature your business'}
</Link>
</p>
</div>
<p className="mt-3 text-xs text-slate-600 dark:text-slate-400">
{es ? 'Espacio patrocinado. Border Pulse no ha evaluado la atención de este negocio.' : 'Sponsored placement. Border Pulse has not evaluated this business’s care.'}
</p>
</aside>
);
}
Loading
Loading