Localize the marketing site: three locales, localized landing, language switcher - #2592
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
Code-analysis diffPainscore total: 6885.89 → 6986.99 (+101.1) 🆕 New findings (274)
…and 254 more. ✅ Resolved (240)
…and 220 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
|
@Hugo0 there are UI changes, so your approval will be needed. Two screenshots attached in the PR description. |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (2)
src/assets/icons/telegram-white.svgis excluded by!**/*.svg,!**/*.svgsrc/assets/icons/x-logo.svgis excluded by!**/*.svg,!**/*.svg
📒 Files selected for processing (84)
redirects.jsonscripts/native-build.jssrc/__tests__/proxy-locale.test.tssrc/app/[locale]/(marketing)/[country]/page.tsxsrc/app/[locale]/(marketing)/card-esign/page.tsxsrc/app/[locale]/(marketing)/card-privacy/page.tsxsrc/app/[locale]/(marketing)/card-prohibited-activities/page.tsxsrc/app/[locale]/(marketing)/card-terms-international/page.tsxsrc/app/[locale]/(marketing)/card-terms-us/page.tsxsrc/app/[locale]/(marketing)/compare/[slug]/page.tsxsrc/app/[locale]/(marketing)/deposit/[exchange]/page.tsxsrc/app/[locale]/(marketing)/error.tsxsrc/app/[locale]/(marketing)/help/[slug]/page.tsxsrc/app/[locale]/(marketing)/layout.tsxsrc/app/[locale]/(marketing)/pay-with/[method]/page.tsxsrc/app/[locale]/(marketing)/pricing/page.tsxsrc/app/[locale]/(marketing)/privacy/page.tsxsrc/app/[locale]/(marketing)/receive-money-from/[country]/page.tsxsrc/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsxsrc/app/[locale]/(marketing)/send-money-to/[country]/page.tsxsrc/app/[locale]/(marketing)/stories/[slug]/page.tsxsrc/app/[locale]/(marketing)/stories/page.tsxsrc/app/[locale]/(marketing)/supported-networks/page.tsxsrc/app/[locale]/(marketing)/terms/page.tsxsrc/app/[locale]/(marketing)/use-cases/[slug]/page.tsxsrc/app/[locale]/(marketing)/withdraw/[slug]/page.tsxsrc/app/es-419/page.tsxsrc/app/exchange/page.tsxsrc/app/lp/page.tsxsrc/app/page.tsxsrc/app/pt-br/page.tsxsrc/app/quests/page.tsxsrc/app/sitemap.tssrc/components/LandingPage/CardPioneers.tsxsrc/components/LandingPage/Footer.tsxsrc/components/LandingPage/LandingPageClient.tsxsrc/components/LandingPage/LandingPageContent.tsxsrc/components/LandingPage/Manteca.tsxsrc/components/LandingPage/RegulatedRails.tsxsrc/components/LandingPage/SEOFooter.tsxsrc/components/LandingPage/SendInSecondsCTA.tsxsrc/components/LandingPage/StickyMobileCTA.tsxsrc/components/LandingPage/TweetCarousel.tsxsrc/components/LandingPage/dropLink.tsxsrc/components/LandingPage/hero.tsxsrc/components/LandingPage/landingStrings.tssrc/components/LandingPage/noFees.tsxsrc/components/LandingPage/securityBuiltIn.tsxsrc/components/LandingPage/sendInSeconds.tsxsrc/components/LandingPage/yourMoney.tsxsrc/components/Marketing/ArticleBackNav.tsxsrc/components/Marketing/ContentPage.tsxsrc/components/Marketing/DestinationGrid.tsxsrc/components/Marketing/LocaleSuggestion.tsxsrc/components/Marketing/LocaleSwitcher.tsxsrc/components/Marketing/MarketingErrorBoundary.tsxsrc/components/Marketing/MarketingHero.tsxsrc/components/Marketing/__tests__/locale-switcher.test.tssrc/components/Marketing/mdx/CountryGrid.tsxsrc/components/Marketing/mdx/FAQ.tsxsrc/components/Marketing/mdx/RelatedPages.tsxsrc/components/Marketing/mdx/Steps.tsxsrc/components/Marketing/mdx/components.tsxsrc/constants/routes.tssrc/i18n/__tests__/locale-bridge.test.tssrc/i18n/__tests__/messages.test.tssrc/i18n/config.tssrc/i18n/en.jsonsrc/i18n/es-419.jsonsrc/i18n/es-ar.jsonsrc/i18n/es-es.jsonsrc/i18n/index.tssrc/i18n/localeBridge.tssrc/i18n/localeMeta.tssrc/i18n/pt-br.jsonsrc/i18n/types.tssrc/i18n/useUrlLocale.tssrc/lib/blog.tssrc/lib/content.test.tssrc/lib/content.tssrc/lib/mdx.tssrc/lib/seo/landing.tssrc/proxy.tsvercel.json
💤 Files with no reviewable changes (3)
- src/i18n/es-ar.json
- src/i18n/es-es.json
- src/i18n/index.ts
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).
…o feat/legal-pages-header
…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).
|
Pushed
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
left a comment
There was a problem hiding this comment.
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 vialandingMetadata - MDX anchor override locale-bound (no
/en/leaks on localized pages) - Footer logo back to auth-proof
/lpfor en (proxy bounces authed/→/home) es-aradded to native-build exclusions alongside es-419/pt-brq=0Accept-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
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
en,es-419,es-ar,pt-br— es-AR restored as a first-class locale (own landing route, catalog, switcher entry, hreflanges-AR). Content fallbackes-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).en,es-419,es-AR,pt-BR.es-AR.jsonis 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 ofenwhile the loader output stays complete.es-AR(app) /es-ar(marketing); the sharedapp-localecookie carries the choice both ways.Landing language UI
LocaleSuggestionbanner is switched off (kill switch in the component; mounts stay wired for re-enable). The footerLocaleSwitcherstays./perAccept-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.?promo=…&id=…links work for cookie'd users.SEO
/now exportslandingMetadata('en')— landing hreflang cluster is reciprocal (was one-way: localized landings pointed at/,/declared nothing).og:locale+og:locale:alternateon all marketing metadata (en_US / es_LA / es_AR / pt_BR).aoverride is locale-bound (localizeContentHref) — markdown links on Spanish/Portuguese pages no longer leak to/en/….LOCALE_ORDERdeleted; switchers iterateSUPPORTED_LOCALES(a 4th locale can no longer compile without appearing in the pickers). SwitcherhrefLanguses proper BCP-47 casing./en → /upgraded 307 → 301; sitemap lists/en/privacy+/en/termsinstead of the redirecting bare URLs;/exchangeand/questsno longer inheritcanonical: '/'; Googlebot may fetch/api/og;inLanguageon breadcrumb/article/FAQ JSON-LD.<html lang>: SSR still shipslang="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 clientHtmlLangcomponent now corrects the document language post-hydration;<main lang>stays as the SSR-visible scope.What still ships English (known, deliberate)
content/landing/has onlyen.mdupstream 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 authoringcontent/landing/{es-419,es-ar,pt-br}.md+ a submodule bump. The FAQ JSON-LD honestly declaresinLanguageof the prose it resolved to.terms,privacy,card-*),team,help/delete-account, one blog post: en-only content; localized URLs serve English via fallback. They now canonicalize to theenURL 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.es-arrender es-419 prose (by design of the fallback chain).Verification
tsc --noEmitclean; fullpnpm buildgreen (all locale routes statically generated).