diff --git a/CHANGELOG.md b/CHANGELOG.md index 719d8254..b53b31ff 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 ee81056b..1c3eaacc 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 7a8b6543..91a4257d 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 9cc2b399..4f7b0fec 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'}