From 4d062ee304aca7217eab1426e9c6003e807f9177 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:26:05 +0000 Subject: [PATCH 1/3] feat(footer): replace Resources column with Legal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site directory had no route to any legal document, and the two links that existed (Terms, Privacy in the footer nav) sat apart from the five card-programme documents, which were reachable only mid-application from CardTermsScreen. App-store review and the card issuer both expect those permanently reachable, so all seven now live in one Legal column. - Legal column: Terms of Service, Privacy Policy, Card Terms (U.S.), Card Terms (International), E-Sign Consent, Account Opening Privacy Notice, Prohibited Activities Policy — locale-routed like the manifest entries, so a Spanish reader lands on Spanish prose. - Resources column is gone; Help Center, Pricing and Supported Networks move to the top of Learn More. The manifest's "Terms" entry is dropped (a stale Notion export superseded by the /terms page) along with "Jobs", which already sits in the footer nav above. - Footer nav drops Terms and Privacy — one home per document. - footerTerms/footerPrivacy now read "Terms of Service"/"Privacy Policy", the labels a legal column wants; footerResources gives up its slot to the new Legal keys across all four catalogs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018GWaH5h1Prm6zpeXkKWcZR --- src/components/LandingPage/Footer.tsx | 8 +-- src/components/LandingPage/SEOFooter.tsx | 63 +++++++++++++++++++----- src/i18n/en.json | 11 +++-- src/i18n/es-419.json | 11 +++-- src/i18n/es-ar.json | 11 +++-- src/i18n/pt-br.json | 11 +++-- src/i18n/types.ts | 7 ++- 7 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx index 5610014afb..963be6b14b 100644 --- a/src/components/LandingPage/Footer.tsx +++ b/src/components/LandingPage/Footer.tsx @@ -114,12 +114,8 @@ const Footer = ({ {i18n.footerDocs} - - {i18n.footerTerms} - - - {i18n.footerPrivacy} - + {/* Terms and Privacy deliberately live in the Legal column of + the site directory below, not here — one home per document. */} {i18n.footerSecurity} diff --git a/src/components/LandingPage/SEOFooter.tsx b/src/components/LandingPage/SEOFooter.tsx index a2bfc026ba..b6c5c67135 100644 --- a/src/components/LandingPage/SEOFooter.tsx +++ b/src/components/LandingPage/SEOFooter.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import manifest from '@/content/generated/footer-manifest.json' import { getTranslations, t } from '@/i18n' -import { DEFAULT_LOCALE, type Locale } from '@/i18n/types' +import { DEFAULT_LOCALE, type Locale, type Translations } from '@/i18n/types' // SEO footer driven by the content manifest (peanut-content/generated/footer-manifest.json). // Data is imported as a JSON module — works in both client and server components @@ -28,6 +28,41 @@ function FooterSection({ title, children }: { title: string; children: React.Rea ) } +/** + * Manifest "resources" entries that now live elsewhere in the footer, so the + * remainder can be folded into "Learn More" without duplicating a link: + * - terms → a stale Notion export, superseded by the /terms page in Legal below + * - jobs → already a top-level link in the footer nav above + */ +const RESOURCES_MOVED_ELSEWHERE = new Set(['terms', 'jobs']) + +/** + * Every published legal document, in the order a reader needs them: the two + * that bind all users first, then the card-programme docs. Card applicants see + * these inline at signing time (CardTermsScreen), but app-store review and the + * issuer both expect them permanently reachable, which is what this column is. + * Hrefs are authored `/en/…` and localized by `localizeHref` like the manifest + * entries; the marketing pages fall back to English prose when a translation + * is missing, so a localized URL is always safe. + */ +const LEGAL_LINKS: Array<{ slug: string; href: string; label: (i18n: Translations) => string }> = [ + { slug: 'terms', href: '/en/terms', label: (i18n) => i18n.footerTerms }, + { slug: 'privacy', href: '/en/privacy', label: (i18n) => i18n.footerPrivacy }, + { slug: 'card-terms-us', href: '/en/card-terms-us', label: (i18n) => i18n.footerCardTermsUs }, + { + slug: 'card-terms-international', + href: '/en/card-terms-international', + label: (i18n) => i18n.footerCardTermsInternational, + }, + { slug: 'card-esign', href: '/en/card-esign', label: (i18n) => i18n.footerCardEsign }, + { slug: 'card-privacy', href: '/en/card-privacy', label: (i18n) => i18n.footerCardPrivacy }, + { + slug: 'card-prohibited-activities', + href: '/en/card-prohibited-activities', + label: (i18n) => i18n.footerCardProhibitedActivities, + }, +] + function FooterLink({ href, external, children }: { href: string; external?: boolean; children: React.ReactNode }) { if (external) { return ( @@ -60,6 +95,9 @@ export function SEOFooter({ locale = DEFAULT_LOCALE }: { locale?: Locale } = {}) const articles = ((manifest as Record).articles ?? []) as ManifestEntry[] const resources = (manifest.resources ?? []) as ManifestEntry[] const hasSendMoney = sendTo.length > 0 || sendFrom.length > 0 + // What's left of "Resources" rides on top of "Learn More" — the column + // itself is gone, its slot in the 4-up grid taken by Legal. + const learnMoreResources = resources.filter((entry) => !RESOURCES_MOVED_ELSEWHERE.has(entry.slug)) const link = (entry: ManifestEntry) => (entry.external ? entry.href : localizeHref(entry.href, locale)) @@ -91,25 +129,28 @@ export function SEOFooter({ locale = DEFAULT_LOCALE }: { locale?: Locale } = {}) )} - {articles.length > 0 && ( + {(articles.length > 0 || learnMoreResources.length > 0) && ( - {articles.map((entry) => ( - + {learnMoreResources.map((entry) => ( + {entry.name} ))} - - )} - - {resources.length > 0 && ( - - {resources.map((entry) => ( - + {articles.map((entry) => ( + {entry.name} ))} )} + + + {LEGAL_LINKS.map((entry) => ( + + {entry.label(i18n)} + + ))} + ) diff --git a/src/i18n/en.json b/src/i18n/en.json index 48880d77b8..3e0b4e5325 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -88,14 +88,19 @@ "footerLegalEntity": "Peanut is a trading name of Squirrel Labs Ltd, registered in England & Wales (No. 14558823)", "footerSupport": "Support", "footerDocs": "Docs", - "footerTerms": "Terms", - "footerPrivacy": "Privacy", + "footerTerms": "Terms of Service", + "footerPrivacy": "Privacy Policy", "footerSecurity": "Security", "footerJobs": "Jobs", "footerSiteDirectory": "Site directory", "footerCompare": "Compare", "footerLearnMoreSection": "Learn More", - "footerResources": "Resources", + "footerLegalSection": "Legal", + "footerCardTermsUs": "Card Terms (U.S.)", + "footerCardTermsInternational": "Card Terms (International)", + "footerCardEsign": "E-Sign Consent", + "footerCardPrivacy": "Account Opening Privacy Notice", + "footerCardProhibitedActivities": "Prohibited Activities Policy", "footerSendTo": "Send to {name}", "footerSendFrom": "Send from {name}", "footerPeanutVs": "Peanut vs {name}", diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json index 9fad38b40f..3123e9dd1a 100644 --- a/src/i18n/es-419.json +++ b/src/i18n/es-419.json @@ -88,14 +88,19 @@ "footerLegalEntity": "Peanut es un nombre comercial de Squirrel Labs Ltd, registrada en Inglaterra y Gales (N.º 14558823)", "footerSupport": "Soporte", "footerDocs": "Documentación", - "footerTerms": "Términos", - "footerPrivacy": "Privacidad", + "footerTerms": "Términos de servicio", + "footerPrivacy": "Política de privacidad", "footerSecurity": "Seguridad", "footerJobs": "Empleo", "footerSiteDirectory": "Directorio del sitio", "footerCompare": "Comparar", "footerLearnMoreSection": "Más información", - "footerResources": "Recursos", + "footerLegalSection": "Legal", + "footerCardTermsUs": "Términos de la tarjeta (EE. UU.)", + "footerCardTermsInternational": "Términos de la tarjeta (internacional)", + "footerCardEsign": "Consentimiento de firma electrónica", + "footerCardPrivacy": "Aviso de privacidad de apertura de cuenta", + "footerCardProhibitedActivities": "Política de actividades prohibidas", "footerSendTo": "Enviar a {name}", "footerSendFrom": "Enviar desde {name}", "footerPeanutVs": "Peanut vs. {name}", diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json index 093d9fedfa..d3bc8905b0 100644 --- a/src/i18n/es-ar.json +++ b/src/i18n/es-ar.json @@ -88,14 +88,19 @@ "footerLegalEntity": "Peanut es un nombre comercial de Squirrel Labs Ltd, registrada en Inglaterra y Gales (N.º 14558823)", "footerSupport": "Soporte", "footerDocs": "Documentación", - "footerTerms": "Términos", - "footerPrivacy": "Privacidad", + "footerTerms": "Términos de servicio", + "footerPrivacy": "Política de privacidad", "footerSecurity": "Seguridad", "footerJobs": "Empleo", "footerSiteDirectory": "Directorio del sitio", "footerCompare": "Comparar", "footerLearnMoreSection": "Más información", - "footerResources": "Recursos", + "footerLegalSection": "Legal", + "footerCardTermsUs": "Términos de la tarjeta (EE. UU.)", + "footerCardTermsInternational": "Términos de la tarjeta (internacional)", + "footerCardEsign": "Consentimiento de firma electrónica", + "footerCardPrivacy": "Aviso de privacidad de apertura de cuenta", + "footerCardProhibitedActivities": "Política de actividades prohibidas", "footerSendTo": "Enviar a {name}", "footerSendFrom": "Enviar desde {name}", "footerPeanutVs": "Peanut vs. {name}", diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json index 257b373fb8..e3dd80a077 100644 --- a/src/i18n/pt-br.json +++ b/src/i18n/pt-br.json @@ -88,14 +88,19 @@ "footerLegalEntity": "Peanut é um nome comercial da Squirrel Labs Ltd, registrada na Inglaterra e no País de Gales (n.º 14558823)", "footerSupport": "Suporte", "footerDocs": "Documentação", - "footerTerms": "Termos", - "footerPrivacy": "Privacidade", + "footerTerms": "Termos de serviço", + "footerPrivacy": "Política de privacidade", "footerSecurity": "Segurança", "footerJobs": "Vagas", "footerSiteDirectory": "Diretório do site", "footerCompare": "Comparar", "footerLearnMoreSection": "Saiba mais", - "footerResources": "Recursos", + "footerLegalSection": "Jurídico", + "footerCardTermsUs": "Termos do cartão (EUA)", + "footerCardTermsInternational": "Termos do cartão (internacional)", + "footerCardEsign": "Consentimento de assinatura eletrônica", + "footerCardPrivacy": "Aviso de privacidade de abertura de conta", + "footerCardProhibitedActivities": "Política de atividades proibidas", "footerSendTo": "Enviar para {name}", "footerSendFrom": "Enviar de {name}", "footerPeanutVs": "Peanut vs. {name}", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index c30a82563c..8a99bd42bd 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -212,7 +212,12 @@ export interface Translations { footerSiteDirectory: string footerCompare: string footerLearnMoreSection: string - footerResources: string + footerLegalSection: string + footerCardTermsUs: string + footerCardTermsInternational: string + footerCardEsign: string + footerCardPrivacy: string + footerCardProhibitedActivities: string footerSendTo: string // "Send to {name}" footerSendFrom: string // "Send from {name}" footerPeanutVs: string // "Peanut vs {name}" From 347fda30da821222e460e03bb34e2e686c7b769d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:09:44 +0000 Subject: [PATCH 2/3] fix(ci): retry Playwright system-deps install past apt mirror stalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apt-get update`, run inside `npx playwright install-deps chromium`, stalls intermittently on the runner's Azure mirrors. On 2026-08-19 it killed the e2e job three consecutive times on one commit — each run ignoring azure.archive.ubuntu.com, then hanging on archive.ubuntu.com noble-security until the 6-minute cap — while sibling runs minutes apart cleared the same step in seconds. Unrelated branches hit it the same afternoon, so it is not branch-specific. The stall is transient, so retry rather than mask: three attempts with a 150s per-attempt cap, dropping to the canonical archive after the first failure. One bad mirror now costs an attempt instead of the whole job. Deliberately still exits non-zero once the attempts are spent. Since `Run E2E tests` is continue-on-error, a setup step is the only thing that can red this job — making the install non-fatal would leave a genuinely missing system library silently unreported. The job cap moves 6 → 9 minutes to fit three bounded attempts; it stays well under the job's own 20-minute ceiling, so the fail-fast property that `ci-success` depends on is preserved. Verified by extracting the run block and exercising it under `bash -e` with fakes for npx/sudo/timeout: succeeds on first pass, recovers on a third attempt, and exits 1 when all three stall. The guarded pkill/sed cleanups return non-zero without aborting the script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018GWaH5h1Prm6zpeXkKWcZR --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73308f77f2..6d98f99ec5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -220,10 +220,36 @@ jobs: # The cache holds browsers, not the apt packages they link against, # so system deps still need installing on a cache hit. + # + # `apt-get update` inside install-deps stalls intermittently on the + # runner's Azure mirrors: three consecutive 6-minute timeouts on + # 2026-08-19 (PR #2755), all of them ignoring + # azure.archive.ubuntu.com and then hanging on noble-security, while + # sibling runs minutes apart cleared the same step in seconds. The + # stall is transient, so retry with a per-attempt cap — one bad + # mirror then costs an attempt instead of the whole job — and drop + # to the canonical archive after the first failure. Still exits + # non-zero once the attempts are spent: a genuinely missing system + # library must stay a red job, not a silent one. - name: Install Playwright system deps if: steps.pw-cache.outputs.cache-hit == 'true' - timeout-minutes: 6 - run: npx playwright install-deps chromium + timeout-minutes: 9 + run: | + for attempt in 1 2 3; do + if timeout 150 npx playwright install-deps chromium; then + exit 0 + fi + echo "::warning::playwright install-deps attempt ${attempt} failed or stalled; retrying" + sudo pkill -9 -f '[a]pt-get' || true + sudo rm -rf /var/lib/apt/lists/partial/* || true + if [ "${attempt}" = 1 ]; then + sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \ + /etc/apt/sources.list.d/*.sources /etc/apt/sources.list || true + fi + sleep 5 + done + echo "::error::playwright install-deps failed after 3 attempts" + exit 1 - name: Run E2E tests run: pnpm test:e2e From cc8f85abc9309476c5d91b037b43543a3a982df3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:22:03 +0000 Subject: [PATCH 3/3] fix(ci): reap stalled install-deps via timeout, not a global pkill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the retry loop. `timeout` already runs the attempt in its own process group and signals that whole group, so the apt children do receive the SIGTERM — `--kill-after=15s` escalates to SIGKILL for any that ignore it. That makes `sudo pkill -9 -f '[a]pt-get'` both redundant and unsafe: it matched by command line across the entire runner, so it could have killed unrelated package work rather than only this step's descendants. Removed. The apt partial-list cleanup stays — that clears state a killed attempt leaves behind, which is what the next attempt trips over. Re-verified under `bash -e` with fakes: succeeds first pass, recovers on the third attempt, exits 1 when all three stall. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018GWaH5h1Prm6zpeXkKWcZR --- .github/workflows/tests.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d98f99ec5..331cfb6f2a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -228,7 +228,9 @@ jobs: # sibling runs minutes apart cleared the same step in seconds. The # stall is transient, so retry with a per-attempt cap — one bad # mirror then costs an attempt instead of the whole job — and drop - # to the canonical archive after the first failure. Still exits + # to the canonical archive after the first failure. `timeout` owns + # the attempt's process group, so --kill-after reaps an apt child + # that ignores the SIGTERM it sends first. Still exits # non-zero once the attempts are spent: a genuinely missing system # library must stay a red job, not a silent one. - name: Install Playwright system deps @@ -236,11 +238,10 @@ jobs: timeout-minutes: 9 run: | for attempt in 1 2 3; do - if timeout 150 npx playwright install-deps chromium; then + if timeout --kill-after=15s 150 npx playwright install-deps chromium; then exit 0 fi echo "::warning::playwright install-deps attempt ${attempt} failed or stalled; retrying" - sudo pkill -9 -f '[a]pt-get' || true sudo rm -rf /var/lib/apt/lists/partial/* || true if [ "${attempt}" = 1 ]; then sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \