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

## Unreleased — guide-to-waits and return visits

- Crossing detail pages now save favorites to the same browser-local list used by the dashboard. The star control previously appeared there but did nothing. A saved crossing rises to the top of the dashboard; no account or notification is implied.
- Blog guides now offer a direct route to current northbound waits and WhatsApp/copy sharing. Shared links carry only a channel-level UTM tag, with no person-level referral identifier. Umami records `blog-to-waits` and `blog-share` using the guide slug and fixed method values.
- Adsterra remains in its existing dashboard placement while ad creative quality and actual revenue are reviewed. No extra ad unit or recurring service was added.

## 2026-09-27 — Crossing discovery and sharing

### Added
Expand Down
2 changes: 2 additions & 0 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// embed-copy distribution / backlink funnel
// share-status viral / WhatsApp distribution
// crossing-share crossing-page distribution; method=native|copy|whatsapp
// blog-share guide distribution; slug and method=copy|whatsapp
// blog-to-waits guide reader opening the current waits dashboard
//
// No free-text is ever sent: slugs and enum values only, never a raw search
// query, so an accidental paste cannot land in analytics.
Expand Down
51 changes: 49 additions & 2 deletions src/pages/BlogPost.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, ArrowRight, Copy, MessageCircle } from 'lucide-react';
import { MDXProvider } from '@mdx-js/react';
import { Button } from '@/components/ui/button';
import { getPost, getAuthor, pillarLabel, listPosts } from '@/lib/blog-runtime';
import { mdxComponents } from '@/components/blog/MdxComponents';
import { LangContext } from '@/lib/LangContext';
import { updatePageMeta, resetPageMeta } from '@/lib/seo';
import { track } from '@/lib/analytics';

const STRINGS = {
en: {
Expand All @@ -17,6 +18,12 @@ const STRINGS = {
and: 'and',
beforeTravel: 'before you travel.',
alsoIn: 'Also available in',
crossingSoon: 'Crossing soon?',
nextStep: 'Check the latest official northbound wait report, then send this guide to someone who needs it.',
currentWaits: 'Current wait times',
copyLink: 'Copy guide link',
copied: 'Guide link copied.',
copyFailed: 'Copy failed. You can share this page from your browser.',
},
es: {
allPosts: 'Todos los posts',
Expand All @@ -26,6 +33,12 @@ const STRINGS = {
and: 'y',
beforeTravel: 'antes de viajar.',
alsoIn: 'Disponible en',
crossingSoon: '¿Cruzarás pronto?',
nextStep: 'Consulta el reporte oficial más reciente hacia EE. UU. y comparte esta guía con quien la necesite.',
currentWaits: 'Tiempos de espera actuales',
copyLink: 'Copiar enlace de la guía',
copied: 'Enlace de la guía copiado.',
copyFailed: 'No se pudo copiar. Puedes compartir esta página desde tu navegador.',
},
};

Expand All @@ -49,6 +62,9 @@ function findTwin(post, allPosts) {
export default function BlogPost() {
const { slug } = useParams();
const post = getPost(slug);
const [copyStatus, setCopyStatus] = useState('');

useEffect(() => setCopyStatus(''), [slug]);

useEffect(() => {
if (!post) return;
Expand All @@ -73,6 +89,21 @@ export default function BlogPost() {
const allPosts = [...listPosts({ lang: 'en' }), ...listPosts({ lang: 'es' })];
const twin = findTwin(post, allPosts);
const twinLangLabel = twin?.frontmatter.lang === 'es' ? 'Español' : 'English';
const guideUrl = `https://borderpulse.com/blog/${slug}/`;
const whatsappUrl = `${guideUrl}?utm_source=whatsapp&utm_medium=share&utm_campaign=guide`;
const copyUrl = `${guideUrl}?utm_source=copy&utm_medium=share&utm_campaign=guide`;
const shareText = lang === 'es'
? `Lee esta guía de Border Pulse: ${fm.title}`
: `Read this Border Pulse guide: ${fm.title}`;
const copyGuideLink = async () => {
try {
await navigator.clipboard.writeText(copyUrl);
setCopyStatus(t.copied);
track('blog-share', { slug, method: 'copy' });
} catch {
setCopyStatus(t.copyFailed);
}
};

return (
<article className="p-3 sm:p-4 lg:p-6 max-w-[760px] mx-auto" lang={lang}>
Expand Down Expand Up @@ -117,6 +148,22 @@ export default function BlogPost() {
</MDXProvider>
</LangContext.Provider>
</div>
<section className="mt-8 rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 dark:border-emerald-900 dark:bg-emerald-950/20 sm:p-5" aria-label={t.crossingSoon}>
<h2 className="text-base font-semibold text-slate-900 dark:text-white">{t.crossingSoon}</h2>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{t.nextStep}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link to="/" onClick={() => track('blog-to-waits', { slug })} className="inline-flex min-h-11 items-center gap-2 rounded-md bg-emerald-700 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-800">
{t.currentWaits}<ArrowRight aria-hidden="true" className="h-4 w-4" />
</Link>
<a href={`https://wa.me/?text=${encodeURIComponent(`${shareText}\n${whatsappUrl}`)}`} target="_blank" rel="noopener noreferrer" onClick={() => track('blog-share', { slug, method: 'whatsapp' })} className="inline-flex min-h-11 items-center gap-2 rounded-md border border-emerald-700 px-3 py-2 text-sm font-medium text-emerald-800 hover:bg-emerald-100 dark:text-emerald-300 dark:hover:bg-emerald-950/40">
<MessageCircle aria-hidden="true" className="h-4 w-4" />WhatsApp
</a>
<button type="button" onClick={copyGuideLink} className="inline-flex min-h-11 items-center gap-2 rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50 dark:border-gray-600 dark:text-slate-200 dark:hover:bg-gray-800">
<Copy aria-hidden="true" className="h-4 w-4" />{t.copyLink}
</button>
</div>
{copyStatus && <p className="mt-2 text-xs text-slate-600 dark:text-slate-300" role="status">{copyStatus}</p>}
</section>
<footer className="mt-10 border-t border-slate-200 dark:border-gray-800 pt-4 text-xs text-slate-500">
{t.footer}{' '}
<a
Expand Down
34 changes: 32 additions & 2 deletions src/pages/CrossingDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { usePersistentLanguage } from '@/lib/useLanguage';
import { isSparseCell } from '@/lib/aggregates';
import { hasPedestrianLane } from '@/lib/crossingAvailability';
import { pickLightestHour } from '@/lib/recommendations';
import { track } from '@/lib/analytics';

const DAY_LABELS = {
en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
Expand Down Expand Up @@ -216,6 +217,28 @@ export default function CrossingDetail() {
const [embedOpen, setEmbedOpen] = useState(false);
const [anomalies, setAnomalies] = useState(null);
const language = usePersistentLanguage();
const [favorites, setFavorites] = useState(() => {
try {
const stored = JSON.parse(localStorage.getItem('borderPulse_favorites') || '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
});

const toggleFavorite = (portNumber) => {
const adding = !favorites.includes(portNumber);
const next = adding
? [...favorites, portNumber]
: favorites.filter((number) => number !== portNumber);
setFavorites(next);
try {
localStorage.setItem('borderPulse_favorites', JSON.stringify(next));
} catch {
// The current page still responds if storage is unavailable.
}
track('favorite-toggle', { action: adding ? 'add' : 'remove', source: 'crossing-detail' });
};

// Initial guess is browser-local; once the crossing loads, todayIdx below
// switches to the port's timezone and the reset effect re-syncs selectedDay.
Expand Down Expand Up @@ -468,9 +491,16 @@ export default function CrossingDetail() {
index={0}
selectedDirection="northbound"
snapshotAt={isSanYsidro ? state.fetchedAt : null}
isFavorite={false}
onToggleFavorite={() => {}}
isFavorite={favorites.includes(crossing.port_number)}
onToggleFavorite={toggleFavorite}
/>
{favorites.includes(crossing.port_number) && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300" role="status">
{language === 'en'
? 'Saved on this device. This crossing appears first on the dashboard.'
: 'Guardado en este dispositivo. Este cruce aparece primero en el panel principal.'}
</p>
)}
</div>

{isSanYsidro && (
Expand Down
Loading