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 — 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
Expand Down
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 89 additions & 1 deletion src/pages/Compare.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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/<slugA>-vs-<slugB> — 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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -291,6 +317,68 @@ export default function Compare() {
</div>
)}

<section className="mb-6 rounded-lg border border-slate-200 bg-white px-4 py-4 dark:border-gray-700 dark:bg-gray-900" aria-labelledby="compare-pilot-title">
<h2 id="compare-pilot-title" className="text-base font-semibold text-slate-900 dark:text-white">
{language === 'en' ? 'Share this comparison' : 'Comparte esta comparación'}
</h2>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">
{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.'}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<a
href={`https://wa.me/?text=${encodeURIComponent(`${shareText}\n${compareUrl}`)}`}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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"
>
<MessageCircle className="h-4 w-4" aria-hidden="true" /> WhatsApp
</a>
<button
type="button"
onClick={copyCompareLink}
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"
>
{copyState === 'copied' ? <Check className="h-4 w-4" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
{copyState === 'copied'
? (language === 'en' ? 'Link copied' : 'Enlace copiado')
: (language === 'en' ? 'Copy link' : 'Copiar enlace')}
</button>
</div>
{copyState === 'failed' && (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400" role="status">
{language === 'en' ? 'Copy failed. You can share this page from your browser.' : 'No se pudo copiar. Puedes compartir esta página desde tu navegador.'}
</p>
)}
<div className="mt-4 border-t border-slate-200 pt-4 dark:border-gray-700">
<p className="text-sm font-medium text-slate-900 dark:text-white">
{language === 'en' ? 'Did this help you choose a crossing?' : '¿Te ayudó a elegir una garita?'}
</p>
{feedback ? (
<p className="mt-2 text-sm text-emerald-800 dark:text-emerald-300" role="status">
{language === 'en' ? 'Thanks for helping us improve Border Pulse.' : 'Gracias por ayudarnos a mejorar Border Pulse.'}
</p>
) : (
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => recordFeedback('yes')} className="min-h-11 rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-800 hover:bg-slate-50 dark:border-gray-600 dark:text-slate-200 dark:hover:bg-gray-800">
{language === 'en' ? 'Yes' : 'Sí'}
</button>
<button type="button" onClick={() => recordFeedback('no')} className="min-h-11 rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-800 hover:bg-slate-50 dark:border-gray-600 dark:text-slate-200 dark:hover:bg-gray-800">
No
</button>
<button type="button" onClick={() => recordFeedback('still-deciding')} className="min-h-11 rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-800 hover:bg-slate-50 dark:border-gray-600 dark:text-slate-200 dark:hover:bg-gray-800">
{language === 'en' ? 'Still deciding' : 'Aún decido'}
</button>
</div>
)}
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{language === 'en' ? 'Anonymous answer; no account needed.' : 'Respuesta anónima; no necesitas cuenta.'}
</p>
</div>
</section>

<section className="mb-6">
<h2 className="text-base font-semibold text-slate-900 dark:text-white mb-2">
{language === 'en' ? 'How to read this' : 'Cómo leer esta comparación'}
Expand Down
Loading