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
2 changes: 2 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# CHANGELOG

## Unreleased — combined free-product release checks

- Combined comparison sharing with guide-to-waits and favorites without coupling the release to Vercel. A storage write failure now leaves the favorite unchanged and shows an EN/ES error instead of a false saved confirmation.
- Added desktop/mobile EN/ES interaction coverage for favorite persistence, storage failure, copy failure, canonical/UTM sharing, feedback and guide-to-waits events. CI checks out full history while the Pages build still derives aggregates from Git.
- Local build, existing full release suite (406 route navigations), and the new targeted interaction checks passed. Production release still requires approval.

## 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.

## 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
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
79 changes: 79 additions & 0 deletions scripts/check-growth-interactions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';

// Run against the built app with external requests blocked by the route suite.
export async function checkGrowthInteractions(page, context, baseUrl) {
const posts = JSON.parse(fs.readFileSync(new URL('../public/data/blog/index.json', import.meta.url))).posts;
await context.addInitScript(() => {
window.testEvents = [];
window.umami = { track: (event, data) => window.testEvents.push({ event, data }) };
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value) => {
if (window.failCopy) throw new Error('clipboard denied');
window.copiedLink = value;
} },
});
});
for (const lang of ['en', 'es']) {
await page.evaluate((value) => {
localStorage.setItem('borderPulse_language', value);
localStorage.setItem('borderPulse_favorites', '[]');
localStorage.setItem('borderPulse_region', 'ALL');
}, lang);
const add = lang === 'en' ? 'Add to favorites' : 'Agregar a favoritos';
const remove = lang === 'en' ? 'Remove from favorites' : 'Quitar de favoritos';
await page.goto(new URL('/crossing/san-ysidro/', baseUrl).href);
await page.getByRole('button', { name: add, exact: true }).click();
await page.getByRole('button', { name: remove, exact: true }).waitFor();
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('borderPulse_favorites'))), ['250401']);
assert.equal(await page.evaluate(() => window.testEvents.filter(e => e.event === 'favorite-toggle' && e.data.action === 'add').length), 1);
// Follow the actual client-side route back to the dashboard.
await page.getByRole('link', { name: lang === 'en' ? 'All crossings' : 'Todos los cruces', exact: true }).click();
await page.getByRole('button', { name: remove, exact: true }).waitFor();
await page.reload();
await page.getByRole('button', { name: remove, exact: true }).waitFor();
await page.goto(new URL('/crossing/san-ysidro/', baseUrl).href);
await page.getByRole('button', { name: remove, exact: true }).click();
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('borderPulse_favorites'))), []);
await page.evaluate(() => {
const original = Storage.prototype.setItem;
Storage.prototype.setItem = function(key, value) {
if (key === 'borderPulse_favorites') throw new Error('storage denied');
return original.call(this, key, value);
};
});
await page.getByRole('button', { name: add, exact: true }).click();
await page.getByText(lang === 'en'
? 'Your browser could not save this change. Allow site storage and try again.'
: 'Tu navegador no pudo guardar este cambio. Permite el almacenamiento del sitio e inténtalo de nuevo.', { exact: true }).waitFor();
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('borderPulse_favorites'))), []);

const pair = 'san-ysidro-vs-otay-mesa';
await page.goto(new URL(`/compare/${pair}/`, baseUrl).href);
await page.getByRole('button', { name: lang === 'en' ? 'Copy link' : 'Copiar enlace', exact: true }).click();
assert.equal(await page.evaluate(() => window.copiedLink), `https://borderpulse.com/compare/${pair}/`);
await page.getByRole('button', { name: lang === 'en' ? 'Yes' : 'Sí', exact: true }).click();
assert.deepEqual(await page.evaluate(() => window.testEvents.filter(e => e.event === 'compare-feedback')), [
{ event: 'compare-feedback', data: { pair, answer: 'yes' } },
]);
await page.evaluate(() => { window.failCopy = true; });
await page.getByRole('button', { name: lang === 'en' ? 'Link copied' : 'Enlace copiado', exact: true }).click();
await page.getByText(lang === 'en'
? 'Copy failed. You can share this page from your browser.'
: 'No se pudo copiar. Puedes compartir esta página desde tu navegador.', { exact: true }).waitFor();

const post = posts.find(p => p.lang === lang);
await page.goto(new URL(`/blog/${post.slug}/`, baseUrl).href);
await page.getByRole('button', { name: lang === 'en' ? 'Copy guide link' : 'Copiar enlace de la guía', exact: true }).click();
assert.equal(await page.evaluate(() => window.copiedLink), `https://borderpulse.com/blog/${post.slug}/?utm_source=copy&utm_medium=share&utm_campaign=guide`);
const whatsapp = new URL(await page.getByRole('link', { name: 'WhatsApp', exact: true }).getAttribute('href'));
assert.ok(whatsapp.searchParams.get('text').includes(`https://borderpulse.com/blog/${post.slug}/?utm_source=whatsapp&utm_medium=share&utm_campaign=guide`));
assert.equal(await page.locator('link[rel="canonical"]').getAttribute('href'), `https://borderpulse.com/blog/${post.slug}/`);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'guide must fit viewport');
await page.getByRole('link', { name: lang === 'en' ? 'Current wait times' : 'Tiempos de espera actuales', exact: true }).click();
await page.waitForURL(url => url.pathname === '/');
assert.equal(await page.evaluate(() => window.testEvents.filter(e => e.event === 'blog-to-waits').length), 1);
}
console.log('[growth] PASS: EN/ES favorites, storage failure, sharing, feedback, guide-to-waits and attribution');
}
10 changes: 10 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
import { checkGrowthInteractions } from './check-growth-interactions.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const distDir = path.resolve(root, 'dist');
Expand Down Expand Up @@ -307,13 +308,22 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) {
failures.push(`${profile.name} dashboard analytics: ${error.message}`);
}

try {
await checkGrowthInteractions(page, context, baseUrl);
} catch (error) {
failures.push(`${profile.name} growth interactions: ${error.stack || error.message}`);
}
await context.close();
console.log(`[routes:${profile.name}] checked ${manifest.canonicals.length} canonical + ${manifest.aliases.length} alias routes + dashboard analytics`);
return failures;
}

async function main() {
const manifest = loadRouteManifest();
if (process.argv.includes('--growth-only')) {
manifest.canonicals = [];
manifest.aliases = [];
}
const crossings = JSON.parse(fs.readFileSync(path.resolve(root, 'public/data/crossings.json'), 'utf8')).crossings || [];
const preview = await startPreview();
let browser;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
// 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
// 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
Loading
Loading