Skip to content

Localize the marketing site: three locales, localized landing, language switcher - #2592

Merged
kushagrasarathe merged 15 commits into
devfrom
feat/marketing-localization
Aug 5, 2026
Merged

Localize the marketing site: three locales, localized landing, language switcher#2592
kushagrasarathe merged 15 commits into
devfrom
feat/marketing-localization

Conversation

@innolope-dev

@innolope-dev innolope-dev commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

App UI localization shipped in #2447 (next-intl, en / es-419 / pt-BR); marketing runs a separate URL-driven i18n system that had drifted: a bloated locale set generating ~450 near-duplicate indexed URLs, and a landing page with zero i18n. The prose itself was already ~95% translated in the content submodule — the work is the locale set, the landing page, the leaks, and the SEO plumbing.

Since the first revision, the target locale set changed: es-AR is back in as a first-class locale (deltas-over-es-419 in the app, own routes on marketing), while es-ES stays retired. The near-duplicate problem that motivated removing es-ar is solved structurally instead (content-aware hreflang/sitemap + fallback canonicals), so a sparse locale no longer pollutes the index.

Localizes the marketing site and landing page, now with the full target locale set: en · es-419 · es-AR · pt-BR (es-ES stays retired, 301 → es-419).

What's in here

Locale set

  • Marketing locales: en, es-419, es-ar, pt-br — es-AR restored as a first-class locale (own landing route, catalog, switcher entry, hreflang es-AR). Content fallback es-ar → es-419 → en: ~34/240 slugs have real AR prose today; the rest serve es-419 under the es-ar URL without being advertised to crawlers (see SEO below).
  • App locales: en, es-419, es-AR, pt-BR. es-AR.json is deltas-only (voseo/Argentine terms) layered over es-419 via deepMerge — shared keys are never re-translated. Parity test asserts the delta file stays a subset of en while the loader output stays complete.
  • An Argentine device resolves to es-AR (app) / es-ar (marketing); the shared app-locale cookie carries the choice both ways.

Landing language UI

  • The LocaleSuggestion banner is switched off (kill switch in the component; mounts stay wired for re-enable). The footer LocaleSwitcher stays.
  • First visit now respects the browser locale: the proxy redirects / per Accept-Language (q-ordered, explicit English wins) and pre-sets the cookie. Crawlers are exempt (UA check + missing-UA treated as bot), so bots always see the English / and hreflang keeps routing them — the concern that made the previous revision cookie-only.
  • Locale-cookie redirect now preserves the query string (UTM/campaign params survive), and the promo-link branch runs before it, so ?promo=…&id=… links work for cookie'd users.

SEO

  • / now exports landingMetadata('en') — landing hreflang cluster is reciprocal (was one-way: localized landings pointed at /, / declared nothing).
  • hreflang + sitemap are content-aware: a locale URL is only advertised when that locale's own content file exists; fallback-served pages canonicalize to the locale that owns the prose. This is what keeps the restored es-ar from re-creating the ~450 near-duplicate URLs, and stops advertising the ~11 slugs that render English under localized URLs.
  • og:locale + og:locale:alternate on all marketing metadata (en_US / es_LA / es_AR / pt_BR).
  • MDX a override is locale-bound (localizeContentHref) — markdown links on Spanish/Portuguese pages no longer leak to /en/….
  • LOCALE_ORDER deleted; switchers iterate SUPPORTED_LOCALES (a 4th locale can no longer compile without appearing in the pickers). Switcher hrefLang uses proper BCP-47 casing.
  • /en → / upgraded 307 → 301; sitemap lists /en/privacy + /en/terms instead of the redirecting bare URLs; /exchange and /quests no longer inherit canonical: '/'; Googlebot may fetch /api/og; inLanguage on breadcrumb/article/FAQ JSON-LD.
  • <html lang>: SSR still ships lang="en" (the root layout can't read the locale without opting the app out of static rendering, which the native static export depends on) — a client HtmlLang component now corrects the document language post-hydration; <main lang> stays as the SSR-visible scope.

What still ships English (known, deliberate)

  • Landing metadata + FAQ JSON-LD on es-419/es-ar/pt-br: content/landing/ has only en.md upstream in mono, so <title>, meta description and og:title/description on the localized landings fall back to English while the visible page is fully translated. Blocked on mono authoring content/landing/{es-419,es-ar,pt-br}.md + a submodule bump. The FAQ JSON-LD honestly declares inLanguage of the prose it resolved to.
  • Legal pages (terms, privacy, card-*), team, help/delete-account, one blog post: en-only content; localized URLs serve English via fallback. They now canonicalize to the en URL and are no longer advertised in hreflang/sitemap for locales without prose, so they're not an SEO liability — but the reader still sees English until mono translates them.
  • ~30/240 slugs under es-ar render es-419 prose (by design of the fallback chain).

Verification

  • 187 jest suites / 2,474 tests green, including new coverage: query-string preservation + promo precedence + Accept-Language/crawler behaviour in the proxy, es-AR restoration in bridge/resolve/switcher tests, es-AR subset parity for the app catalog.
  • tsc --noEmit clean; full pnpm build green (all locale routes statically generated).

Marketing ran a separate, URL-driven i18n system from the product UI and had
drifted from it. This aligns the locale set, localizes the landing page, and
fixes the leaks found along the way.

Locale set -> en / es-419 / pt-br, matching the app. es-ar had real prose for
30 of 237 slugs and es-es for 23; the rest served es-419 content under distinct
indexed URLs, and the sitemap submitted all of it. Both now 301 to es-419 but
stay reserved in DEDICATED_ROUTES so a stale URL can't be read as a username.

Landing page localized. /es-419 and /pt-br are literal path segments rather
than a [locale] dynamic route on purpose: [locale]/page.tsx would match any
single segment and shadow [...recipient], breaking every /{username} profile
URL. Removing the bare-locale redirects also fixes breadcrumbs, which linked
`/{locale}` and landed users on the English homepage.

Copy extraction: ~134 keys x 3 locales. Server sections read getTranslations
directly; client sections take a narrowed strings bag so the landing bundle
doesn't ship all three catalogs.

Locale leaks fixed. mdx/CountryGrid passed no locale to DestinationGrid, which
defaults to 'en', so every Spanish and Portuguese article emitted /en/ links;
renderContent now threads locale through createMdxComponents. Both footers
rendered English labels beside localized hrefs. Several landing sections
hardcoded /en/help, /en/pay-with and /en/compare paths. getAlternates
hardcoded https://peanut.me, so previews emitted hreflang pointing at prod.

Language switcher in the footer on every marketing route and the landing. The
only previous switcher was ArticleBackNav, on four article-detail routes; hubs,
help, pricing and the homepage had no way to change language.

Detection is cookie-based, never Accept-Language: crawlers arrive from US IPs
sending en and would never reach the localized pages. proxy.ts redirects / to
the stored locale (307, after the auth redirect so signed-in users still reach
/home); first-time visitors get a dismissible suggestion banner instead.

Legal stays English-only pending legal review; the en.md fallback serves it.
@innolope-dev innolope-dev self-assigned this Jul 30, 2026
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 5, 2026 8:52am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a2e7e152-a895-489a-b453-963533ec7bf0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR’s main changes: marketing-site localization, three supported locales, localized landing pages, and a language switcher.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 6885.89 → 6986.99 (+101.1)
Findings: +34 net (+274 new, -240 resolved)

🆕 New findings (274)

  • critical complexity — src/lib/content.ts — CC 58, MI 61.74, SLOC 239
  • high complexity — src/components/LandingPage/LandingPageClient.tsx — CC 48, MI 55.67, SLOC 172
  • high complexity — src/components/LandingPage/TweetCarousel.tsx — CC 47, MI 63.42, SLOC 159
  • high complexity — src/proxy.ts — CC 37, MI 56.85, SLOC 119
  • high complexity — src/components/LandingPage/hero.tsx — CC 34, MI 59.58, SLOC 125
  • high hotspot — src/components/LandingPage/hero.tsx — 33 commits, +294/-205 lines since 6 months ago
  • high complexity — src/app/sitemap.ts — CC 23, MI 37.57, SLOC 174
  • high complexity — src/components/LandingPage/dropLink.tsx — CC 2, MI 46.74, SLOC 65
  • medium high-mdd — src/components/LandingPage/hero.tsx:116 — Hero: MDD 89.3 (uses across many lines from declarations)
  • medium high-mdd — src/app/sitemap.ts:27 — generateSitemap: MDD 73.8 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/sendInSeconds.tsx:41 — SendInSeconds: MDD 54.7 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/Footer.tsx:57 — Footer: MDD 49.2 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/LandingPageClient.tsx:48 — LandingPageClient: MDD 48.3 (uses across many lines from declarations)
  • medium high-mdd — src/components/LandingPage/Manteca.tsx:20 — Manteca: MDD 47.8 (uses across many lines from declarations)
  • medium high-mdd — src/app/quests/page.tsx:20 — QuestsPage: MDD 46.5 (uses across many lines from declarations)
  • medium structural-dup — app/[locale]/(marketing)/card-esign/page.tsx:53 — 35 duplicate lines / 191 tokens with app/[locale]/(marketing)/card-privacy/page.tsx:53
  • medium structural-dup — app/[locale]/(marketing)/card-esign/page.tsx:53 — 35 duplicate lines / 191 tokens with app/[locale]/(marketing)/card-prohibited-activities/page.tsx:53
  • medium structural-dup — app/[locale]/(marketing)/card-esign/page.tsx:53 — 35 duplicate lines / 191 tokens with app/[locale]/(marketing)/card-terms-international/page.tsx:53
  • medium structural-dup — app/[locale]/(marketing)/card-esign/page.tsx:53 — 35 duplicate lines / 191 tokens with app/[locale]/(marketing)/card-terms-us/page.tsx:53
  • medium high-mdd — src/app/[locale]/(marketing)/blog/[slug]/page.tsx:52 — BlogPostPageLocalized: MDD 33.0 (uses across many lines from declarations)

…and 254 more.

✅ Resolved (240)

  • src/components/LandingPage/LandingPageClient.tsx — CC 48, MI 55.72, SLOC 172
  • src/components/LandingPage/TweetCarousel.tsx — CC 47, MI 63.43, SLOC 159
  • src/lib/content.ts — CC 43, MI 58.95, SLOC 194
  • src/components/LandingPage/hero.tsx — CC 34, MI 59.67, SLOC 125
  • src/components/LandingPage/hero.tsx — 31 commits, +283/-198 lines since 6 months ago
  • src/app/sitemap.ts — CC 6, MI 42.2, SLOC 125
  • src/components/LandingPage/dropLink.tsx — CC 2, MI 46.98, SLOC 65
  • src/components/LandingPage/hero.tsx:112 — Hero: MDD 89.3 (uses across many lines from declarations)
  • src/app/sitemap.ts:21 — generateSitemap: MDD 70.5 (uses across many lines from declarations)
  • src/components/LandingPage/LandingPageClient.tsx:44 — LandingPageClient: MDD 47.9 (uses across many lines from declarations)
  • src/app/quests/page.tsx:18 — QuestsPage: MDD 46.5 (uses across many lines from declarations)
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/card-privacy/page.tsx:49
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/card-prohibited-activities/page.tsx:49
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/card-terms-international/page.tsx:49
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/card-terms-us/page.tsx:49
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/privacy/page.tsx:49
  • app/[locale]/(marketing)/card-esign/page.tsx:49 — 35 duplicate lines / 189 tokens with app/[locale]/(marketing)/terms/page.tsx:49
  • src/app/[locale]/(marketing)/blog/[slug]/page.tsx:49 — BlogPostPageLocalized: MDD 32.2 (uses across many lines from declarations)
  • app/[locale]/(marketing)/blog/page.tsx:17 — 30 duplicate lines / 152 tokens with app/[locale]/(marketing)/card-privacy/page.tsx:24
  • app/[locale]/(marketing)/blog/page.tsx:17 — 30 duplicate lines / 152 tokens with app/[locale]/(marketing)/card-prohibited-activities/page.tsx:24

…and 220 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/LandingPage/landingStrings.ts 0.0 7.4 +7.4
src/components/Marketing/LocaleSuggestion.tsx 0.0 7.1 +7.1
src/components/Marketing/LocaleSwitcher.tsx 0.0 6.5 +6.5
src/components/LandingPage/LandingPageContent.tsx 0.0 5.1 +5.1
src/i18n/useUrlLocale.ts 0.0 4.8 +4.8
src/lib/seo/landing.ts 0.0 4.6 +4.6
src/i18n/localeMeta.ts 0.0 4.5 +4.5
src/i18n/localeBridge.ts 0.0 4.3 +4.3
src/components/LandingPage/Footer.tsx 3.8 7.8 +4.0
src/app/[locale]/(marketing)/error.tsx 3.3 6.7 +3.4
src/components/LandingPage/sendInSeconds.tsx 5.7 8.9 +3.2
src/components/LandingPage/yourMoney.tsx 2.2 5.4 +3.2
src/app/quests/layout.tsx 0.0 3.0 +3.0
src/components/LandingPage/Manteca.tsx 5.4 8.2 +2.9
src/components/Marketing/HtmlLang.tsx 0.0 2.7 +2.7
src/app/exchange/layout.tsx 0.0 2.5 +2.5
src/app/es-419/page.tsx 0.0 2.2 +2.2
src/app/pt-br/page.tsx 0.0 2.2 +2.2
src/app/es-ar/page.tsx 0.0 2.1 +2.1
src/app/sitemap.ts 12.3 14.4 +2.0

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2497 ran, 0 failed, 0 skipped, 36.5s

📊 Coverage (unit)

metric %
statements 62.3%
branches 45.3%
functions 51.5%
lines 62.9%
⏱ 10 slowest test cases
time test
2.7s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
0.8s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.2s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.2s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.2s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@innolope-dev

Copy link
Copy Markdown
Collaborator Author

@Hugo0 there are UI changes, so your approval will be needed. Two screenshots attached in the PR description.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/`[locale]/(marketing)/error.tsx:
- Around line 21-34: Update the Link in the error page component to derive its
destination from the active URL locale, using “/” only when the locale is “en”
and prefixing non-English locales such as “es-419” or “pt-br” accordingly; keep
the existing styling and localized label unchanged.

In `@src/components/LandingPage/Footer.tsx`:
- Line 34: Update the logo Link in the Footer component to preserve the active
locale: use "/" for English and `/${locale}` for localized landing pages such as
`/es-419` and `/pt-br`, replacing the hardcoded "/lp" destination.

In `@src/components/LandingPage/hero.tsx`:
- Line 84: Update src/components/LandingPage/hero.tsx lines 84-84 to accept a
locale: Locale prop and use it when constructing city-link hrefs instead of
hard-coding /en/. Update src/components/LandingPage/LandingPageClient.tsx lines
208-213 to pass the existing locale into Hero.

In `@src/components/LandingPage/noFees.tsx`:
- Line 167: Update the ExchangeRateWidget invocation in the LandingPage
component to pass the current catalog’s localized labels alongside the existing
CTA props. Reuse the established strings/catalog label symbol so send/receive
field labels follow the active es-419 and pt-br locales.

In `@src/components/Marketing/LocaleSwitcher.tsx`:
- Around line 61-97: Update the locale menu semantics in LocaleSwitcher by
removing the listbox and option roles, since the menu contains links and does
not implement listbox keyboard navigation. Preserve the existing toggle button,
locale links, selection styling, and open-state behavior.

In `@src/components/Marketing/mdx/RelatedPages.tsx`:
- Around line 35-36: Update RelatedPages and its internal RelatedLink rendering
so locale-less internal hrefs are normalized with the existing locale-aware
route helper using the injected locale, while preserving external and
already-localized URLs unchanged. Add a regression test covering a non-default
locale such as es-419 or pt-br.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 646c7861-c73c-4092-981d-411eb61742f7

📥 Commits

Reviewing files that changed from the base of the PR and between a0adc20 and 2af9948.

⛔ Files ignored due to path filters (2)
  • src/assets/icons/telegram-white.svg is excluded by !**/*.svg, !**/*.svg
  • src/assets/icons/x-logo.svg is excluded by !**/*.svg, !**/*.svg
📒 Files selected for processing (84)
  • redirects.json
  • scripts/native-build.js
  • src/__tests__/proxy-locale.test.ts
  • src/app/[locale]/(marketing)/[country]/page.tsx
  • src/app/[locale]/(marketing)/card-esign/page.tsx
  • src/app/[locale]/(marketing)/card-privacy/page.tsx
  • src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx
  • src/app/[locale]/(marketing)/card-terms-international/page.tsx
  • src/app/[locale]/(marketing)/card-terms-us/page.tsx
  • src/app/[locale]/(marketing)/compare/[slug]/page.tsx
  • src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
  • src/app/[locale]/(marketing)/error.tsx
  • src/app/[locale]/(marketing)/help/[slug]/page.tsx
  • src/app/[locale]/(marketing)/layout.tsx
  • src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
  • src/app/[locale]/(marketing)/pricing/page.tsx
  • src/app/[locale]/(marketing)/privacy/page.tsx
  • src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
  • src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
  • src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
  • src/app/[locale]/(marketing)/stories/[slug]/page.tsx
  • src/app/[locale]/(marketing)/stories/page.tsx
  • src/app/[locale]/(marketing)/supported-networks/page.tsx
  • src/app/[locale]/(marketing)/terms/page.tsx
  • src/app/[locale]/(marketing)/use-cases/[slug]/page.tsx
  • src/app/[locale]/(marketing)/withdraw/[slug]/page.tsx
  • src/app/es-419/page.tsx
  • src/app/exchange/page.tsx
  • src/app/lp/page.tsx
  • src/app/page.tsx
  • src/app/pt-br/page.tsx
  • src/app/quests/page.tsx
  • src/app/sitemap.ts
  • src/components/LandingPage/CardPioneers.tsx
  • src/components/LandingPage/Footer.tsx
  • src/components/LandingPage/LandingPageClient.tsx
  • src/components/LandingPage/LandingPageContent.tsx
  • src/components/LandingPage/Manteca.tsx
  • src/components/LandingPage/RegulatedRails.tsx
  • src/components/LandingPage/SEOFooter.tsx
  • src/components/LandingPage/SendInSecondsCTA.tsx
  • src/components/LandingPage/StickyMobileCTA.tsx
  • src/components/LandingPage/TweetCarousel.tsx
  • src/components/LandingPage/dropLink.tsx
  • src/components/LandingPage/hero.tsx
  • src/components/LandingPage/landingStrings.ts
  • src/components/LandingPage/noFees.tsx
  • src/components/LandingPage/securityBuiltIn.tsx
  • src/components/LandingPage/sendInSeconds.tsx
  • src/components/LandingPage/yourMoney.tsx
  • src/components/Marketing/ArticleBackNav.tsx
  • src/components/Marketing/ContentPage.tsx
  • src/components/Marketing/DestinationGrid.tsx
  • src/components/Marketing/LocaleSuggestion.tsx
  • src/components/Marketing/LocaleSwitcher.tsx
  • src/components/Marketing/MarketingErrorBoundary.tsx
  • src/components/Marketing/MarketingHero.tsx
  • src/components/Marketing/__tests__/locale-switcher.test.ts
  • src/components/Marketing/mdx/CountryGrid.tsx
  • src/components/Marketing/mdx/FAQ.tsx
  • src/components/Marketing/mdx/RelatedPages.tsx
  • src/components/Marketing/mdx/Steps.tsx
  • src/components/Marketing/mdx/components.tsx
  • src/constants/routes.ts
  • src/i18n/__tests__/locale-bridge.test.ts
  • src/i18n/__tests__/messages.test.ts
  • src/i18n/config.ts
  • src/i18n/en.json
  • src/i18n/es-419.json
  • src/i18n/es-ar.json
  • src/i18n/es-es.json
  • src/i18n/index.ts
  • src/i18n/localeBridge.ts
  • src/i18n/localeMeta.ts
  • src/i18n/pt-br.json
  • src/i18n/types.ts
  • src/i18n/useUrlLocale.ts
  • src/lib/blog.ts
  • src/lib/content.test.ts
  • src/lib/content.ts
  • src/lib/mdx.ts
  • src/lib/seo/landing.ts
  • src/proxy.ts
  • vercel.json
💤 Files with no reviewable changes (3)
  • src/i18n/es-ar.json
  • src/i18n/es-es.json
  • src/i18n/index.ts

Comment thread src/app/[locale]/(marketing)/error.tsx Outdated
Comment thread src/components/LandingPage/Footer.tsx Outdated
Comment thread src/components/LandingPage/hero.tsx
Comment thread src/components/LandingPage/noFees.tsx Outdated
Comment thread src/components/Marketing/LocaleSwitcher.tsx
Comment thread src/components/Marketing/mdx/RelatedPages.tsx
Four more locale leaks of the same class the PR set out to fix:

- hero.tsx: the city links (Buenos Aires / Sao Paulo / Floripa) were hardcoded
  to /en/, sending es-419 and pt-br visitors back to English.
- Footer.tsx: the logo linked to /lp, dropping the visitor's landing locale.
- error.tsx: "Go home" linked to /, so a direct /es-419/* visit with no cookie
  landed on English.
- mdx/RelatedPages.tsx: locale reached the heading but not the RelatedLink
  hrefs. Content authors write those both as /en/help/x and /help/x, so
  localizeContentHref strips any leading locale before prefixing.

ExchangeRateWidget only had its CTA localized; its send/receive field labels
fell back to English defaults. Now fed from the catalog.

Locale switcher: dropped the listbox/option roles. It is a toggleable list of
links, not a managed listbox, and advertising those roles promises arrow-key
selection that isn't implemented.

Also fixes the dropdown failing to open. The outside-click listener was bound
whenever the menu opened, so it went live while the click that opened it was
still being dispatched and closed it again — a race that resolved differently
per machine. It is now bound once on mount: a click on the trigger is inside
the wrapper, so the document handler never fires for it, and the button's
onClick owns the toggle. Listens for pointerdown so touch and pen work.

Social icons centre on their own row on mobile, inline on desktop.
Both pages rendered as bare prose with no header, unlike every other marketing
page. The pink hero and yellow marquee come from the MDX <Hero> component,
which help articles and the five card-* legal pages get because their content
markdown includes a <Hero /> tag. privacy and terms never had one.

The header goes in the page components rather than the markdown: legal content
is content_mode: verbatim, authored upstream in peanutprotocol/mono, and
content/legal/README.md is explicit that generation must not overwrite those
files — anything written into the mirror would be reverted on the next sync.

Title comes from frontmatter (with the "| Peanut" suffix already stripped by
displayTitle), so it picks up legal translations automatically if they ever
ship and falls back to English until then. The subtitle is a catalog string,
translated for all three locales.
Neither next.config headers() nor vercel.json survives Next rewriting Vary with
its own RSC list after the proxy runs; verified absent on a preview deploy both
times. Leaving the config in place implied a guarantee that was not there.

Not load-bearing: the proxy runs ahead of the cache on every request to /, so a
cached English / is still redirected for a visitor whose cookie says otherwise
(confirmed on the preview). The redirect response keeps its Vary, which nothing
rewrites.
Locale set is now en / es-419 / es-ar / pt-br on marketing and
en / es-419 / es-AR / pt-BR in the app. es-AR ships as a deltas-only
catalog (voseo) deepMerged over es-419 so shared keys are never
re-translated; the parity test asserts the delta file stays a subset of
en while the loader output stays complete. Marketing es-ar gets its
es-ar.json back (with the keys this branch added since), a literal
/es-ar landing route, LOCALE_META/HREFLANG entries and the reactivated
es-ar -> es-419 -> en content chain; the /es-ar 301s are gone.

Landing language UI: the LocaleSuggestion banner is switched off (kill
switch, mounts stay wired) and first-visit language now comes from
Accept-Language in the proxy — q-ordered, explicit English wins,
crawlers exempt (UA check; no UA = bot) so bots keep getting the
English / and hreflang keeps routing them. The redirect pre-sets the
shared cookie, preserves the query string, and runs after the promo
branch so ?promo links still resolve for cookie'd visitors.

SEO: / exports landingMetadata('en') making the landing hreflang
cluster reciprocal; hreflang + sitemap are content-aware (a locale URL
is only advertised when that locale's own file exists) and
fallback-served pages canonicalize to the locale that owns the prose —
which is what keeps restored es-ar from re-creating the near-duplicate
URL problem. og:locale/og:locale:alternate everywhere, locale-bound MDX
anchors via localizeContentHref, LOCALE_ORDER deleted in favour of
SUPPORTED_LOCALES, /en -> / upgraded to 301, /exchange and /quests get
their own canonicals, Googlebot may fetch /api/og, inLanguage on
breadcrumb/article/FAQ JSON-LD, and a client HtmlLang component stamps
the document language post-hydration (the root layout can't read the
locale without breaking the native static export).

Content submodule bumped to the newest mirror (3 new es-ar files).
…Hero

privacy/terms markdown is authored upstream with its own '# Title' — the
mirror is read-only, so rendering <Hero title> above it produced the same
string in two h1s. Strip only the FIRST node when it's an h1 (the bodies
use '##' for sections; a blanket strip would swallow authoring signals),
opt-in via renderContent's new stripLeadingH1 option, enabled on the two
pages that carry a Hero over verbatim content. The card-* legal pages
already follow the Hero-instead-of-h1 convention and are untouched.

Also merges the updated base (es-AR restore) and adds the two legal Hero
subtitle keys to the restored es-ar catalog (voseo).
@innolope-dev

Copy link
Copy Markdown
Collaborator Author

Pushed 5b2812eb7 — finalizes the PR per the updated locale decision. Summary of what changed since the last review round (the PR body is rewritten to match):

  • es-AR restored as a first-class locale: marketing es-ar (catalog restored + the keys this branch added since, literal /es-ar landing route, LOCALE_META/HREFLANG entries, 301s removed) and app es-AR as a deltas-only voseo catalog deepMerged over es-419 — parity test enforces subset-of-en for the delta file, complete loader output for all locales.
  • Review blockers fixed: locale redirect now preserves the query string and runs after the promo branch (both regression-tested); / exports landingMetadata('en') so the landing hreflang cluster is reciprocal; the MDX a override is locale-bound via localizeContentHref; LOCALE_ORDER deleted in favour of SUPPORTED_LOCALES.
  • Landing language UI: LocaleSuggestion banner off (kill switch, mounts stay wired), first visit now respects Accept-Language in the proxy with crawlers exempt + cookie pre-set.
  • SEO batch: content-aware hreflang/sitemap (a locale URL is only advertised when its own file exists; fallback pages canonicalize to the locale that owns the prose — this is what keeps es-ar from re-creating the near-duplicate problem), og:locale everywhere, /en→/ 301, /exchange + /quests canonicals, Googlebot allowed on /api/og, inLanguage JSON-LD, post-hydration html-lang stamp.
  • Content submodule bumped to the newest mirror.

Verified: 187 suites / 2,474 tests green, tsc clean, full production build green (1,043 static pages) — hreflang/canonical/og:locale behaviour checked in the built HTML. Known-English surfaces are listed honestly in the body (landing metadata blocked on mono translations).

…le filter, catalog-free client strings

- footer logo points at /lp again for en: the proxy bounces authed users
  from / into /home, which yanked signed-in visitors off the marketing site
- es-ar joins es-419/pt-br in the native-build exclusion list so the
  static export doesn't pick up the web-only localized landing
- Accept-Language ranges with q=0 are explicitly not acceptable (RFC 9110)
  and no longer trigger a locale redirect
- LocaleSuggestion and the marketing error boundary inline their few
  strings instead of importing '@/i18n', which shipped all four locale
  catalogs (~33KB raw) in the client bundle of every landing/marketing page
…d localization

Conflicts in LandingPageClient, StickyMobileCTA, hero: dev's device-based
store CTAs / migration flag wiring kept as-is; the PR's strings/locale
props layered on top. StickyMobileCTA's flag-off fallback now uses the
localized strings.signUpNow instead of hardcoded English; the PR's local
CTAButton type gave way to dev's shared landing.types.ts
Give /privacy and /terms the marketing page header

@kushagrasarathe kushagrasarathe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review verdict — APPROVE

Two full review passes (multi-agent + hand re-verification against PR heads), all blocking findings fixed and verified on this branch.

Verified in scope: locale set exactly en / es-419 / es-AR / pt-BR (es-ES out, en-US = plain English via resolveLocale); es-AR is a deltas-only overlay deep-merged over es-419 → en; localized landings are literal segments so peanut.me/{username} keeps resolving; banner switcher off, footer switcher kept, browser-locale preselect via proxy with crawlers exempt.

Fixed during review (pushed to this branch):

  • Locale redirect on / preserves query params and runs after the promo/UTM branch (was silently dropping promo claims)
  • Reciprocal hreflang: / now exports language alternates via landingMetadata
  • MDX anchor override locale-bound (no /en/ leaks on localized pages)
  • Footer logo back to auth-proof /lp for en (proxy bounces authed //home)
  • es-ar added to native-build exclusions alongside es-419/pt-br
  • q=0 Accept-Language ranges (RFC 9110 "not acceptable") no longer redirect
  • Client components no longer bundle all four locale catalogs (~33KB) — LocaleSuggestion + marketing error boundary use inlined strings
  • Legal pages: duplicate h1 resolved (via #2593, now merged in)
  • dev merge (native-app migration) resolved keeping migration CTAs byte-identical + localized fallback labels

Known follow-ups (non-blocking): landing content translations don't exist in peanut-content yet — localized landings fall back to English hero CTA/marquee/FAQ until authored + submodule bumped (do before prod release); hreflang builder triplication in i18n/config.ts and the ~21-file canonical/hreflang metadata block are DRY debt for a later pass.

CI green on the merged head.

the localized nav labels forced the nav onto its own row, but at
text-xl bold it competed with the brand row as a third heavy band.
small text + thin rules + inline socials keep the three-section
structure without the visual weight
per design call: Telegram/X/GitHub move up to the brand row's right edge;
the language switcher and hand illustrations come out of the footer.
locale choice now relies on the proxy's Accept-Language redirect and the
app-side settings picker
… directory clearance

desktop: socials top-right in the brand row, switcher right of the nav
links. mobile: brand, then socials+switcher on one justify-between row,
then nav links, then directory. pb-24 on the directory for mobile only —
the homepage's fixed CTA was covering the last rows
…the switcher

peace + middle finger keep their youtube easter-egg links
@kushagrasarathe
kushagrasarathe merged commit e5f48bc into dev Aug 5, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants