diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index e7722d7db..8bfc5fa4b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 719d82547..ad4b3c4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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/scripts/check-growth-interactions.mjs b/scripts/check-growth-interactions.mjs new file mode 100644 index 000000000..a06e05d21 --- /dev/null +++ b/scripts/check-growth-interactions.mjs @@ -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'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 085f6c815..64616fd7e 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -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'); @@ -307,6 +308,11 @@ 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; @@ -314,6 +320,10 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { 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; diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 7a8b65437..5d7bdc374 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -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. 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}

} +