From e89084df8e6e6ea01dda0fec25ab6899cb526936 Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:31:31 -0700 Subject: [PATCH 1/2] feat: add comparison sharing and usefulness signal --- CHANGELOG.md | 6 +++ ROADMAP.md | 3 ++ src/lib/analytics.js | 2 + src/pages/Compare.jsx | 90 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 719d82547..b53b31ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## Unreleased — comparison pilot learning loop + +- The crossing comparison page now offers a canonical WhatsApp share link and a copy-link action. Shared links open current data rather than carrying a potentially stale wait claim. +- A three-choice, anonymous usefulness prompt records whether the comparison helped someone choose a crossing. Umami records only the canonical pair and a fixed answer value; it does not record a person's route, name, or free text. +- This is a low-cost way to learn from the San Ysidro–Otay Mesa pilot within the existing stack. It does not establish a paid team use case or add accounts, storage, or a new service. + ## 2026-09-27 — Crossing discovery and sharing ### Added diff --git a/ROADMAP.md b/ROADMAP.md index ee81056bc..1c3eaacc8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,9 @@ cross-border experience; it is a product hypothesis, not a claim of demand. release, review 30 days of `compare-open` clicks and San Ysidro page visits; use Search Console traffic as a guardrail, not causal proof. If Umami reports remain inaccessible, verify event emission and defer use claims. + Comparison sharing and a fixed-choice usefulness prompt are prepared for the + next release. Read `compare-share` and `compare-feedback` alongside visits; + a feedback click is stated usefulness, not evidence of a completed crossing. - Follow up on the separately observed Sunday-pattern callout: its "Today vs. the 30-day pattern" heading can appear on another day. Align the heading and displayed day with the pattern's actual scope before calling it a today diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 7a8b65437..91a4257dc 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -19,11 +19,13 @@ // favorite-toggle repeat-use signal (commuters vs one-off visitors) // show-all-crossings whether the mobile collapse hides too much // compare-open San Ysidro decision-panel click; slug and source only +// compare-feedback stated usefulness of a pair; answer=yes|no|still-deciding // // Distribution: // embed-copy distribution / backlink funnel // share-status viral / WhatsApp distribution // crossing-share crossing-page distribution; method=native|copy|whatsapp +// compare-share comparison-page distribution; method=copy|whatsapp // // No free-text is ever sent: slugs and enum values only, never a raw search // query, so an accidental paste cannot land in analytics. diff --git a/src/pages/Compare.jsx b/src/pages/Compare.jsx index 9cc2b399a..4f7b0fec1 100644 --- a/src/pages/Compare.jsx +++ b/src/pages/Compare.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams, Link, Navigate } from 'react-router-dom'; -import { ArrowLeft, ArrowRight, Clock } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Check, Clock, Copy, MessageCircle } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { dataService } from '@/components/utils/dataService'; import { buildSlugMap } from '@/lib/slugs'; @@ -10,6 +10,7 @@ import { nowInTz } from '@/components/utils/crossingMeta'; import { updatePageMeta, resetPageMeta } from '@/lib/seo'; import { usePersistentLanguage } from '@/lib/useLanguage'; import { isSparseCell } from '@/lib/aggregates'; +import { track } from '@/lib/analytics'; // /compare/-vs- — side-by-side live wait + 30-day pattern. // The pair is parsed from the single :pair param so we don't have to add a @@ -124,6 +125,13 @@ export default function Compare() { const [state, setState] = useState({ crossings: [], isLoading: true, fetchedAt: null }); const [aggA, setAggA] = useState(null); const [aggB, setAggB] = useState(null); + const [feedback, setFeedback] = useState(null); + const [copyState, setCopyState] = useState('idle'); + + useEffect(() => { + setFeedback(null); + setCopyState('idle'); + }, [pair]); useEffect(() => { (async () => { @@ -191,6 +199,24 @@ export default function Compare() { const waitA = standardPassengerWait(crossingA); const waitB = standardPassengerWait(crossingB); const freshness = freshnessOf(state.fetchedAt); + const compareUrl = `https://borderpulse.com/compare/${aSlug}-vs-${bSlug}/`; + const shareText = language === 'en' + ? `Compare ${crossingA.name} and ${crossingB.name} northbound waits on Border Pulse. Check the data time before you travel.` + : `Compara las esperas hacia EE. UU. en ${crossingA.name} y ${crossingB.name} en Border Pulse. Revisa la hora de los datos antes de salir.`; + const recordFeedback = (answer) => { + if (feedback) return; + setFeedback(answer); + track('compare-feedback', { pair: `${aSlug}-vs-${bSlug}`, answer }); + }; + const copyCompareLink = async () => { + try { + await navigator.clipboard.writeText(compareUrl); + setCopyState('copied'); + track('compare-share', { pair: `${aSlug}-vs-${bSlug}`, method: 'copy' }); + } catch { + setCopyState('failed'); + } + }; const snapshotTime = state.fetchedAt && freshness.state !== FRESHNESS.UNKNOWN ? new Date(state.fetchedAt).toLocaleString(language === 'es' ? 'es-MX' : 'en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'medium', timeStyle: 'short', @@ -291,6 +317,68 @@ export default function Compare() { )} +
+

+ {language === 'en' ? 'Share this comparison' : 'Comparte esta comparación'} +

+

+ {language === 'en' + ? 'The link opens the latest available data. It does not preserve the waits shown right now.' + : 'El enlace abre los datos más recientes disponibles. No guarda las esperas que ves ahora.'} +

+
+ track('compare-share', { pair: `${aSlug}-vs-${bSlug}`, method: 'whatsapp' })} + className="inline-flex min-h-11 items-center gap-2 rounded-md border border-emerald-600 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" + > + + +
+ {copyState === 'failed' && ( +

+ {language === 'en' ? 'Copy failed. You can share this page from your browser.' : 'No se pudo copiar. Puedes compartir esta página desde tu navegador.'} +

+ )} +
+

+ {language === 'en' ? 'Did this help you choose a crossing?' : '¿Te ayudó a elegir una garita?'} +

+ {feedback ? ( +

+ {language === 'en' ? 'Thanks for helping us improve Border Pulse.' : 'Gracias por ayudarnos a mejorar Border Pulse.'} +

+ ) : ( +
+ + + +
+ )} +

+ {language === 'en' ? 'Anonymous answer; no account needed.' : 'Respuesta anónima; no necesitas cuenta.'} +

+
+
+

{language === 'en' ? 'How to read this' : 'Cómo leer esta comparación'} From abfa093835b9137daeec02b7c90e7883af7b53a6 Mon Sep 17 00:00:00 2001 From: Sebastian Becerra <207095575+sbc1-code@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:14:13 -0700 Subject: [PATCH 2/2] feat: connect guide readers to live waits and saved crossings --- CHANGELOG.md | 6 +++++ src/lib/analytics.js | 2 ++ src/pages/BlogPost.jsx | 51 ++++++++++++++++++++++++++++++++++-- src/pages/CrossingDetail.jsx | 34 ++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 719d82547..9d4ffda9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 7a8b65437..d0bbdde89 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -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. diff --git a/src/pages/BlogPost.jsx b/src/pages/BlogPost.jsx index 54456bd5b..bd6faa516 100644 --- a/src/pages/BlogPost.jsx +++ b/src/pages/BlogPost.jsx @@ -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: { @@ -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', @@ -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.', }, }; @@ -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; @@ -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 (
@@ -117,6 +148,22 @@ export default function BlogPost() { +
+

{t.crossingSoon}

+

{t.nextStep}

+
+ 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}
+ {copyStatus &&

{copyStatus}

} +