From 012d4904b0ad194c2d695e4973924a3eb0c3f179 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 09:27:52 +0000
Subject: [PATCH 01/10] =?UTF-8?q?feat(landing):=20homepage=20rebuild=20?=
=?UTF-8?q?=E2=80=94=20main-based=20port=20of=20the=20LP=20refresh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports the whole LP change set onto main so it can ship to production. dev is
75 commits ahead of main, so #2690 cannot simply retarget; this is the same
diff rebuilt on main's tree.
Of the 51 files in the change set, 35 are byte-identical between main and dev
and carried over whole. The other 16 also move on dev, so each was three-way
merged with dev as the base — main's version plus the LP delta and nothing
else. That keeps dev's unrelated work out: main's catalogs gain the seven
ProblemFold keys, lose landingPayLocalSettles, and take the reworded support
and footer strings, but none of dev's other new keys come along.
src/content stays on main's pointer. Production content moves on its own
pipeline and must not be dragged backwards or forwards by a code PR.
---
redirects.json | 5 +
scripts/generate-valid-links.ts | 2 +-
scripts/ping-indexnow.ts | 2 +-
scripts/verify-content.ts | 2 +-
src/app/lp/card/CardLandingPage.tsx | 867 ------------------
src/app/lp/card/page.tsx | 25 -
src/app/robots.ts | 1 -
src/app/shhhhh/ShhhhhLandingPage.tsx | 23 +-
src/app/shhhhh/page.tsx | 2 +-
src/app/sitemap.ts | 6 +-
src/assets/icons/pix-logo.svg | 2 +-
src/assets/illustrations/flag-ar.svg | 35 +
src/assets/illustrations/flag-br.svg | 42 +
src/assets/illustrations/stack-coin.svg | 8 +
src/assets/logos/index.ts | 2 -
src/components/Card/doorTally.utils.ts | 9 +-
.../Card/share-asset/PixelatedCardFace.tsx | 7 +-
src/components/Global/FAQs/index.tsx | 17 +-
.../Global/MarqueeWrapper/index.tsx | 25 +-
.../Global/MarqueeWrapper/marquee.types.ts | 10 +
src/components/LandingPage/CardPioneers.tsx | 115 ---
.../LandingPage/LandingPageClient.tsx | 112 ++-
.../LandingPage/LandingPageContent.tsx | 11 +-
src/components/LandingPage/Manteca.tsx | 79 +-
src/components/LandingPage/PartnerLockup.tsx | 56 ++
src/components/LandingPage/PioneerCard3D.tsx | 158 ----
src/components/LandingPage/ProblemFold.tsx | 77 ++
src/components/LandingPage/RegulatedRails.tsx | 59 +-
.../LandingPage/ScarcityCounter.tsx | 40 +
src/components/LandingPage/ShhhhhFold.tsx | 76 ++
.../__tests__/landingLinks.utils.test.ts | 61 ++
src/components/LandingPage/faq.tsx | 4 +-
src/components/LandingPage/index.ts | 1 -
src/components/LandingPage/landing.types.ts | 15 +
.../LandingPage/landingLinks.utils.ts | 49 +
src/components/LandingPage/landingStrings.ts | 13 +
src/components/LandingPage/marquee.tsx | 4 +-
.../LandingPage/securityBuiltIn.tsx | 16 +-
src/components/LandingPage/yourMoney.tsx | 22 +-
src/config/underMaintenance.config.ts | 11 +-
src/i18n/app/__tests__/shhhhh-catalog.test.ts | 2 +-
src/i18n/app/messages/en.json | 4 +-
src/i18n/app/messages/es-419.json | 4 +-
src/i18n/app/messages/es-AR.json | 2 +-
src/i18n/app/messages/pt-BR.json | 4 +-
src/i18n/en.json | 12 +-
src/i18n/es-419.json | 10 +-
src/i18n/es-ar.json | 10 +-
src/i18n/pt-br.json | 10 +-
src/i18n/types.ts | 10 +-
50 files changed, 828 insertions(+), 1311 deletions(-)
delete mode 100644 src/app/lp/card/CardLandingPage.tsx
delete mode 100644 src/app/lp/card/page.tsx
create mode 100644 src/assets/illustrations/flag-ar.svg
create mode 100644 src/assets/illustrations/flag-br.svg
create mode 100644 src/assets/illustrations/stack-coin.svg
create mode 100644 src/components/Global/MarqueeWrapper/marquee.types.ts
delete mode 100644 src/components/LandingPage/CardPioneers.tsx
create mode 100644 src/components/LandingPage/PartnerLockup.tsx
delete mode 100644 src/components/LandingPage/PioneerCard3D.tsx
create mode 100644 src/components/LandingPage/ProblemFold.tsx
create mode 100644 src/components/LandingPage/ScarcityCounter.tsx
create mode 100644 src/components/LandingPage/ShhhhhFold.tsx
create mode 100644 src/components/LandingPage/__tests__/landingLinks.utils.test.ts
create mode 100644 src/components/LandingPage/landingLinks.utils.ts
diff --git a/redirects.json b/redirects.json
index f29571fec6..5369087048 100644
--- a/redirects.json
+++ b/redirects.json
@@ -141,6 +141,11 @@
"destination": "/",
"permanent": true
},
+ {
+ "source": "/lp/card",
+ "destination": "/shhhhh",
+ "permanent": true
+ },
{
"source": "/sh",
"destination": "/card",
diff --git a/scripts/generate-valid-links.ts b/scripts/generate-valid-links.ts
index 82911450c0..64bd4b1c70 100644
--- a/scripts/generate-valid-links.ts
+++ b/scripts/generate-valid-links.ts
@@ -59,7 +59,7 @@ function main() {
// Static pages
lines.push('## Static Pages')
lines.push('')
- for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms']) {
lines.push(`- \`${p}\``)
}
lines.push('')
diff --git a/scripts/ping-indexnow.ts b/scripts/ping-indexnow.ts
index f0d0e0c1bd..c9ba84493d 100644
--- a/scripts/ping-indexnow.ts
+++ b/scripts/ping-indexnow.ts
@@ -27,7 +27,7 @@ async function getAllPaths(): Promise {
const { SUPPORTED_LOCALES } = await import('../src/i18n/types')
const { listContentSlugs } = await import('../src/lib/content')
- const paths: string[] = ['/', '/lp/card', '/careers', '/exchange', '/privacy', '/terms']
+ const paths: string[] = ['/', '/shhhhh', '/careers', '/exchange', '/privacy', '/terms']
for (const locale of SUPPORTED_LOCALES) {
for (const country of Object.keys(COUNTRIES_SEO)) {
diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts
index c45997ffce..79418ccc9e 100644
--- a/scripts/verify-content.ts
+++ b/scripts/verify-content.ts
@@ -157,7 +157,7 @@ function discoverRoutes(): Set {
const routes = new Set()
// Static pages
- for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms']) {
routes.add(p)
}
diff --git a/src/app/lp/card/CardLandingPage.tsx b/src/app/lp/card/CardLandingPage.tsx
deleted file mode 100644
index 6444eefc27..0000000000
--- a/src/app/lp/card/CardLandingPage.tsx
+++ /dev/null
@@ -1,867 +0,0 @@
-'use client'
-import { motion } from 'framer-motion'
-import Image from 'next/image'
-import { Button } from '@/components/0_Bruddle/Button'
-import { getFlagUrl } from '@/constants/countryCurrencyMapping'
-import { FAQsPanel } from '@/components/Global/FAQs'
-import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
-import { Marquee } from '@/components/LandingPage'
-import { useAuth } from '@/context/authContext'
-import { useRouter } from 'next/navigation'
-import Star from '@/assets/illustrations/star.svg'
-import HandThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg'
-import { useEffect } from 'react'
-import underMaintenanceConfig from '@/config/underMaintenance.config'
-
-const faqQuestions = [
- {
- id: '0',
- question: 'What is Card Pioneers?',
- answer: 'Card Pioneers is the early-access program for the Peanut Card. Reserve a spot, get priority rollout access, and unlock Pioneer perks like $5 for every friend you refer.',
- },
- {
- id: '1',
- question: 'How does it work?',
- answer: '1. Reserve your spot by adding $10 in starter card balance. 2. Share your Peanut invite link. 3. When someone joins Card Pioneers through your invite, you earn $5 instantly, plus rewards every time they spend - forever.',
- },
- {
- id: '2',
- question: 'Is the $10 refundable?',
- answer: "Your $10 becomes starter balance when your card launches. If you're found not eligible at launch (for example: your region isn't supported, or you can't complete required verification), you can request a refund.",
- },
- {
- id: '3',
- question: 'Where is the card available?',
- answer: "We're rolling out by region in stages: US, Latin America, and Africa. You'll see eligibility details during signup.",
- },
- {
- id: '4',
- question: "Do I earn from my invites' invites too?",
- answer: 'Yes! You earn a smaller part for your entire invite tree. So if you invite someone who becomes a power-referrer, you earn from everyone they bring in too.',
- },
-]
-
-const CardLandingPage = () => {
- const { user } = useAuth()
- const router = useRouter()
- // feature flag: redirect to landing if card pioneers is disabled
- useEffect(() => {
- if (underMaintenanceConfig.disableCardPioneers) {
- router.replace('/')
- }
- }, [router])
-
- if (underMaintenanceConfig.disableCardPioneers) {
- return null
- }
-
- const handleCTA = () => {
- if (user) {
- router.push('/card')
- } else {
- router.push('/setup?redirect_uri=/card')
- }
- }
-
- // Marquee copy from CARD_coremessaging.md
- const marqueeProps = {
- visible: true,
- message: ['EARLY = EARN', 'BUILD YOUR TREE', 'ONE LINK', 'LIFETIME UPSIDE', '$5 PER INVITE', 'EARN FOREVER'],
- }
-
- return (
- <>
- {/* Hero Section - Yellow with card */}
-
-
-
-
-
-
- YOUR DOLLARS.
-
- EVERYWHERE.
-
-
-
- Pay with the peanut card. Earn with every purchase, yours or your friends.
-
-
- Non-custodial. Best rates. No hidden fees.
-
-
-
-
-
-
-
-
- JOIN PIONEERS
-
-
- $10 starter balance = your spot secured
-
-
-
-
-
-
-
-
- {/* How it works - Cream */}
-
-
-
-
-
- HOW IT WORKS
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Earn Forever - Cream Background */}
-
-
-
-
- {/* Visual - Simplified Invite Visual */}
-
-
- {/*
- LAYOUT - Calculated with Python trigonometry
- Container: 340x380
-
- L0 (YOU): center (170, 190), 80x80px
- L1 nodes: 48x48px, 120px from YOU
- - Top: center (170, 70) - outward angle -90°
- - Bottom-left: center (70, 310) - outward angle 129.8°
- - Bottom-right: center (270, 310) - outward angle 50.2°
-
- L2 nodes: 32x32px, 55px from parent L1 center, fanning at -45°, 0°, +45° from outward direction
- Top L1 (170,70): (131,31), (170,15), (209,31)
- Bottom-left L1 (70,310): (75,365), (35,352), (15,315)
- Bottom-right L1 (270,310): (325,315), (305,352), (265,365)
- */}
-
- {/* Connection lines */}
-
- {/* L0 to L1 edges */}
-
-
-
-
- {/* Top L1 (170,70) to L2 */}
-
-
-
-
- {/* Bottom-left L1 (70,310) to L2 */}
-
-
-
-
- {/* Bottom-right L1 (270,310) to L2 */}
-
-
-
-
-
- {/* L0: YOU node - center (170,190), top-left (130,150) */}
-
- YOU
-
-
- {/* +$5 BADGES - at visual midpoint between node edges
- Top edge: YOU bottom (y=150) to L1 top (y=94) -> visual mid = (150+94)/2 = 122, badge top = 114
- Bottom edges: at midpoint of line between centers
- */}
-
- +$5
-
-
- +$5
-
-
- +$5
-
-
- {/* L1: Top primary - center (170,70), top-left (146,46) */}
-
-
-
-
- {/* L1: Bottom-left primary - center (70,310), top-left (46,286) */}
-
-
-
-
- {/* L1: Bottom-right primary - center (270,310), top-left (246,286) */}
-
-
-
-
- {/* L2 NODES - positioned directly at calculated centers
- Each node is 32x32, so top-left = center - 16
- Top L1 (170,70): L2 at (131,31), (170,15), (209,31)
- Bottom-left L1 (70,310): L2 at (75,365), (35,352), (15,315)
- Bottom-right L1 (270,310): L2 at (325,315), (305,352), (265,365)
- */}
-
- {/* Top L1's children - labels 2px gap from node edge */}
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
- {/* Bottom-left L1's children */}
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
- {/* Bottom-right L1's children */}
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
-
-
-
-
- +%
-
-
-
-
- {/* Copy */}
-
-
- INVITE ONCE.
-
- EARN FOREVER.
-
-
-
-
-
-
-
-
-
- START EARNING
-
-
-
-
-
-
-
-
- {/* Coverage - Yellow */}
-
-
-
-
-
- ROLLING OUT
-
- GLOBALLY
-
-
-
- Starting with US , Latin America , and Africa
-
-
-
- {/* Individual country flags */}
- {[
- { name: 'United States', code: 'us' },
- { name: 'Brazil', code: 'br' },
- { name: 'Argentina', code: 'ar' },
- { name: 'Mexico', code: 'mx' },
- { name: 'Nigeria', code: 'ng' },
- { name: 'Kenya', code: 'ke' },
- { name: 'South Africa', code: 'za' },
- ].map((country, i) => (
-
-
- {country.name}
-
- ))}
-
- {/* Region pills without flags */}
-
- Latin America
-
-
- Africa
-
-
- + more
-
-
-
-
-
-
-
- {/* FAQ - Cream */}
-
-
-
-
- {/* Final CTA - Secondary Yellow */}
-
-
-
-
-
-
- Early access is open
-
-
-
- READY TO
-
- JOIN?
-
-
- $10 reserves your spot. And for every friend you invite, earn forever.
-
-
-
- BECOME A PIONEER
-
-
-
-
-
-
- >
- )
-}
-
-// Floating stars component
-const FloatingStars = () => {
- const starConfigs = [
- { className: 'absolute left-12 top-10', delay: 0.2 },
- { className: 'absolute left-56 top-1/2', delay: 0.2 },
- { className: 'absolute bottom-20 left-20', delay: 0.2 },
- { className: 'absolute -top-16 right-20 md:top-10', delay: 0.6 },
- { className: 'absolute bottom-20 right-44', delay: 0.6 },
- ]
-
- return (
- <>
- {starConfigs.map((config, index) => (
-
- ))}
- >
- )
-}
-
-// Step card component
-const StepCard = ({
- num,
- title,
- desc,
- color,
- textLight,
- delay,
-}: {
- num: string
- title: string
- desc: string
- color: string
- textLight?: boolean
- delay: number
-}) => (
-
-
- {num}
-
- {title}
- {desc}
-
-)
-
-// Reward item component
-const RewardItem = ({ amount, label }: { amount: string; label: string }) => (
-
-
- {amount}
-
- {label}
-
-)
-
-export default CardLandingPage
diff --git a/src/app/lp/card/page.tsx b/src/app/lp/card/page.tsx
deleted file mode 100644
index 90687fc5b3..0000000000
--- a/src/app/lp/card/page.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { generateMetadata as generateMeta } from '@/app/metadata'
-import { LandingPageShell } from '@/components/LandingPage/LandingPageShell'
-import Footer from '@/components/LandingPage/Footer'
-import CardLandingPage from './CardLandingPage'
-
-export const metadata = generateMeta({
- title: 'Card Pioneers | Get Early Access to Peanut Card',
- description:
- 'Join Card Pioneers for early access to the Peanut Card. Reserve your spot with $10, earn $5 for every friend who joins, and spend your dollars globally.',
- keywords:
- 'peanut card, card pioneers, crypto card, digital dollars, global spending, early access, referral rewards, international card',
- // Without this the page inherits lp/layout.tsx's deliberate `canonical: '/'`
- // (the /lp subtree aliases the root landing page). That policy is wrong for
- // /lp/card specifically: it's a distinct product page sitting in the sitemap.
- canonical: '/lp/card',
-})
-
-export default function CardLPPage() {
- return (
-
-
-
-
- )
-}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 90b92a96fa..aae9be1a6b 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -97,7 +97,6 @@ export default function robots(): MetadataRoute.Robots {
'/privacy',
'/terms',
'/exchange',
- '/lp/card',
// SEO routes (all locale-prefixed)
...SUPPORTED_LOCALES.map((l) => `/${l}/`),
],
diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx
index f4f8970fe6..ae7505ba50 100644
--- a/src/app/shhhhh/ShhhhhLandingPage.tsx
+++ b/src/app/shhhhh/ShhhhhLandingPage.tsx
@@ -8,6 +8,7 @@ import posthog from 'posthog-js'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/0_Bruddle/Button'
import { Marquee } from '@/components/LandingPage'
+import { ScarcityCounter } from '@/components/LandingPage/ScarcityCounter'
import { useAuth } from '@/context/authContext'
import { PixelatedCardFace } from '@/components/Card/share-asset/PixelatedCardFace'
import { inflateWaitlistPosition } from '@/components/Card/doorTally.utils'
@@ -81,24 +82,6 @@ const badges: Array<{ code: string; name: string }> = [
const ctaButtonClassName =
'!w-auto bg-white px-7 py-3 text-base font-extrabold hover:bg-white/90 md:px-9 md:py-8 md:text-xl'
-function ScarcityCounter() {
- const t = useTranslations('shhhhh.hero')
- const [count, setCount] = useState(21)
- useEffect(() => {
- const timer = setTimeout(() => setCount(20), 2500)
- return () => clearTimeout(timer)
- }, [])
- return (
-
- {t('onlyCount', { count })}
-
- )
-}
-
function StickyShhhhhCTA({ onClick }: { onClick: () => void }) {
const t = useTranslations('shhhhh.hero')
const [visible, setVisible] = useState(false)
@@ -316,7 +299,9 @@ export default function ShhhhhLandingPage() {
{t('hero.tagline')}
- {t.rich('hero.body', { counter: () => })}
+ {t.rich('hero.body', {
+ counter: () => t('hero.onlyCount', { count })} />,
+ })}
{isJoined ? (
diff --git a/src/app/shhhhh/page.tsx b/src/app/shhhhh/page.tsx
index 64138309d5..87752bbcb6 100644
--- a/src/app/shhhhh/page.tsx
+++ b/src/app/shhhhh/page.tsx
@@ -6,7 +6,7 @@ import ShhhhhLandingPage from './ShhhhhLandingPage'
export const metadata = generateMeta({
title: 'Shhhhh. | The peanut card is out, quietly.',
description:
- 'A non-custodial card accepted at over 150 million Visa-accepting merchants. We are letting beta users in slowly — about 20 a week.',
+ 'A non-custodial card accepted at over 150 million Visa-accepting merchants. We are letting beta users in slowly — about 50 a week.',
keywords: 'peanut card, non-custodial card, visa-accepting merchants, closed beta, stablecoins',
canonical: '/shhhhh',
})
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 8bb5226337..6e6805cab4 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -58,12 +58,12 @@ async function generateSitemap(): Promise
{
// Homepage
{ path: '', priority: 1.0, changeFrequency: 'weekly' },
- // Product pages
- { path: '/lp/card', priority: 0.9, changeFrequency: 'weekly' },
-
// Public pages
{ path: '/careers', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/exchange', priority: 0.7, changeFrequency: 'weekly' },
+ // The only card landing URL left — /lp/card is a 308 to it, so the
+ // target has to be listed.
+ { path: '/shhhhh', priority: 0.7, changeFrequency: 'weekly' },
// Legal — the bare /privacy and /terms are redirects; list the real URLs.
{ path: '/en/privacy', priority: 0.5, changeFrequency: 'yearly' },
diff --git a/src/assets/icons/pix-logo.svg b/src/assets/icons/pix-logo.svg
index 4b5628c4ca..46a4882a2a 100644
--- a/src/assets/icons/pix-logo.svg
+++ b/src/assets/icons/pix-logo.svg
@@ -1,4 +1,4 @@
-
+
diff --git a/src/assets/illustrations/flag-ar.svg b/src/assets/illustrations/flag-ar.svg
new file mode 100644
index 0000000000..94a308cba6
--- /dev/null
+++ b/src/assets/illustrations/flag-ar.svg
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/illustrations/flag-br.svg b/src/assets/illustrations/flag-br.svg
new file mode 100644
index 0000000000..e87f8036cb
--- /dev/null
+++ b/src/assets/illustrations/flag-br.svg
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/illustrations/stack-coin.svg b/src/assets/illustrations/stack-coin.svg
new file mode 100644
index 0000000000..f125d3602a
--- /dev/null
+++ b/src/assets/illustrations/stack-coin.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/logos/index.ts b/src/assets/logos/index.ts
index 4cb16fce15..535cb05908 100644
--- a/src/assets/logos/index.ts
+++ b/src/assets/logos/index.ts
@@ -1,5 +1,3 @@
export { default as PEANUT_LOGO } from './peanut-logo.svg';
export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg';
-export { default as MEPA_ARGENTINA_LOGO } from './mepa-arg.svg';
-export { default as PIX_BRZ_LOGO } from './pix-brz.svg';
export { default as DEVCONNECT_LOGO } from './devconnect.svg';
\ No newline at end of file
diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts
index 98dab41484..d92fcd94f0 100644
--- a/src/components/Card/doorTally.utils.ts
+++ b/src/components/Card/doorTally.utils.ts
@@ -3,7 +3,7 @@
*
* The backend (/card → waitlistTotal, admittedTotal) reports the REAL counts.
* "tried" gets inflated for FOMO — the same fake-scarcity flex the /shhhhh
- * landing already does with its "only 20 a week" ScarcityCounter — while
+ * landing already does with its "only 50 a week" ScarcityCounter — while
* "got in" is shown verbatim (the real number admitted).
*
* The inflation is a pure function of the real count (a constant multiplier
@@ -11,6 +11,13 @@
* frames. No randomness on purpose.
*/
+/**
+ * Admissions per week in the closed beta (product/card.md). Drives the
+ * ScarcityCounter pill on the homepage door fold and on /shhhhh, so both
+ * surfaces quote the same number.
+ */
+export const ADMISSIONS_PER_WEEK = 50
+
/**
* FOMO inflation factor applied to the real waitlist size for "tried". ×5 (not
* ×3) so the real number clears the 213 floor at the current prod waitlist size
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx
index f3e5f57a30..bfe6c57c37 100644
--- a/src/components/Card/share-asset/PixelatedCardFace.tsx
+++ b/src/components/Card/share-asset/PixelatedCardFace.tsx
@@ -59,6 +59,10 @@ export interface PixelatedCardFaceProps {
* display the Visa wordmark for compliance reasons (it renders crisp
* there since the share asset is the one surface that doesn't `blurAll`). */
hideVisa?: boolean
+ /** Colour of the offset drop shadow. Defaults to the design-system black;
+ * the homepage door fold overrides it because a black shadow is invisible
+ * on that fold's black ground. */
+ shadowColor?: string
/** Fires once the hand has loaded — i.e. the card face is fully
* painted. Capture surfaces gate the Share/Save buttons on this so a
* snapshot can never fire before the hand is ready. */
@@ -70,6 +74,7 @@ export const PixelatedCardFace: FC = ({
style,
blurAll = false,
hideVisa = false,
+ shadowColor = '#000',
onReady,
}) => (
// The drop-shadow is an offset sibling, NOT a CSS box-shadow: html-to-image
@@ -80,7 +85,7 @@ export const PixelatedCardFace: FC = ({
}
+// Matches the landing page's other "learn more" affordances: right-aligned,
+// underlined at rest, arrow trailing.
+const learnMoreClass = 'font-roboto-flex text-base text-n-1 underline hover:no-underline md:text-lg'
+
function linkifyText(text: string) {
const markdownLinkRegex = /\[([^\]]+)\]\(([^\s)]+)\)/g
const parts: (string | JSX.Element)[] = []
@@ -41,7 +49,7 @@ function linkifyText(text: string) {
return parts
}
-export function FAQsPanel({ heading, questions }: FAQsProps) {
+export function FAQsPanel({ heading, questions, learnMoreLabel = 'Learn more' }: FAQsProps) {
return (
@@ -78,6 +86,13 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
{faq.redirectText}
)}
+ {faq.learnMoreHref && (
+
+
+ {learnMoreLabel} →
+
+
+ )}
))}
diff --git a/src/components/Global/MarqueeWrapper/index.tsx b/src/components/Global/MarqueeWrapper/index.tsx
index d2d3eb7207..dcc2d64ccf 100644
--- a/src/components/Global/MarqueeWrapper/index.tsx
+++ b/src/components/Global/MarqueeWrapper/index.tsx
@@ -1,7 +1,9 @@
'use client'
import Image from 'next/image'
+import Link from 'next/link'
import Marquee from 'react-fast-marquee'
+import type { MarqueeItem } from './marquee.types'
type directionType = 'left' | 'right' | 'up' | 'down' | undefined
@@ -32,6 +34,19 @@ export function MarqueeWrapper({
)
}
+const wordClass = 'text-lg font-bold uppercase md:text-xl'
+
+// A word is either plain text or a link — the strip looks identical either way,
+// so a linked word still reads as part of the run of words.
+function MarqueeWord({ item }: { item: MarqueeItem }) {
+ if (typeof item === 'string') return {item}
+ return (
+
+ {item.label}
+
+ )
+}
+
// MarqueeComp: A pre-configured marquee component with message and image
export function MarqueeComp({
message,
@@ -39,7 +54,7 @@ export function MarqueeComp({
imageAnimationClass = 'animation-thumbsUp',
backgroundColor = 'bg-primary',
}: {
- message?: string | string[]
+ message?: string | MarqueeItem[]
imageSrc: string
imageAnimationClass?: string
backgroundColor?: string
@@ -50,11 +65,11 @@ export function MarqueeComp({
{Array.isArray(message)
? message.map((msg, index) => (
-
{msg}
+
{index < message.length && (
- {message}
+ {message}
{
- const router = useRouter()
- const [screenWidth, setScreenWidth] = useState(1200)
-
- useEffect(() => {
- const handleResize = () => setScreenWidth(window.innerWidth)
- handleResize() // Set actual width on mount
- window.addEventListener('resize', handleResize)
- return () => window.removeEventListener('resize', handleResize)
- }, [])
-
- const isMobile = screenWidth < 768
-
- const handleCTA = () => {
- router.push('/lp/card')
- }
-
- return (
-
- {!isMobile && }
-
- {/* Card on left */}
-
-
- {/* Copy on right */}
-
-
- {strings.cardHeading}
-
-
-
{strings.cardBody}
-
-
-
-
- {strings.cardBullet1}
-
-
-
- {strings.cardBullet2}
-
-
-
- {strings.cardBullet3}
-
-
-
- {strings.cardDisclaimer}
-
-
-
-
- {strings.cardCta}
-
-
-
-
-
- )
-}
-
-// Animated stars - matches Manteca.tsx pattern
-const Stars = () => (
- <>
-
-
-
- >
-)
-
-export { CardPioneers }
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index 79934bf2ed..02be273a97 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -2,7 +2,8 @@
import { useFooterVisibility } from '@/context/footerVisibility'
import { Suspense, useEffect, useMemo, useState, useRef, useCallback, type ReactNode } from 'react'
-import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage'
+import { FAQs, Hero, Marquee, NoFees } from '@/components/LandingPage'
+import { ShhhhhFold } from '@/components/LandingPage/ShhhhhFold'
import { SupportedRailsFaqAnswer } from '@/components/LandingPage/SupportedRailsFaqAnswer'
import { SUPPORTED_RAILS_FAQ_ID } from '@/constants/faq.consts'
import TweetCarousel from '@/components/LandingPage/TweetCarousel'
@@ -37,6 +38,7 @@ type LandingPageClientProps = {
locale: Locale
strings: LandingStrings
// Server-rendered slots
+ problemSlot: ReactNode
mantecaSlot: ReactNode
regulatedRailsSlot: ReactNode
yourMoneySlot: ReactNode
@@ -51,6 +53,7 @@ export function LandingPageClient({
marqueeMessages,
locale,
strings,
+ problemSlot,
mantecaSlot,
regulatedRailsSlot,
yourMoneySlot,
@@ -63,8 +66,14 @@ export function LandingPageClient({
// app-locale translation (LatAm-first funnel); the flag-off label still
// comes from the content system per landing locale
const tMigration = useTranslations('migration')
+ // the strip under the door fold speaks /shhhhh's vocabulary, not the
+ // product one every other strip repeats
+ const tDoorMarquee = useTranslations('shhhhh.marquee')
const { deviceType } = useDeviceType()
const isDesktop = deviceType === DeviceType.WEB
+ // Kill switch: the door fold and the closed-beta strip under it are one
+ // promise, so they go dark together.
+ const doorFoldOn = !underMaintenanceConfig.disableLandingCardFold
// pwa-sunset hero CTAs are device-based: phones get one "Download now"
// with their store's mark deep-linking to it; desktop drops the primary
@@ -91,13 +100,26 @@ export function LandingPageClient({
// Memoized: this component re-renders per scroll frame during the button
// animation — don't rebuild the FAQ array + rich answer element each time.
- const faqQuestions = useMemo(
- () =>
- faqData.questions.map((q) =>
- q.id === SUPPORTED_RAILS_FAQ_ID ? { ...q, answerContent: } : q
- ),
- [faqData.questions]
- )
+ const faqQuestions = useMemo(() => {
+ // The questions come from the content system, which must not carry code
+ // concerns, so the article each one continues into is mapped here by id.
+ // "Why Peanut?" and "My question is not here" are left out on purpose:
+ // the first has no single article behind it, the second already links
+ // the help centre in its own answer.
+ const learnMore: Record = {
+ '1': `/${locale}/help/what-are-digital-dollars`,
+ '2': `/${locale}/help/verification`,
+ '3': `/${locale}/help/passkeys`,
+ '4': `/${locale}/help/security-custody`,
+ '5': `/${locale}/help/fees-pricing`,
+ [SUPPORTED_RAILS_FAQ_ID]: `/${locale}/help/supported-geographies`,
+ }
+ return faqData.questions.map((q) => ({
+ ...q,
+ ...(q.id === SUPPORTED_RAILS_FAQ_ID ? { answerContent: } : {}),
+ ...(learnMore[q.id] ? { learnMoreHref: learnMore[q.id] } : {}),
+ }))
+ }, [faqData.questions, locale])
const [buttonVisible, setButtonVisible] = useState(true)
const [isScrollFrozen, setIsScrollFrozen] = useState(false)
@@ -230,7 +252,42 @@ export function LandingPageClient({
}
}, [handleScrollDelta])
- const marqueeProps = { visible: true, message: marqueeMessages }
+ // Only the words with a real article behind them become links; the rest
+ // stay plain text. Words come from the content system's marquee list, so an
+ // edit there just drops out of this map and renders unlinked.
+ const marqueeProps = useMemo(() => {
+ const hrefs: Record = {
+ 'No transfer fees': `/${locale}/pricing`,
+ USD: `/${locale}/help/what-are-digital-dollars`,
+ EUR: `/${locale}/help/send-euros-argentina`,
+ 'USDT/USDC': `/${locale}/blog/stablecoin-balance-visa-merchants`,
+ GLOBAL: `/${locale}/help/supported-geographies`,
+ 'SELF-CUSTODIAL': `/${locale}/help/security-custody`,
+ // app route, not a locale-prefixed marketing page
+ '24/7': '/support',
+ }
+ return {
+ visible: true,
+ message: marqueeMessages.map((word) => (hrefs[word] ? { label: word, href: hrefs[word] } : word)),
+ }
+ }, [marqueeMessages, locale])
+
+ // Memoized for the same reason as faqQuestions above — this component
+ // re-renders per scroll frame while the send button grows.
+ const doorMarqueeProps = useMemo(
+ () => ({
+ visible: true,
+ // the whole strip is the door: every word goes to /shhhhh
+ message: [
+ tDoorMarquee('iykyk'),
+ tDoorMarquee('wordTravels'),
+ tDoorMarquee('closedBeta'),
+ tDoorMarquee('shhhh'),
+ tDoorMarquee('peanutClub'),
+ ].map((label) => ({ label, href: '/shhhhh' })),
+ }),
+ [tDoorMarquee]
+ )
return (
<>
@@ -254,34 +311,39 @@ export function LandingPageClient({
}
/>
- {mantecaSlot}
-
- {yourMoneySlot}
-
- {!underMaintenanceConfig.disableCardPioneers && (
+ {doorFoldOn && (
<>
-
-
+
+
>
)}
+ {problemSlot}
+
+ {/* Suspense needed: NoFees renders ExchangeRateWidget which uses useSearchParams().
+ Without this boundary, the entire LandingPageClient suspends during SSR,
+ sending an empty HTML shell to crawlers and killing SEO. */}
+
+
+
+
+ {yourMoneySlot}
+
{regulatedRailsSlot}
-
+ {mantecaSlot}
{securitySlot}
{sendInSecondsSlot}
- {/* Suspense needed: NoFees renders ExchangeRateWidget which uses useSearchParams().
- Without this boundary, the entire LandingPageClient suspends during SSR,
- sending an empty HTML shell to crawlers and killing SEO. */}
-
-
-
-
-
+
{footerSlot}
diff --git a/src/components/LandingPage/LandingPageContent.tsx b/src/components/LandingPage/LandingPageContent.tsx
index dc86550fd6..57807b0d14 100644
--- a/src/components/LandingPage/LandingPageContent.tsx
+++ b/src/components/LandingPage/LandingPageContent.tsx
@@ -5,6 +5,7 @@ import { RegulatedRails } from './RegulatedRails'
import { YourMoney } from './yourMoney'
import { SecurityBuiltIn } from './securityBuiltIn'
import { SendInSeconds } from './sendInSeconds'
+import { ProblemFold } from './ProblemFold'
import Footer from './Footer'
import { faqSchema } from '@/lib/seo/schemas'
import { singletonLocaleFor } from '@/lib/content'
@@ -14,11 +15,16 @@ import { getTranslations } from '@/i18n'
import { landingStrings } from './landingStrings'
import type { Locale } from '@/i18n/types'
+// Blue, not Manteca's default cream: on the homepage it follows RegulatedRails,
+// which is cream already.
+const MANTECA_BG_COLOR = '#90A8ED'
+
// Shared body of the landing page, rendered by / (en) and by each per-locale
// landing route. Reads the filesystem via getLandingContent, so this must stay
// a server component.
export function LandingPageContent({ locale }: { locale: Locale }) {
const { heroConfig, faqData, marqueeMessages } = getLandingContent(locale)
+ const strings = landingStrings(getTranslations(locale))
// inLanguage reflects the language the FAQ prose actually resolved to —
// until mono ships landing translations, that's English on every locale.
const faqJsonLd = faqSchema(
@@ -38,8 +44,9 @@ export function LandingPageContent({ locale }: { locale: Locale }) {
faqData={faqData}
marqueeMessages={marqueeMessages}
locale={locale}
- strings={landingStrings(getTranslations(locale))}
- mantecaSlot={ }
+ strings={strings}
+ problemSlot={ }
+ mantecaSlot={ }
regulatedRailsSlot={ }
yourMoneySlot={ }
securitySlot={ }
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index b6c52eede6..911a0302c0 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -1,9 +1,11 @@
import mantecaIphone from '@/assets/iphone-ss/manteca_ss.webp'
import Image from 'next/image'
-import Link from 'next/link'
-import MEPA_ARGENTINA_LOGO from '@/assets/logos/mepa-arg.svg'
-import PIX_BRZ_LOGO from '@/assets/logos/pix-brz.svg'
+import MERCADO_PAGO_ICON from '@/assets/icons/mercado-pago-logo.svg'
+import PIX_ICON from '@/assets/icons/pix-logo.svg'
+import FLAG_AR from '@/assets/illustrations/flag-ar.svg'
+import FLAG_BR from '@/assets/illustrations/flag-br.svg'
import Star from '@/assets/illustrations/star.svg'
+import { PartnerLockup } from './PartnerLockup'
import { CloudsCss } from './CloudsCss'
import { AnimateOnView } from '@/components/Global/AnimateOnView'
import { getTranslations } from '@/i18n'
@@ -17,14 +19,24 @@ const starConfigs = [
{ className: 'absolute bottom-20 right-44', delay: '0.6s', rotate: '22deg' },
]
-const Manteca = ({ locale = DEFAULT_LOCALE }: { locale?: Locale }) => {
+// Cream, as on /quests. The homepage passes blue instead — there it follows
+// RegulatedRails, which is already cream.
+const DEFAULT_BG_COLOR = '#F9F4F0'
+
+const Manteca = ({
+ locale = DEFAULT_LOCALE,
+ backgroundColor = DEFAULT_BG_COLOR,
+}: {
+ locale?: Locale
+ backgroundColor?: string
+}) => {
const i18n = getTranslations(locale)
return (
@@ -56,38 +68,37 @@ const Manteca = ({ locale = DEFAULT_LOCALE }: { locale?: Locale }) => {
{i18n.landingPayLocalBody}
- {/* Mobile layout */}
-
-
- {/* Desktop layout */}
-
-
-
-
-
-
-
-
-
+
+ {/* On mobile the two lockups sit side by side above the phone, so the
+ sticky CTA covers the screenshot rather than either link. `md:contents`
+ drops this wrapper on desktop so they flank the phone instead. */}
+
-
{i18n.landingPayLocalSettles}
+
-
-
- {i18n.landingPayLocalSettles}
-
)
}
diff --git a/src/components/LandingPage/PartnerLockup.tsx b/src/components/LandingPage/PartnerLockup.tsx
new file mode 100644
index 0000000000..8db499d77b
--- /dev/null
+++ b/src/components/LandingPage/PartnerLockup.tsx
@@ -0,0 +1,56 @@
+import Image, { type StaticImageData } from 'next/image'
+import Link from 'next/link'
+import { twMerge } from 'tailwind-merge'
+
+/**
+ * A country flag over its payment rail's mark, linking to the page that explains
+ * the rail.
+ *
+ * The mark sits directly on the section ground — black is 9:1 on the homepage
+ * blue and 19:1 on the /quests cream, so it needs no card (unlike
+ * `#regulated-rails`, whose white inner card exists because those marks sit on
+ * pink). The fixed-height slot keeps the two marks on a shared baseline; the
+ * underlined "Learn more →" is the only click affordance, so it stays.
+ */
+export function PartnerLockup({
+ flag,
+ flagAlt,
+ logo,
+ logoAlt,
+ logoClassName,
+ href,
+ ariaLabel,
+ learnMore,
+ className,
+}: {
+ flag: StaticImageData
+ flagAlt: string
+ logo: StaticImageData
+ logoAlt: string
+ logoClassName: string
+ href: string
+ ariaLabel: string
+ learnMore: string
+ className?: string
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {learnMore} →
+
+
+ )
+}
diff --git a/src/components/LandingPage/PioneerCard3D.tsx b/src/components/LandingPage/PioneerCard3D.tsx
deleted file mode 100644
index dd53dcc05b..0000000000
--- a/src/components/LandingPage/PioneerCard3D.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-'use client'
-import { motion, useMotionValue, useTransform, useSpring, useMotionTemplate } from 'framer-motion'
-import Image from 'next/image'
-import { CARD_GRADIENT_4, CARD_GRADIENT_5, CARD_GRADIENT_9, CARD_GRADIENT_10 } from '@/assets/cards'
-import { useRef, useState, useEffect, useCallback } from 'react'
-
-const CARD_BACKGROUNDS = [CARD_GRADIENT_4, CARD_GRADIENT_9, CARD_GRADIENT_10, CARD_GRADIENT_5]
-const CYCLE_INTERVAL = 3000
-
-interface PioneerCard3DProps {
- className?: string
-}
-
-const PioneerCard3D = ({ className }: PioneerCard3DProps) => {
- const cardRef = useRef
(null)
- const [activeIndex, setActiveIndex] = useState(0)
- const timerRef = useRef(null)
-
- const x = useMotionValue(0)
- const y = useMotionValue(0)
- const isInteractingRef = useRef(false)
- const resumeTimerRef = useRef | null>(null)
-
- const rotateX = useTransform(y, [-100, 100], [12, -12])
- const rotateY = useTransform(x, [-100, 100], [-12, 12])
-
- const shadowX = useTransform(x, [-100, 100], [14, -2])
- const shadowY = useTransform(y, [-100, 100], [14, -2])
-
- const springRotateX = useSpring(rotateX, { stiffness: 200, damping: 25 })
- const springRotateY = useSpring(rotateY, { stiffness: 200, damping: 25 })
- const springShadowX = useSpring(shadowX, { stiffness: 200, damping: 25 })
- const springShadowY = useSpring(shadowY, { stiffness: 200, damping: 25 })
-
- const boxShadow = useMotionTemplate`${springShadowX}px ${springShadowY}px 0 #000000`
-
- const advance = useCallback(() => {
- setActiveIndex((prev) => (prev + 1) % CARD_BACKGROUNDS.length)
- }, [])
-
- const resetTimer = useCallback(() => {
- if (timerRef.current) clearInterval(timerRef.current)
- timerRef.current = setInterval(advance, CYCLE_INTERVAL)
- }, [advance])
-
- useEffect(() => {
- resetTimer()
- return () => {
- if (timerRef.current) clearInterval(timerRef.current)
- }
- }, [resetTimer])
-
- // Mobile auto-oscillation: slow sine wave to show off parallax without interaction
- // Pauses when user touches/clicks the card and resumes 2s after release
- useEffect(() => {
- const isMobile = window.matchMedia('(max-width: 768px)').matches
- if (!isMobile) return
-
- let frame: number
- const startTime = Date.now()
-
- const animate = () => {
- if (!isInteractingRef.current) {
- const elapsed = (Date.now() - startTime) / 1000
- // gentle figure-8 pattern using offset sine waves
- x.set(Math.sin(elapsed * 0.4) * 60)
- y.set(Math.sin(elapsed * 0.3 + 1) * 40)
- }
- frame = requestAnimationFrame(animate)
- }
-
- frame = requestAnimationFrame(animate)
- return () => cancelAnimationFrame(frame)
- }, [x, y])
-
- const pauseOscillation = useCallback(() => {
- isInteractingRef.current = true
- if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
- }, [])
-
- const resumeOscillation = useCallback(() => {
- if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
- resumeTimerRef.current = setTimeout(() => {
- isInteractingRef.current = false
- }, 2000)
- }, [])
-
- useEffect(() => {
- return () => {
- if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
- }
- }, [])
-
- const handleClick = () => {
- advance()
- resetTimer()
- }
-
- const handlePointerDown = (e: React.PointerEvent) => {
- pauseOscillation()
- if (!cardRef.current) return
- const rect = cardRef.current.getBoundingClientRect()
- x.set(e.clientX - rect.left - rect.width / 2)
- y.set(e.clientY - rect.top - rect.height / 2)
- }
-
- const handlePointerMove = (e: React.PointerEvent) => {
- if (!cardRef.current) return
- const rect = cardRef.current.getBoundingClientRect()
- x.set(e.clientX - rect.left - rect.width / 2)
- y.set(e.clientY - rect.top - rect.height / 2)
- }
-
- const handlePointerLeave = () => {
- x.set(0)
- y.set(0)
- resumeOscillation()
- }
-
- return (
-
-
- {/* All card images layered, only the active one is visible */}
- {CARD_BACKGROUNDS.map((bg, i) => (
-
-
-
- ))}
-
-
- )
-}
-
-export default PioneerCard3D
diff --git a/src/components/LandingPage/ProblemFold.tsx b/src/components/LandingPage/ProblemFold.tsx
new file mode 100644
index 0000000000..a08839cb36
--- /dev/null
+++ b/src/components/LandingPage/ProblemFold.tsx
@@ -0,0 +1,77 @@
+import Image from 'next/image'
+import HandPeace from '@/assets/illustrations/hand-peace.svg'
+import StackCoin from '@/assets/illustrations/stack-coin.svg'
+import type { LandingStrings } from './landingStrings'
+
+const bgColor = '#F9F4F0'
+
+// One height for all three marks so the card tops line up. The glyph needs a
+// bigger type size than the drawings to reach the same optical weight — a
+// Knerd "%" only fills about two thirds of its em box.
+const iconClass = 'h-11 w-auto'
+
+// Card tilts, in source order. Small and alternating, so the row reads as
+// hand-placed rather than as a broken grid.
+const tilts = ['-rotate-1', 'rotate-[0.8deg]', '-rotate-[0.6deg]']
+
+/**
+ * "You already know the problem." — three named cards, one per person we built
+ * peanut for. No links: this fold names the pain, the folds under it answer it.
+ */
+export function ProblemFold({ strings }: { strings: LandingStrings }) {
+ const { problem } = strings
+
+ const cards = [
+ {
+ id: 'cross-border',
+ title: problem.crossBorderTitle,
+ body: problem.crossBorderBody,
+ icon: ,
+ },
+ {
+ id: 'send-home',
+ title: problem.sendHomeTitle,
+ body: problem.sendHomeBody,
+ icon: (
+
+ %
+
+ ),
+ },
+ {
+ id: 'paid-abroad',
+ title: problem.paidAbroadTitle,
+ body: problem.paidAbroadBody,
+ icon: ,
+ },
+ ]
+
+ return (
+
+
+
+ {problem.heading}
+
+
+
+ {cards.map((card, i) => (
+
+
{card.icon}
+
+ {card.title}
+
+
{card.body}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index 168d0a21d4..003c48eb99 100644
--- a/src/components/LandingPage/RegulatedRails.tsx
+++ b/src/components/LandingPage/RegulatedRails.tsx
@@ -1,4 +1,5 @@
import Image from 'next/image'
+import Link from 'next/link'
import { MarqueeWrapper } from '../Global/MarqueeWrapper'
import BBVA_ICON from '@/assets/icons/bbva-logo.svg'
import BRUBANK_ICON from '@/assets/icons/brubank-logo.svg'
@@ -17,18 +18,32 @@ import { DEFAULT_LOCALE, type Locale } from '@/i18n/types'
const bgColor = '#F9F4F0'
+/**
+ * `path` is the locale-prefixed page that honestly explains what this partner
+ * does with Peanut. A logo with nothing truthful to point at stays unlinked —
+ * Brubank and Stripe have no article covering their rail, so they do.
+ *
+ * `onWhite` gives a logo a white card behind it: these two are the only marks
+ * in the set that are not a heavy wordmark, and they disappear on the pink.
+ */
const logos = [
- { logo: BBVA_ICON, alt: 'BBVA' },
+ { logo: BBVA_ICON, alt: 'BBVA', path: 'help/deposit-bank' },
{ logo: BRUBANK_ICON, alt: 'Brubank' },
- { logo: N26_ICON, alt: 'N26' },
- { logo: SANTANDER_ICON, alt: 'Santander' },
- { logo: REVOLUT_ICON, alt: 'Revolut' },
+ { logo: N26_ICON, alt: 'N26', path: 'help/deposit-bank' },
+ { logo: SANTANDER_ICON, alt: 'Santander', path: 'help/deposit-bank' },
+ { logo: REVOLUT_ICON, alt: 'Revolut', path: 'compare/peanut-vs-revolut' },
{ logo: STRIPE_ICON, alt: 'Stripe' },
- { logo: MERCADO_PAGO_ICON, alt: 'Mercado Pago' },
- { logo: PIX_ICON, alt: 'PIX' },
- { logo: WISE_ICON, alt: 'Wise' },
+ { logo: MERCADO_PAGO_ICON, alt: 'Mercado Pago', path: 'help/mercadopago-qr', onWhite: true },
+ { logo: PIX_ICON, alt: 'PIX', path: 'brazil', onWhite: true },
+ { logo: WISE_ICON, alt: 'Wise', path: 'compare/peanut-vs-wise' },
]
+// my-2, not mb-2: react-fast-marquee's container is overflow-x:hidden, which
+// makes the Y axis compute to auto — it clips. Without top margin the tile sits
+// flush against that edge and the hover lift shaves its top border off.
+const tileClass = 'btn btn-purple btn-shadow-primary-4 mx-7 my-2 flex h-26 w-48 items-center gap-2'
+const linkedTileClass = `${tileClass} transition-transform hover:-translate-y-0.5 hover:opacity-90`
+
const regulatedRailsClouds = [
{ top: '20%', width: 200, speed: '38s', direction: 'ltr' as const },
{ top: '60%', width: 220, speed: '34s', direction: 'rtl' as const },
@@ -58,7 +73,7 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
{i18n.landingRailsBody}
-
diff --git a/src/components/LandingPage/ScarcityCounter.tsx b/src/components/LandingPage/ScarcityCounter.tsx
new file mode 100644
index 0000000000..4aa43215d0
--- /dev/null
+++ b/src/components/LandingPage/ScarcityCounter.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { motion, useReducedMotion } from 'framer-motion'
+import { ADMISSIONS_PER_WEEK } from '@/components/Card/doorTally.utils'
+
+// The counter starts one above the real weekly rate and ticks down once, so
+// the number reads as live.
+const COUNTER_START = ADMISSIONS_PER_WEEK + 1
+
+/**
+ * "Only N a week" scarcity pill, shared by the homepage door fold and the
+ * /shhhhh hero. `label` builds the copy for a given count — the caller owns
+ * the interpolation, so the pill never has to know which catalog it is in.
+ */
+export function ScarcityCounter({ label }: { label: (count: number) => string }) {
+ const prefersReducedMotion = useReducedMotion()
+ const [count, setCount] = useState(COUNTER_START)
+
+ useEffect(() => {
+ if (prefersReducedMotion) {
+ setCount(ADMISSIONS_PER_WEEK)
+ return
+ }
+ const timer = setTimeout(() => setCount(ADMISSIONS_PER_WEEK), 2500)
+ return () => clearTimeout(timer)
+ }, [prefersReducedMotion])
+
+ const ticked = count === ADMISSIONS_PER_WEEK
+
+ return (
+
+ {label(count)}
+
+ )
+}
diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx
new file mode 100644
index 0000000000..932b912288
--- /dev/null
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -0,0 +1,76 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/0_Bruddle/Button'
+
+// The card face renders nothing on the server and rasterises four canvases on
+// mount, so it stays out of the first chunk. The host box reserves the aspect
+// ratio, which keeps the fold from shifting when the chunk lands.
+const ScaledPixelatedCardFace = dynamic(
+ () => import('@/components/Card/share-asset/ScaledPixelatedCardFace').then((m) => m.ScaledPixelatedCardFace),
+ { ssr: false }
+)
+
+// On a black ground a black drop shadow is invisible, so the card and the
+// button both carry brand pink here. Deliberate deviation from the
+// all-black-shadow rule, and it applies on this fold only. The button repeats
+// the hex in its class because Tailwind only sees literals.
+const PINK = '#FF90E8'
+
+/**
+ * The homepage door to /shhhhh: wordmark, tagline, the door itself, the card.
+ * Every click here goes to /shhhhh — the fold makes the offer and the page
+ * does the work (the pitch, the scarcity count, sign-in, badge check, waitlist).
+ */
+export function ShhhhhFold() {
+ const t = useTranslations('shhhhh.hero')
+
+ return (
+
+
+
+
+ {t('wordmark')}
+
+
+
+ {t('tagline')}
+
+
+
+
+ {/* no shadowSize: that prop only paints black. The pink
+ shadow is an arbitrary class so the base
+ `active:shadow-none` still flattens it on press. */}
+
+ {t('tryTheDoor')}
+
+
+
+ {t('orJoinWaitlist')}
+
+
+
+
+
+ {/* blurAll: closed-beta tease — card shape recognisable, logos +
+ number unreadable. Rotation sits on the wrapper so the inner
+ host still measures its layout width for the fit-to-width scale. */}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/__tests__/landingLinks.utils.test.ts b/src/components/LandingPage/__tests__/landingLinks.utils.test.ts
new file mode 100644
index 0000000000..73b13a44d9
--- /dev/null
+++ b/src/components/LandingPage/__tests__/landingLinks.utils.test.ts
@@ -0,0 +1,61 @@
+import { linkTerms, type LinkedTerm } from '../landingLinks.utils'
+
+// the real city terms the "global cash" line links, aliases and all
+const CITIES: LinkedTerm[] = [
+ { aliases: ['New York', 'Nueva York', 'Nova York'], href: '/en/united-states' },
+ { aliases: ['Madrid', 'Madri'], href: '/en/spain' },
+ { aliases: ['Mexico City', 'Ciudad de México', 'Cidade do México'], href: '/en/mexico' },
+]
+
+describe('linkTerms', () => {
+ it('links every city in the English line, in reading order', () => {
+ const parts = linkTerms('From New York to Madrid to Mexico City — send and share instantly.', CITIES)
+
+ expect(parts).toEqual([
+ { text: 'From ' },
+ { text: 'New York', href: '/en/united-states' },
+ { text: ' to ' },
+ { text: 'Madrid', href: '/en/spain' },
+ { text: ' to ' },
+ { text: 'Mexico City', href: '/en/mexico' },
+ { text: ' — send and share instantly.' },
+ ])
+ })
+
+ it.each([
+ ['es-419', 'De Nueva York a Madrid y Ciudad de México: envía y comparte al instante.'],
+ ['es-ar', 'De Nueva York a Madrid y Ciudad de México: enviá y compartí al instante.'],
+ ['pt-br', 'De Nova York a Madri e à Cidade do México: envie e compartilhe na hora.'],
+ ])('links all three cities in the %s translation', (_locale, line) => {
+ const linked = linkTerms(line, CITIES).filter((p) => p.href)
+
+ expect(linked.map((p) => p.href)).toEqual(['/en/united-states', '/en/spain', '/en/mexico'])
+ // the sentence survives the split intact
+ expect(
+ linkTerms(line, CITIES)
+ .map((p) => p.text)
+ .join('')
+ ).toBe(line)
+ })
+
+ it('keeps "Madrid" whole rather than matching its prefix "Madri"', () => {
+ const parts = linkTerms('to Madrid today', [{ aliases: ['Madri', 'Madrid'], href: '/en/spain' }])
+
+ expect(parts).toEqual([{ text: 'to ' }, { text: 'Madrid', href: '/en/spain' }, { text: ' today' }])
+ })
+
+ it('renders plain text for a term the translation does not contain', () => {
+ const parts = linkTerms('Send money anywhere.', CITIES)
+
+ expect(parts).toEqual([{ text: 'Send money anywhere.' }])
+ })
+
+ it('drops a term that overlaps one already matched instead of nesting it', () => {
+ const parts = linkTerms('New York', [
+ { aliases: ['New York'], href: '/en/united-states' },
+ { aliases: ['York'], href: '/en/united-kingdom' },
+ ])
+
+ expect(parts).toEqual([{ text: 'New York', href: '/en/united-states' }])
+ })
+})
diff --git a/src/components/LandingPage/faq.tsx b/src/components/LandingPage/faq.tsx
index 7d4923068f..9ac54ba6f3 100644
--- a/src/components/LandingPage/faq.tsx
+++ b/src/components/LandingPage/faq.tsx
@@ -12,7 +12,7 @@ type LocalFAQsProps = FAQsProps & {
}
}
-export function FAQs({ heading, questions, marquee = { visible: false } }: LocalFAQsProps) {
+export function FAQs({ heading, questions, learnMoreLabel, marquee = { visible: false } }: LocalFAQsProps) {
return (
-
+
{marquee.visible && (
) => void
}
+
+/**
+ * Copy for the problem fold ( ): one heading over three named
+ * cards. Each card is a separate title/body pair rather than one list string,
+ * so a translator can never collapse three cards into two.
+ */
+export interface LandingProblemStrings {
+ heading: string
+ crossBorderTitle: string
+ crossBorderBody: string
+ sendHomeTitle: string
+ sendHomeBody: string
+ paidAbroadTitle: string
+ paidAbroadBody: string
+}
diff --git a/src/components/LandingPage/landingLinks.utils.ts b/src/components/LandingPage/landingLinks.utils.ts
new file mode 100644
index 0000000000..88313d8f85
--- /dev/null
+++ b/src/components/LandingPage/landingLinks.utils.ts
@@ -0,0 +1,49 @@
+/** One term to link, with every spelling it takes across the landing catalogs. */
+export interface LinkedTerm {
+ aliases: string[]
+ href: string
+}
+
+/** A run of the original text. `href` is set only on the matched terms. */
+export interface LinkedTextPart {
+ text: string
+ href?: string
+}
+
+/**
+ * Split a translated sentence around known terms so each match can be wrapped
+ * in a link at render time.
+ *
+ * The sentence stays one catalog string, so no locale loses its translation to
+ * a restructure. A term whose aliases are all absent from `text` is simply not
+ * matched, and that part of the sentence renders as plain text — a translator
+ * rewording "Mexico City" costs a link, never the sentence.
+ */
+export function linkTerms(text: string, terms: LinkedTerm[]): LinkedTextPart[] {
+ const matches: Array<{ start: number; end: number; href: string }> = []
+
+ for (const term of terms) {
+ // longest alias first: "Madrid" must win over its own prefix "Madri"
+ for (const alias of [...term.aliases].sort((a, b) => b.length - a.length)) {
+ const start = text.indexOf(alias)
+ if (start === -1) continue
+ matches.push({ start, end: start + alias.length, href: term.href })
+ break
+ }
+ }
+
+ matches.sort((a, b) => a.start - b.start)
+
+ const parts: LinkedTextPart[] = []
+ let cursor = 0
+ for (const match of matches) {
+ // a term overlapping one already taken is dropped rather than nested
+ if (match.start < cursor) continue
+ if (match.start > cursor) parts.push({ text: text.slice(cursor, match.start) })
+ parts.push({ text: text.slice(match.start, match.end), href: match.href })
+ cursor = match.end
+ }
+ if (cursor < text.length) parts.push({ text: text.slice(cursor) })
+
+ return parts
+}
diff --git a/src/components/LandingPage/landingStrings.ts b/src/components/LandingPage/landingStrings.ts
index 43303ea7ac..07d6dfaa4b 100644
--- a/src/components/LandingPage/landingStrings.ts
+++ b/src/components/LandingPage/landingStrings.ts
@@ -1,5 +1,6 @@
import en from '@/i18n/en.json'
import { type Translations } from '@/i18n/types'
+import type { LandingProblemStrings } from './landing.types'
// Narrowed copy bag handed from the server landing page down through
// LandingPageClient. Keeps the client bundle from importing all locale
@@ -13,6 +14,7 @@ export interface LandingStrings {
heroNoLocalId: string
zeroFees: string
seeMarkupOn: string
+ learnMore: string
dropLinkHeading: string
dropLinkBody: string
currencyPlaceholder: string
@@ -25,6 +27,7 @@ export interface LandingStrings {
cardCta: string
wallOfLove: string
wallOfLoveBody: string
+ problem: LandingProblemStrings
/** Passed straight through to ExchangeRateWidget's `labels`. */
exchange: {
youSend: string
@@ -49,6 +52,7 @@ export function landingStrings(i18n: Translations): LandingStrings {
heroNoLocalId: i18n.landingHeroNoLocalId,
zeroFees: i18n.landingZeroFees,
seeMarkupOn: i18n.landingSeeMarkupOn,
+ learnMore: i18n.landingLearnMore,
dropLinkHeading: i18n.landingDropLinkHeading,
dropLinkBody: i18n.landingDropLinkBody,
currencyPlaceholder: i18n.landingCurrencyPlaceholder,
@@ -61,6 +65,15 @@ export function landingStrings(i18n: Translations): LandingStrings {
cardCta: i18n.landingCardCta,
wallOfLove: i18n.landingWallOfLove,
wallOfLoveBody: i18n.landingWallOfLoveBody,
+ problem: {
+ heading: i18n.landingProblemHeading,
+ crossBorderTitle: i18n.landingProblemCrossBorderTitle,
+ crossBorderBody: i18n.landingProblemCrossBorderBody,
+ sendHomeTitle: i18n.landingProblemSendHomeTitle,
+ sendHomeBody: i18n.landingProblemSendHomeBody,
+ paidAbroadTitle: i18n.landingProblemPaidAbroadTitle,
+ paidAbroadBody: i18n.landingProblemPaidAbroadBody,
+ },
exchange: {
youSend: i18n.exchangeYouSend,
recipientGets: i18n.exchangeRecipientGets,
diff --git a/src/components/LandingPage/marquee.tsx b/src/components/LandingPage/marquee.tsx
index 563e0fed6d..84b8ee17e0 100644
--- a/src/components/LandingPage/marquee.tsx
+++ b/src/components/LandingPage/marquee.tsx
@@ -1,9 +1,11 @@
import HandThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg'
import { MarqueeComp } from '../Global/MarqueeWrapper'
+import type { MarqueeItem } from '../Global/MarqueeWrapper/marquee.types'
type MarqueeProps = {
visible?: boolean
- message?: string[]
+ /** Plain words, or `{ label, href }` for the ones that link somewhere. */
+ message?: MarqueeItem[]
imageSrc?: string
backgroundColor?: string
}
diff --git a/src/components/LandingPage/securityBuiltIn.tsx b/src/components/LandingPage/securityBuiltIn.tsx
index cad2afb8a3..a75534449a 100644
--- a/src/components/LandingPage/securityBuiltIn.tsx
+++ b/src/components/LandingPage/securityBuiltIn.tsx
@@ -91,12 +91,16 @@ export function SecurityBuiltIn({ locale = DEFAULT_LOCALE }: { locale?: Locale }
{feature.description}
{feature.learnMoreHref && (
-
- {i18n.landingLearnMore} →
-
+ // max-w matches the paragraph above, so the link
+ // lands on that right edge instead of the wider cell
+
+
+ {i18n.landingLearnMore} →
+
+
)}
{feature.id === 3 && (
diff --git a/src/components/LandingPage/yourMoney.tsx b/src/components/LandingPage/yourMoney.tsx
index 6dca98519d..68b73a2941 100644
--- a/src/components/LandingPage/yourMoney.tsx
+++ b/src/components/LandingPage/yourMoney.tsx
@@ -1,11 +1,23 @@
import Image from 'next/image'
+import Link from 'next/link'
import LandingCountries from '@/assets/illustrations/landing-countries.svg'
import { Button } from '@/components/0_Bruddle/Button'
import { getTranslations } from '@/i18n'
import { DEFAULT_LOCALE, type Locale } from '@/i18n/types'
+import { linkTerms, type LinkedTerm } from './landingLinks.utils'
+
+// The three cities named in landingGlobalCashBody, each pointing at its country
+// page. Aliases cover every spelling the catalogs use, so the line stays one
+// translated string per locale.
+const cityTerms = (locale: Locale): LinkedTerm[] => [
+ { aliases: ['New York', 'Nueva York', 'Nova York'], href: `/${locale}/united-states` },
+ { aliases: ['Madrid', 'Madri'], href: `/${locale}/spain` },
+ { aliases: ['Mexico City', 'Ciudad de México', 'Cidade do México'], href: `/${locale}/mexico` },
+]
export function YourMoney({ locale = DEFAULT_LOCALE }: { locale?: Locale }) {
const i18n = getTranslations(locale)
+ const bodyParts = linkTerms(i18n.landingGlobalCashBody, cityTerms(locale))
return (
@@ -21,7 +33,15 @@ export function YourMoney({ locale = DEFAULT_LOCALE }: { locale?: Locale }) {
- {i18n.landingGlobalCashBody}
+ {bodyParts.map((part, index) =>
+ part.href ? (
+
+ {part.text}
+
+ ) : (
+ part.text
+ )
+ )}
diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts
index 99feac8e71..144ed322c2 100644
--- a/src/config/underMaintenance.config.ts
+++ b/src/config/underMaintenance.config.ts
@@ -29,9 +29,9 @@
*
* 6. disableCardPioneers: hides the card pioneers waitlist feature entirely
* - /card page redirects to /home
- * - /lp/card marketing page redirects to /
- * - card pioneers section hidden from landing page
+ * - /lp/card redirects to /shhhhh (redirects.json) — the marketing page itself is gone
* - card pioneer modal, carousel cta, and perk rewards hidden from home
+ * - the landing-page card section it used to hide was removed in 2026-08; use disableLandingCardFold for the new one
* - set to false to enable the feature
*
* 7. pixBrazilOnrampMaintenance: warn-only flag for the BRL-via-PIX onramp (Manteca Brazil deposit)
@@ -51,6 +51,11 @@
* - use during a partial Manteca outage so recovered currencies (e.g. ARS) come back while others stay blocked
* - does NOT touch QR payments (Manteca QR / Brazil PIX-over-QR stay open) — that is disabledPaymentProviders
*
+ * 10. disableLandingCardFold: hides the "shhhhh" card fold on the landing page
+ * - removes the black door fold and the closed-beta marquee strip under it
+ * - /shhhhh and the rest of the card flow stay reachable — this only mutes the homepage pitch
+ * - use if the closed beta fills up or the card goes down
+ *
* note: if either mode is enabled, the maintenance banner will show everywhere
*
* I HOPE WE NEVER NEED TO USE THIS...
@@ -67,6 +72,7 @@ interface MaintenanceConfig {
disableXchainSend: boolean
disableCardPioneers: boolean
disableCardLaunchCTA: boolean
+ disableLandingCardFold: boolean
pixBrazilOnrampMaintenance: boolean
/** Manteca fiat currencies still down (e.g. ['BRL']); currencies not listed stay live. Empty = all enabled. */
disabledMantecaCurrencies: MantecaCurrency[]
@@ -83,6 +89,7 @@ const underMaintenanceConfig: MaintenanceConfig = {
disableXchainSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum)
disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature
disableCardLaunchCTA: false, // kill-switch for the in-app "shhh" card CTA (funnel card step + activated home splash). Set true to mute it (dial down in-app load); /card flow + /shhhhh + waitlist stay reachable regardless.
+ disableLandingCardFold: false, // set to true to hide the landing-page card fold (black door fold + the closed-beta strip under it)
pixBrazilOnrampMaintenance: false, // BRL deposits restored via dynamic PIX QR (2026-07-02). Set true if the onramp degrades again.
disabledMantecaCurrencies: [], // Manteca restored (ARS + BRL live). Add a currency here to block it during a future outage.
}
diff --git a/src/i18n/app/__tests__/shhhhh-catalog.test.ts b/src/i18n/app/__tests__/shhhhh-catalog.test.ts
index 65586157c8..42cf58a9cc 100644
--- a/src/i18n/app/__tests__/shhhhh-catalog.test.ts
+++ b/src/i18n/app/__tests__/shhhhh-catalog.test.ts
@@ -8,7 +8,7 @@ describe('/shhhhh catalog', () => {
it.each(APP_LOCALES)('%s keeps the tag the hero interpolates', async (locale) => {
const messages = await loadMessages(locale)
// t.rich renders into this tag; drop it in a translation
- // and the "only 20 a week" scarcity beat silently disappears.
+ // and the "only 50 a week" scarcity beat silently disappears.
expect(messages.shhhhh.hero.body).toContain(' ')
})
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 0dd66d9afb..b0242a1478 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -3125,7 +3125,7 @@
"badgeShowMore": "+{count} more →",
"badgeShowLess": "show less",
"noBadgeTitle": "No badge.",
- "noBadgeBody": "Join the waitlist. We let people in 20 a week.",
+ "noBadgeBody": "Join the waitlist. We let people in 50 a week.",
"noBadgeFooter": "Put your name on the list and we'll holler when your turn comes up."
},
"notForYou": {
@@ -3153,7 +3153,7 @@
},
"q5": {
"question": "HOW LONG IS THE WAITLIST?",
- "answer": "We're letting in about 20 people a week during closed beta. Order matters. Badges skip the line entirely."
+ "answer": "We're letting in about 50 people a week during closed beta. Order matters. Badges skip the line entirely."
},
"q6": {
"question": "WHERE CAN I GET THE CARD?",
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index dbfabebda7..3dc434a6e5 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -3125,7 +3125,7 @@
"badgeShowMore": "+{count} más →",
"badgeShowLess": "ver menos",
"noBadgeTitle": "Sin insignia.",
- "noBadgeBody": "Únete a la lista de espera. Dejamos entrar a 20 personas por semana.",
+ "noBadgeBody": "Únete a la lista de espera. Dejamos entrar a 50 personas por semana.",
"noBadgeFooter": "Anota tu nombre en la lista y te avisamos cuando llegue tu turno."
},
"notForYou": {
@@ -3153,7 +3153,7 @@
},
"q5": {
"question": "¿CUÁNTO DURA LA LISTA DE ESPERA?",
- "answer": "Durante la beta cerrada dejamos entrar a unas 20 personas por semana. El orden importa. Las insignias saltan la fila por completo."
+ "answer": "Durante la beta cerrada dejamos entrar a unas 50 personas por semana. El orden importa. Las insignias saltan la fila por completo."
},
"q6": {
"question": "¿DÓNDE PUEDO CONSEGUIR LA TARJETA?",
diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json
index b1618b7357..16a6282a56 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1230,7 +1230,7 @@
"badgeTitle": "Tenés una insignia.",
"badgeBody": "Te saltás la fila. Estamos habilitando a quienes tengan:",
"badgeFooter": "Iniciá sesión, revisamos las insignias de tu cuenta y pasás directo a la verificación. ¿Todavía no tenés cuenta de peanut? Registrate primero: toma un minuto.",
- "noBadgeBody": "Sumate a la lista de espera. Dejamos entrar a 20 personas por semana.",
+ "noBadgeBody": "Sumate a la lista de espera. Dejamos entrar a 50 personas por semana.",
"noBadgeFooter": "Anotá tu nombre en la lista y te avisamos cuando llegue tu turno."
},
"notForYou": {
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 3e0d99d075..82975aaee4 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -3125,7 +3125,7 @@
"badgeShowMore": "+{count} mais →",
"badgeShowLess": "mostrar menos",
"noBadgeTitle": "Sem selo.",
- "noBadgeBody": "Entre na lista de espera. Liberamos 20 pessoas por semana.",
+ "noBadgeBody": "Entre na lista de espera. Liberamos 50 pessoas por semana.",
"noBadgeFooter": "Coloque seu nome na lista e a gente avisa quando chegar sua vez."
},
"notForYou": {
@@ -3153,7 +3153,7 @@
},
"q5": {
"question": "QUANTO TEMPO DEMORA A LISTA DE ESPERA?",
- "answer": "Durante o beta fechado, liberamos cerca de 20 pessoas por semana. A ordem importa. Os selos furam a fila por completo."
+ "answer": "Durante o beta fechado, liberamos cerca de 50 pessoas por semana. A ordem importa. Os selos furam a fila por completo."
},
"q6": {
"question": "ONDE POSSO CONSEGUIR O CARTÃO?",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3e0b4e5325..765abcbb5b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -71,7 +71,6 @@
"landingPayLocalHeading": "PAY LIKE A LOCAL.",
"landingPayLocalSubheading": "RECEIVE FROM ANYWHERE. NO LOCAL ID NEEDED.",
"landingPayLocalBody": "Pay MercadoPago QR in Argentina. Send PIX in Brazil. Just your passport.",
- "landingPayLocalSettles": "Settles in digital dollars at the real exchange rate.",
"landingMercadoPagoAria": "How MercadoPago QR works",
"landingPixAria": "How PIX works without a CPF",
"landingSecurityHeading": "SECURITY. CONTROL. SUPPORT.",
@@ -80,7 +79,7 @@
"landingSecurityControlTitle": "TRUE CONTROL",
"landingSecurityControlDesc": "You verify your identity only when a feature truly requires it — like bank connections or the Peanut Card. For everyday peer-to-peer use, no mandatory KYC or friction.",
"landingSecurityHelpTitle": "24/7 HELP",
- "landingSecurityHelpDesc": "One tap connects you to real humans. Friendly, expert support is on hand day or night to resolve any question instantly.",
+ "landingSecurityHelpDesc": "One tap and support is always available. Friendly, expert support is on hand day or night to resolve any question instantly.",
"landingTalkToSupport": "Talk to Support",
"landingSendTagline1": "MOVE MONEY WORLDWIDE INSTANTLY.",
"landingSendTagline2": "ALWAYS UNDER YOUR CONTROL.",
@@ -91,7 +90,7 @@
"footerTerms": "Terms of Service",
"footerPrivacy": "Privacy Policy",
"footerSecurity": "Security",
- "footerJobs": "Jobs",
+ "footerJobs": "Career",
"footerSiteDirectory": "Site directory",
"footerCompare": "Compare",
"footerLearnMoreSection": "Learn More",
@@ -125,6 +124,13 @@
"landingSupportedRailsFaqQuestion": "Which networks, tokens and banks does Peanut support?",
"landingSupportedRailsFaqAnswer": "Crypto: deposit and withdraw USDC and USDT on {evmCount} EVM networks with a single address ({evmList}), plus {otherList}. ETH is also supported on EVM networks; Tron is USDT-only. Banks & local payment apps: {railList}. Deposits are free — Peanut covers the gas.",
"listJoinAnd": "and",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemCrossBorderTitle": "Living across borders",
+ "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
+ "landingProblemSendHomeTitle": "Sending money home",
+ "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
+ "landingProblemPaidAbroadTitle": "Paid from abroad",
+ "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
"faqTitle": "FAQ",
"heroMarqueeNoFees": "No fees",
"heroMarqueeInstant": "Instant",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 3123e9dd1a..45eb3be285 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -71,7 +71,6 @@
"landingPayLocalHeading": "PAGA COMO LOCAL.",
"landingPayLocalSubheading": "RECIBE DESDE CUALQUIER LUGAR. SIN DOCUMENTO LOCAL.",
"landingPayLocalBody": "Paga con QR de Mercado Pago en Argentina. Envía PIX en Brasil. Solo con tu pasaporte.",
- "landingPayLocalSettles": "Se liquida en dólares digitales al tipo de cambio real.",
"landingMercadoPagoAria": "Cómo funciona el QR de Mercado Pago",
"landingPixAria": "Cómo funciona PIX sin CPF",
"landingSecurityHeading": "SEGURIDAD. CONTROL. SOPORTE.",
@@ -80,7 +79,7 @@
"landingSecurityControlTitle": "CONTROL REAL",
"landingSecurityControlDesc": "Verificas tu identidad solo cuando una función realmente lo exige, como conectar tu banco o la Peanut Card. Para el uso diario entre personas, sin KYC obligatorio ni fricción.",
"landingSecurityHelpTitle": "AYUDA 24/7",
- "landingSecurityHelpDesc": "Con un toque hablas con personas reales. Soporte experto y amable, de día o de noche, para resolver cualquier duda al instante.",
+ "landingSecurityHelpDesc": "Con un toque siempre tienes soporte disponible. Soporte experto y amable, de día o de noche, para resolver cualquier duda al instante.",
"landingTalkToSupport": "Hablar con soporte",
"landingSendTagline1": "MUEVE DINERO POR TODO EL MUNDO AL INSTANTE.",
"landingSendTagline2": "SIEMPRE BAJO TU CONTROL.",
@@ -125,6 +124,13 @@
"landingSupportedRailsFaqQuestion": "¿Con qué redes, tokens y bancos funciona Peanut?",
"landingSupportedRailsFaqAnswer": "Cripto: deposita y retira USDC y USDT en {evmCount} redes EVM con una sola dirección ({evmList}), además de {otherList}. ETH también está disponible en las redes EVM; Tron solo admite USDT. Bancos y apps de pago locales: {railList}. Los depósitos son gratis: Peanut cubre el gas.",
"listJoinAnd": "y",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemCrossBorderTitle": "Living across borders",
+ "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
+ "landingProblemSendHomeTitle": "Sending money home",
+ "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
+ "landingProblemPaidAbroadTitle": "Paid from abroad",
+ "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index d3bc8905b0..705ddf4c32 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -71,7 +71,6 @@
"landingPayLocalHeading": "PAGÁ COMO LOCAL.",
"landingPayLocalSubheading": "RECIBÍ DESDE CUALQUIER LUGAR. SIN DOCUMENTO LOCAL.",
"landingPayLocalBody": "Pagá con QR de Mercado Pago en Argentina. Enviá PIX en Brasil. Solo con tu pasaporte.",
- "landingPayLocalSettles": "Se liquida en dólares digitales al tipo de cambio real.",
"landingMercadoPagoAria": "Cómo funciona el QR de Mercado Pago",
"landingPixAria": "Cómo funciona PIX sin CPF",
"landingSecurityHeading": "SEGURIDAD. CONTROL. SOPORTE.",
@@ -80,7 +79,7 @@
"landingSecurityControlTitle": "CONTROL REAL",
"landingSecurityControlDesc": "Verificás tu identidad solo cuando una función realmente lo exige, como conectar tu banco o la Peanut Card. Para el uso diario entre personas, sin KYC obligatorio ni fricción.",
"landingSecurityHelpTitle": "AYUDA 24/7",
- "landingSecurityHelpDesc": "Con un toque hablás con personas reales. Soporte experto y amable, de día o de noche, para resolver cualquier duda al instante.",
+ "landingSecurityHelpDesc": "Con un toque siempre tenés soporte disponible. Soporte experto y amable, de día o de noche, para resolver cualquier duda al instante.",
"landingTalkToSupport": "Hablar con soporte",
"landingSendTagline1": "MOVÉ PLATA POR TODO EL MUNDO AL INSTANTE.",
"landingSendTagline2": "SIEMPRE BAJO TU CONTROL.",
@@ -125,6 +124,13 @@
"landingSupportedRailsFaqQuestion": "¿Con qué redes, tokens y bancos funciona Peanut?",
"landingSupportedRailsFaqAnswer": "Cripto: depositá y retirá USDC y USDT en {evmCount} redes EVM con una sola dirección ({evmList}), además de {otherList}. ETH también está disponible en las redes EVM; Tron solo admite USDT. Bancos y apps de pago locales: {railList}. Los depósitos son gratis: Peanut cubre el gas.",
"listJoinAnd": "y",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemCrossBorderTitle": "Living across borders",
+ "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
+ "landingProblemSendHomeTitle": "Sending money home",
+ "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
+ "landingProblemPaidAbroadTitle": "Paid from abroad",
+ "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index e3dd80a077..eb0983b8b8 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -71,7 +71,6 @@
"landingPayLocalHeading": "PAGUE COMO UM LOCAL.",
"landingPayLocalSubheading": "RECEBA DE QUALQUER LUGAR. COM SEU PASSAPORTE.",
"landingPayLocalBody": "Pague com QR do Mercado Pago na Argentina. Envie Pix no Brasil. Só com seu passaporte.",
- "landingPayLocalSettles": "A liquidação é em dólares digitais pela cotação real.",
"landingMercadoPagoAria": "Como funciona o QR do Mercado Pago",
"landingPixAria": "Como funciona o Pix com seu passaporte",
"landingSecurityHeading": "SEGURANÇA. CONTROLE. SUPORTE.",
@@ -80,7 +79,7 @@
"landingSecurityControlTitle": "CONTROLE DE VERDADE",
"landingSecurityControlDesc": "Você verifica sua identidade só quando um recurso realmente exige — como conectar o banco ou o Peanut Card. No uso diário entre pessoas, sem KYC obrigatório nem burocracia.",
"landingSecurityHelpTitle": "AJUDA 24/7",
- "landingSecurityHelpDesc": "Com um toque você fala com pessoas de verdade. Suporte especializado e atencioso, de dia ou de noite, para resolver qualquer dúvida na hora.",
+ "landingSecurityHelpDesc": "Com um toque o suporte está sempre disponível. Suporte especializado e atencioso, de dia ou de noite, para resolver qualquer dúvida na hora.",
"landingTalkToSupport": "Falar com o suporte",
"landingSendTagline1": "MOVIMENTE DINHEIRO PELO MUNDO NA HORA.",
"landingSendTagline2": "SEMPRE SOB SEU CONTROLE.",
@@ -125,6 +124,13 @@
"landingSupportedRailsFaqQuestion": "Com quais redes, tokens e bancos o Peanut funciona?",
"landingSupportedRailsFaqAnswer": "Cripto: deposite e saque USDC e USDT em {evmCount} redes EVM com um único endereço ({evmList}), além de {otherList}. ETH também está disponível nas redes EVM; a Tron aceita apenas USDT. Bancos e apps de pagamento locais: {railList}. Os depósitos são grátis — o Peanut paga o gas.",
"listJoinAnd": "e",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemCrossBorderTitle": "Living across borders",
+ "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
+ "landingProblemSendHomeTitle": "Sending money home",
+ "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
+ "landingProblemPaidAbroadTitle": "Paid from abroad",
+ "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
"faqTitle": "Perguntas frequentes",
"heroMarqueeNoFees": "Sem taxas",
"heroMarqueeInstant": "Na hora",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 8a99bd42bd..99c8fffb5a 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -101,7 +101,6 @@ export interface Translations {
landingPayLocalHeading: string
landingPayLocalSubheading: string
landingPayLocalBody: string
- landingPayLocalSettles: string
landingMercadoPagoAria: string
landingPixAria: string
@@ -154,6 +153,15 @@ export interface Translations {
landingSupportedRailsFaqAnswer: string // "… {evmCount} … ({evmList}), plus {otherList} … {railList} …"
listJoinAnd: string // conjunction used when joining name lists ("Solana and Tron")
+ // Landing page — problem fold (three named cards, icons drawn by the component)
+ landingProblemHeading: string
+ landingProblemCrossBorderTitle: string
+ landingProblemCrossBorderBody: string
+ landingProblemSendHomeTitle: string
+ landingProblemSendHomeBody: string
+ landingProblemPaidAbroadTitle: string
+ landingProblemPaidAbroadBody: string
+
// Legal page headers (privacy/terms carry no in their verbatim markdown)
legalHeroSubtitlePrivacy: string
legalHeroSubtitleTerms: string
From 411fec124e130e69cf3aebf5718004609aa80ff9 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 10:39:17 +0000
Subject: [PATCH 02/10] feat(landing): compliance disclaimer under the
door-fold CTA
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Konrad hotfix: small light-grey line below the CTA — verification
required, no monthly/annual fees, cardholder terms, beta programme.
New shhhhh.hero.disclaimer key in all four app catalogs (es/pt
machine-drafted, flag at the localization gate).
---
src/components/LandingPage/ShhhhhFold.tsx | 4 ++++
src/i18n/app/messages/en.json | 1 +
src/i18n/app/messages/es-419.json | 1 +
src/i18n/app/messages/es-AR.json | 1 +
src/i18n/app/messages/pt-BR.json | 1 +
5 files changed, 8 insertions(+)
diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx
index 932b912288..a9b2353889 100644
--- a/src/components/LandingPage/ShhhhhFold.tsx
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -58,6 +58,10 @@ export function ShhhhhFold() {
{t('orJoinWaitlist')}
+
+
+ {t('disclaimer')}
+
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index b0242a1478..9b01c0c678 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -3089,6 +3089,7 @@
"onlyCount": "only {count}",
"tryTheDoor": "TRY THE DOOR",
"orJoinWaitlist": "or join the waitlist →",
+ "disclaimer": "Verification required for all cardholders. No monthly or annual fees. Standard fees and limits apply per the cardholder terms. Beta programme.",
"joinError": "Couldn't join the waitlist — try the door again."
},
"waitlist": {
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 3dc434a6e5..ad48538c87 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -3089,6 +3089,7 @@
"onlyCount": "solo {count}",
"tryTheDoor": "PRUEBA LA PUERTA",
"orJoinWaitlist": "o únete a la lista de espera →",
+ "disclaimer": "Verificación requerida para todos los titulares de tarjeta. Sin cuotas mensuales ni anuales. Se aplican tarifas y límites estándar según los términos del titular. Programa beta.",
"joinError": "No pudimos sumarte a la lista de espera. Prueba la puerta de nuevo."
},
"waitlist": {
diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json
index 16a6282a56..7755ff7f3b 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1218,6 +1218,7 @@
"body": "Una tarjeta aceptada en más de 150 millones de comercios que aceptan Visa. Vamos dejando entrar a los usuarios beta de a poco:
por semana. Si tenés la insignia correcta, te saltás la fila por completo.",
"tryTheDoor": "PROBÁ LA PUERTA",
"orJoinWaitlist": "o sumate a la lista de espera →",
+ "disclaimer": "Verificación requerida para todos los titulares de tarjeta. Sin cuotas mensuales ni anuales. Se aplican tarifas y límites estándar según los términos del titular. Programa beta.",
"joinError": "No pudimos sumarte a la lista de espera. Probá la puerta de nuevo."
},
"whatItDoes": {
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 82975aaee4..ac2d6ecbd7 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -3089,6 +3089,7 @@
"onlyCount": "só {count}",
"tryTheDoor": "TESTE A PORTA",
"orJoinWaitlist": "ou entre na lista de espera →",
+ "disclaimer": "Verificação obrigatória para todos os titulares do cartão. Sem tarifas mensais ou anuais. Tarifas e limites padrão se aplicam conforme os termos do titular. Programa beta.",
"joinError": "Não conseguimos te colocar na lista de espera. Teste a porta de novo."
},
"waitlist": {
From 61264b19211f73f97de14e86b62bfcab9014e3e3 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 20 Aug 2026 09:46:13 +0000
Subject: [PATCH 03/10] fix(landing): keep the 24/7 marquee link localized,
revive the lockup fade
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three findings from the review of this PR, all inside its own diff:
- The "24/7" marquee word pointed at `/support`, which is not a route — only a
permanent redirect to `/en/help`. Every neighbouring word resolves per locale,
so es/pt readers were the only ones dropped into English. Point it at
`/{locale}/help` instead.
- `PartnerLockup` carried `group` and `group-hover:opacity-90` on the same
element. Tailwind compiles that to a descendant selector, so the element can
never match itself and the fade was dead. `hover:opacity-90` is what was meant.
- `/lp/card` left the hand-maintained static-route lists in `verify-content` and
`generate-valid-links`, but its successor `/shhhhh` was never added — only
`ping-indexnow` got it. `/shhhhh` is now the canonical card page in the
sitemap, so the first content link to it would have failed verify-content's
Pass 1.
---
scripts/generate-valid-links.ts | 2 +-
scripts/verify-content.ts | 2 +-
src/components/LandingPage/LandingPageClient.tsx | 5 +++--
src/components/LandingPage/PartnerLockup.tsx | 2 +-
4 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/scripts/generate-valid-links.ts b/scripts/generate-valid-links.ts
index 64bd4b1c70..745cb6c788 100644
--- a/scripts/generate-valid-links.ts
+++ b/scripts/generate-valid-links.ts
@@ -59,7 +59,7 @@ function main() {
// Static pages
lines.push('## Static Pages')
lines.push('')
- for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/shhhhh']) {
lines.push(`- \`${p}\``)
}
lines.push('')
diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts
index 79418ccc9e..93ddb57d80 100644
--- a/scripts/verify-content.ts
+++ b/scripts/verify-content.ts
@@ -157,7 +157,7 @@ function discoverRoutes(): Set {
const routes = new Set()
// Static pages
- for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/shhhhh']) {
routes.add(p)
}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index 02be273a97..42f2057ca2 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -263,8 +263,9 @@ export function LandingPageClient({
'USDT/USDC': `/${locale}/blog/stablecoin-balance-visa-merchants`,
GLOBAL: `/${locale}/help/supported-geographies`,
'SELF-CUSTODIAL': `/${locale}/help/security-custody`,
- // app route, not a locale-prefixed marketing page
- '24/7': '/support',
+ // /support is only a permanent redirect to /en/help, so linking it
+ // would drop es/pt readers into English while its neighbours stay localized
+ '24/7': `/${locale}/help`,
}
return {
visible: true,
diff --git a/src/components/LandingPage/PartnerLockup.tsx b/src/components/LandingPage/PartnerLockup.tsx
index 8db499d77b..d7a506df57 100644
--- a/src/components/LandingPage/PartnerLockup.tsx
+++ b/src/components/LandingPage/PartnerLockup.tsx
@@ -38,7 +38,7 @@ export function PartnerLockup({
href={href}
aria-label={ariaLabel}
className={twMerge(
- 'group flex flex-col items-center transition-transform group-hover:opacity-90 hover:-translate-y-0.5',
+ 'group flex flex-col items-center transition-transform hover:-translate-y-0.5 hover:opacity-90',
className
)}
>
From 7144d4ed2284c0948b3951ac9e906a2af60ed5de Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 20 Aug 2026 10:19:14 +0000
Subject: [PATCH 04/10] fix(landing): point the compare links at real slugs,
localize the rails FAQ body
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two of the follow-ups this PR had filed rather than done, both code in this repo.
The noFees "See the markup on" links pointed at /compare/wise|paypal|western-union.
`generateStaticParams` only builds `peanut-vs-`, so all three 404'd. They are
restored-from-dev lines, so the bug predates this branch, but it ships on the fold
this PR rebuilt.
`SupportedRailsFaqAnswer` hardcoded its five headings in English while its
plain-text SEO twin was translated in all four catalogs, so a pt-BR reader opening
the FAQ got a Portuguese question and an English body. The copy now comes from the
landing catalog like every other FAQ body, and the non-EVM chain list joins on the
existing `listJoinAnd` instead of a hardcoded "and". The es/pt values are lifted
from the already-shipped `landingSupportedRailsFaqAnswer` sentence in each catalog,
not drafted fresh.
`t()` moves to `src/i18n/interpolate.ts` so a client component can interpolate
without importing `@/i18n`, which would pull all four catalogs into the bundle.
`@/i18n` re-exports it, so every existing caller is unchanged.
Rail region names (`United States`, `36 countries`, …) stay English — they come
from FIAT_RAILS and the plain-text answer already interpolates them the same way.
Localizing that table is its own change.
---
.../LandingPage/LandingPageClient.tsx | 6 +++--
.../LandingPage/SupportedRailsFaqAnswer.tsx | 23 +++++++++++--------
src/components/LandingPage/landing.types.ts | 11 +++++++++
src/components/LandingPage/landingStrings.ts | 12 +++++++++-
src/components/LandingPage/noFees.tsx | 8 ++++---
src/i18n/en.json | 5 ++++
src/i18n/es-419.json | 5 ++++
src/i18n/es-ar.json | 5 ++++
src/i18n/index.ts | 6 +----
src/i18n/interpolate.ts | 9 ++++++++
src/i18n/pt-br.json | 5 ++++
src/i18n/types.ts | 7 ++++++
12 files changed, 82 insertions(+), 20 deletions(-)
create mode 100644 src/i18n/interpolate.ts
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index 42f2057ca2..d47d5be9d6 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -116,10 +116,12 @@ export function LandingPageClient({
}
return faqData.questions.map((q) => ({
...q,
- ...(q.id === SUPPORTED_RAILS_FAQ_ID ? { answerContent: } : {}),
+ ...(q.id === SUPPORTED_RAILS_FAQ_ID
+ ? { answerContent: }
+ : {}),
...(learnMore[q.id] ? { learnMoreHref: learnMore[q.id] } : {}),
}))
- }, [faqData.questions, locale])
+ }, [faqData.questions, locale, strings.supportedRails])
const [buttonVisible, setButtonVisible] = useState(true)
const [isScrollFrozen, setIsScrollFrozen] = useState(false)
diff --git a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx
index 2777fbcef6..1f7783b249 100644
--- a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx
+++ b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx
@@ -4,20 +4,27 @@ import ChainChip from '@/components/AddMoney/components/ChainChip'
import { CHAIN_LOGOS, OTHER_SUPPORTED_CHAINS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts'
import { FIAT_RAILS } from '@/constants/faq.consts'
import { chainDisplayName } from '@/utils/chain-display.utils'
+import { t } from '@/i18n/interpolate'
+import type { LandingSupportedRailsStrings } from './landing.types'
/**
* Rich answer body for the "which networks, tokens and banks?" landing FAQ item.
* Renders from the same rhino.consts constants as the add-money Choose Network
* drawer (and FIAT_RAILS shared with the plain-text SEO answer), so the FAQ
* always advertises exactly what the app supports.
+ *
+ * Copy comes from the landing catalog via `strings`, like every other FAQ body —
+ * the chain, token and rail names inside it stay as the app spells them.
*/
-export function SupportedRailsFaqAnswer() {
+export function SupportedRailsFaqAnswer({ strings }: { strings: LandingSupportedRailsStrings }) {
return (
- Crypto — one deposit address for all {SUPPORTED_EVM_CHAINS.length} EVM networks, plus{' '}
- {OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(' and ')}:
+ {t(strings.crypto, {
+ evmCount: String(SUPPORTED_EVM_CHAINS.length),
+ otherList: OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(` ${strings.joinAnd} `),
+ })}
{[...SUPPORTED_EVM_CHAINS, ...OTHER_SUPPORTED_CHAINS].map((chain) => (
@@ -26,18 +33,16 @@ export function SupportedRailsFaqAnswer() {
-
Tokens:
+
{strings.tokens}
{getSupportedTokens('EVM').map((token) => (
))}
-
- USDC & USDT on every network · ETH on EVM networks · Tron is USDT-only
-
+
{strings.tokenNote}
-
Banks & local payment apps:
+
{strings.banks}
{FIAT_RAILS.map((rail) => (
@@ -50,7 +55,7 @@ export function SupportedRailsFaqAnswer() {
))}
-
Deposits are free — Peanut covers the gas.
+
{strings.free}
)
}
diff --git a/src/components/LandingPage/landing.types.ts b/src/components/LandingPage/landing.types.ts
index 242203e67d..9d4430b01d 100644
--- a/src/components/LandingPage/landing.types.ts
+++ b/src/components/LandingPage/landing.types.ts
@@ -16,6 +16,17 @@ export type CTAButton = {
* cards. Each card is a separate title/body pair rather than one list string,
* so a translator can never collapse three cards into two.
*/
+export interface LandingSupportedRailsStrings {
+ /** "… {evmCount} … {otherList}:" — interpolated where the chain lists are built */
+ crypto: string
+ tokens: string
+ tokenNote: string
+ banks: string
+ free: string
+ /** conjunction for the non-EVM chain list, shared with the plain-text answer */
+ joinAnd: string
+}
+
export interface LandingProblemStrings {
heading: string
crossBorderTitle: string
diff --git a/src/components/LandingPage/landingStrings.ts b/src/components/LandingPage/landingStrings.ts
index 07d6dfaa4b..f0d7a57f57 100644
--- a/src/components/LandingPage/landingStrings.ts
+++ b/src/components/LandingPage/landingStrings.ts
@@ -1,6 +1,6 @@
import en from '@/i18n/en.json'
import { type Translations } from '@/i18n/types'
-import type { LandingProblemStrings } from './landing.types'
+import type { LandingProblemStrings, LandingSupportedRailsStrings } from './landing.types'
// Narrowed copy bag handed from the server landing page down through
// LandingPageClient. Keeps the client bundle from importing all locale
@@ -28,6 +28,8 @@ export interface LandingStrings {
wallOfLove: string
wallOfLoveBody: string
problem: LandingProblemStrings
+ /** Headings for the rich supported-rails FAQ body (SupportedRailsFaqAnswer). */
+ supportedRails: LandingSupportedRailsStrings
/** Passed straight through to ExchangeRateWidget's `labels`. */
exchange: {
youSend: string
@@ -74,6 +76,14 @@ export function landingStrings(i18n: Translations): LandingStrings {
paidAbroadTitle: i18n.landingProblemPaidAbroadTitle,
paidAbroadBody: i18n.landingProblemPaidAbroadBody,
},
+ supportedRails: {
+ crypto: i18n.landingSupportedRailsCrypto,
+ tokens: i18n.landingSupportedRailsTokens,
+ tokenNote: i18n.landingSupportedRailsTokenNote,
+ banks: i18n.landingSupportedRailsBanks,
+ free: i18n.landingSupportedRailsFree,
+ joinAnd: i18n.listJoinAnd,
+ },
exchange: {
youSend: i18n.exchangeYouSend,
recipientGets: i18n.exchangeRecipientGets,
diff --git a/src/components/LandingPage/noFees.tsx b/src/components/LandingPage/noFees.tsx
index b8366ceb5e..fdd2c6858e 100644
--- a/src/components/LandingPage/noFees.tsx
+++ b/src/components/LandingPage/noFees.tsx
@@ -175,9 +175,11 @@ export function NoFees({
className="mt-6"
label={strings.seeMarkupOn}
links={[
- { label: 'Wise', href: `/${locale}/compare/wise` },
- { label: 'PayPal', href: `/${locale}/compare/paypal` },
- { label: 'Western Union', href: `/${locale}/compare/western-union` },
+ // the route is `peanut-vs-` — generateStaticParams builds
+ // no bare-slug page, so the short form 404s
+ { label: 'Wise', href: `/${locale}/compare/peanut-vs-wise` },
+ { label: 'PayPal', href: `/${locale}/compare/peanut-vs-paypal` },
+ { label: 'Western Union', href: `/${locale}/compare/peanut-vs-western-union` },
]}
/>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 765abcbb5b..0834f10244 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -124,6 +124,11 @@
"landingSupportedRailsFaqQuestion": "Which networks, tokens and banks does Peanut support?",
"landingSupportedRailsFaqAnswer": "Crypto: deposit and withdraw USDC and USDT on {evmCount} EVM networks with a single address ({evmList}), plus {otherList}. ETH is also supported on EVM networks; Tron is USDT-only. Banks & local payment apps: {railList}. Deposits are free — Peanut covers the gas.",
"listJoinAnd": "and",
+ "landingSupportedRailsCrypto": "Crypto — one deposit address for all {evmCount} EVM networks, plus {otherList}:",
+ "landingSupportedRailsTokens": "Tokens:",
+ "landingSupportedRailsTokenNote": "USDC & USDT on every network · ETH on EVM networks · Tron is USDT-only",
+ "landingSupportedRailsBanks": "Banks & local payment apps:",
+ "landingSupportedRailsFree": "Deposits are free — Peanut covers the gas.",
"landingProblemHeading": "You already know the problem.",
"landingProblemCrossBorderTitle": "Living across borders",
"landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 45eb3be285..f251b970d8 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -124,6 +124,11 @@
"landingSupportedRailsFaqQuestion": "¿Con qué redes, tokens y bancos funciona Peanut?",
"landingSupportedRailsFaqAnswer": "Cripto: deposita y retira USDC y USDT en {evmCount} redes EVM con una sola dirección ({evmList}), además de {otherList}. ETH también está disponible en las redes EVM; Tron solo admite USDT. Bancos y apps de pago locales: {railList}. Los depósitos son gratis: Peanut cubre el gas.",
"listJoinAnd": "y",
+ "landingSupportedRailsCrypto": "Cripto: una sola dirección de depósito para las {evmCount} redes EVM, además de {otherList}:",
+ "landingSupportedRailsTokens": "Tokens:",
+ "landingSupportedRailsTokenNote": "USDC y USDT en todas las redes · ETH en las redes EVM · Tron solo admite USDT",
+ "landingSupportedRailsBanks": "Bancos y apps de pago locales:",
+ "landingSupportedRailsFree": "Los depósitos son gratis: Peanut cubre el gas.",
"landingProblemHeading": "You already know the problem.",
"landingProblemCrossBorderTitle": "Living across borders",
"landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index 705ddf4c32..f6d51c3302 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -124,6 +124,11 @@
"landingSupportedRailsFaqQuestion": "¿Con qué redes, tokens y bancos funciona Peanut?",
"landingSupportedRailsFaqAnswer": "Cripto: depositá y retirá USDC y USDT en {evmCount} redes EVM con una sola dirección ({evmList}), además de {otherList}. ETH también está disponible en las redes EVM; Tron solo admite USDT. Bancos y apps de pago locales: {railList}. Los depósitos son gratis: Peanut cubre el gas.",
"listJoinAnd": "y",
+ "landingSupportedRailsCrypto": "Cripto: una sola dirección de depósito para las {evmCount} redes EVM, además de {otherList}:",
+ "landingSupportedRailsTokens": "Tokens:",
+ "landingSupportedRailsTokenNote": "USDC y USDT en todas las redes · ETH en las redes EVM · Tron solo admite USDT",
+ "landingSupportedRailsBanks": "Bancos y apps de pago locales:",
+ "landingSupportedRailsFree": "Los depósitos son gratis: Peanut cubre el gas.",
"landingProblemHeading": "You already know the problem.",
"landingProblemCrossBorderTitle": "Living across borders",
"landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 58e71bdacd..0885b2b50f 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -16,8 +16,4 @@ export function getTranslations(locale: Locale): Translations {
return messages[locale] ?? messages.en
}
-/** Simple template interpolation: replaces {key} with values */
-export function t(template: string, vars?: Record): string {
- if (!vars) return template
- return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`)
-}
+export { t } from './interpolate'
diff --git a/src/i18n/interpolate.ts b/src/i18n/interpolate.ts
new file mode 100644
index 0000000000..6c6dbb2072
--- /dev/null
+++ b/src/i18n/interpolate.ts
@@ -0,0 +1,9 @@
+/**
+ * Template interpolation, split out of `@/i18n` so a client component can use it
+ * without importing that module — which pulls all four locale catalogs into the
+ * bundle. `@/i18n` re-exports it, so server callers are unchanged.
+ */
+export function t(template: string, vars?: Record): string {
+ if (!vars) return template
+ return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`)
+}
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index eb0983b8b8..6397872d7d 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -124,6 +124,11 @@
"landingSupportedRailsFaqQuestion": "Com quais redes, tokens e bancos o Peanut funciona?",
"landingSupportedRailsFaqAnswer": "Cripto: deposite e saque USDC e USDT em {evmCount} redes EVM com um único endereço ({evmList}), além de {otherList}. ETH também está disponível nas redes EVM; a Tron aceita apenas USDT. Bancos e apps de pagamento locais: {railList}. Os depósitos são grátis — o Peanut paga o gas.",
"listJoinAnd": "e",
+ "landingSupportedRailsCrypto": "Cripto: um único endereço de depósito para as {evmCount} redes EVM, além de {otherList}:",
+ "landingSupportedRailsTokens": "Tokens:",
+ "landingSupportedRailsTokenNote": "USDC e USDT em todas as redes · ETH nas redes EVM · a Tron aceita apenas USDT",
+ "landingSupportedRailsBanks": "Bancos e apps de pagamento locais:",
+ "landingSupportedRailsFree": "Os depósitos são grátis — o Peanut paga o gas.",
"landingProblemHeading": "You already know the problem.",
"landingProblemCrossBorderTitle": "Living across borders",
"landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 99c8fffb5a..1dfad65917 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -152,6 +152,13 @@ export interface Translations {
landingSupportedRailsFaqQuestion: string
landingSupportedRailsFaqAnswer: string // "… {evmCount} … ({evmList}), plus {otherList} … {railList} …"
listJoinAnd: string // conjunction used when joining name lists ("Solana and Tron")
+ // …and the rich chip body of the same FAQ item (SupportedRailsFaqAnswer).
+ // The plain-text answer above is one sentence; this is its headings.
+ landingSupportedRailsCrypto: string // "… {evmCount} … {otherList}:"
+ landingSupportedRailsTokens: string
+ landingSupportedRailsTokenNote: string
+ landingSupportedRailsBanks: string
+ landingSupportedRailsFree: string
// Landing page — problem fold (three named cards, icons drawn by the component)
landingProblemHeading: string
From cabe0eea380e85e7aa4649bc3025f9cd84513f8b Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Tue, 18 Aug 2026 15:36:05 +0000
Subject: [PATCH 05/10] fix(landing): keep the hero CTA inside the first fold
on laptop viewports
The hero artwork was sized purely by viewport width (50vw, no height cap), and
the stack under it (mt-18 + tagline + mt-12 + button) is 344px of fixed height.
On 16:9/16:10 laptops the SIGN UP button ends up at or below the fold:
1366x657 and 1280x689 -> below fold, 1440x789 (MacBook Air 13") -> clipped,
1536x753 -> below fold. Even 1920x969 fits with 2px to spare.
Cap the artwork at 100svh minus the fixed stack (+3rem slack) and tighten the
two desktop gaps (h2 mt-18 -> md:mt-12, CTA md:mt-12 -> mt-8). The button is now
inside the fold at every measured viewport; tall screens are near-unchanged.
Reported by Slava (Discord), Notion TASK-21626.
---
src/components/LandingPage/hero.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 9353f6f23c..6f69016296 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -111,7 +111,7 @@ const getHoverAnimation = (variant: 'primary' | 'secondary') => ({
const transitionConfig = { type: 'spring', damping: 15 } as const
const getButtonContainerClasses = (variant: 'primary' | 'secondary') =>
- `relative z-20 mt-8 md:mt-12 flex flex-col items-center justify-center ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}`
+ `relative z-20 mt-8 flex flex-col items-center justify-center ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}`
export function Hero({
primaryCta,
@@ -170,11 +170,12 @@ export function Hero({
>
+ {/* 23rem = the fixed stack below the artwork (h2 -> CTA) + 3rem slack, so the CTA stays inside the first fold on short laptop viewports */}
@@ -198,7 +199,7 @@ export function Hero({
-
+
{strings.heroTapScan}
Date: Tue, 18 Aug 2026 15:48:35 +0000
Subject: [PATCH 06/10] fix(landing): scale the hero mascot with the artwork on
short viewports
With the artwork now capped by viewport height, a 40vh mascot on a 1366x657
laptop was almost as tall as the artwork and hid "LOCAL FEEL". Cap it at
100svh-28rem too (md+ only) so it keeps the ~0.7x artwork proportion of the
1440x789 layout; viewports >= 789px tall are unchanged.
---
src/components/LandingPage/hero.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 6f69016296..63f8376a00 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -70,7 +70,7 @@ function PeanutMascot() {
src={PeanutWhistling}
unoptimized
alt="Peanut Guy"
- className="absolute left-1/2 z-10 h-auto max-h-[40vh] w-auto max-w-[90%] -translate-x-1/2 object-contain"
+ className="absolute left-1/2 z-10 h-auto max-h-[40vh] w-auto max-w-[90%] -translate-x-1/2 object-contain md:max-h-[min(40vh,calc(100svh-28rem))]"
/>
)
}
From cc33b6aa0c0bea50211c7d0e0495a5831593d16e Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 20 Aug 2026 13:18:47 +0000
Subject: [PATCH 07/10] fix(landing): keep the CTA in the fold when the
headline runs to three lines
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first-fold fix sized the artwork against a 23rem stack below it, but that
stack is not fixed — it assumes a two-line headline. pt-BR's "TOQUE. ESCANEIE.
EM QUALQUER LUGAR." takes three lines, and four below 360px, where en and es
take two. The surplus outran what the artwork cap bought back, so pt-BR stayed
under the fold on short phones: -48px at 320x568, -42 at 360x600, -20 at
375x553 and 414x600, -2 at 360x640, -1 at 430x650.
Measured on the preview, not reasoned about: shortening the headline does NOT
fix it (still -10 at 320x568, -4 at 360x600, because it keeps wrapping to three
lines on narrow screens), and taking the space from the artwork instead would
shrink the hero for every locale on every short viewport to serve one.
So take it from the gap above the headline, and only where the problem exists.
Scoped to max-height 660px AND max-width 767px: without the width bound this
would fire on 1366x657 laptops, which already clear the fold by 64px and do not
need their spacing changed. Above those bounds nothing moves.
All eight measured viewports now pass in pt-BR, worst case +8px; en gains
slack it did not need and loses nothing elsewhere.
---
src/components/LandingPage/hero.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 63f8376a00..efa74d009f 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -199,7 +199,11 @@ export function Hero({
-
+ {/* Short phone viewports only: the pt-BR headline wraps to 3 lines (and to 4 below
+ 360px) where en/es take 2, which pushes the CTA under the fold. Buy the 38-76px
+ back from this gap rather than from the artwork, so every locale keeps the same
+ hero on normal screens. Width-scoped too, or it would fire on 1366x657 laptops. */}
+
{strings.heroTapScan}
Date: Thu, 20 Aug 2026 13:42:58 +0000
Subject: [PATCH 08/10] i18n(landing): translate the problem fold out of
English
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fold shipped EN-first in all four catalogs, so /es-419, /es-ar and /pt-br
rendered "You already know the problem." in English inside an otherwise
localized page. Translating it was a recorded pre-merge gate, not a nice-to-have.
Written against the glossary test rather than translated loosely: es-419 stays
tuteo ("Ya conoces", "cruzas", "gastas") because voseo is banned there, es-ar
takes the voseo overrides ("conocés", "cruzás", "Ganás", "gastás") because
tú-forms are banned there, and pt-BR avoids the banned register. es-ar also
uses "plata" where es-419 says "dinero".
---
src/i18n/es-419.json | 14 +++++++-------
src/i18n/es-ar.json | 14 +++++++-------
src/i18n/pt-br.json | 14 +++++++-------
3 files changed, 21 insertions(+), 21 deletions(-)
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index f251b970d8..06d14144c9 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -129,13 +129,13 @@
"landingSupportedRailsTokenNote": "USDC y USDT en todas las redes · ETH en las redes EVM · Tron solo admite USDT",
"landingSupportedRailsBanks": "Bancos y apps de pago locales:",
"landingSupportedRailsFree": "Los depósitos son gratis: Peanut cubre el gas.",
- "landingProblemHeading": "You already know the problem.",
- "landingProblemCrossBorderTitle": "Living across borders",
- "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
- "landingProblemSendHomeTitle": "Sending money home",
- "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
- "landingProblemPaidAbroadTitle": "Paid from abroad",
- "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
+ "landingProblemHeading": "Ya conoces el problema.",
+ "landingProblemCrossBorderTitle": "Vivir entre países",
+ "landingProblemCrossBorderBody": "Cada neobanco te rechaza en la verificación apenas cruzas una frontera.",
+ "landingProblemSendHomeTitle": "Enviar dinero a casa",
+ "landingProblemSendHomeBody": "Pagar 4% en cada envío a casa.",
+ "landingProblemPaidAbroadTitle": "Cobrar desde el exterior",
+ "landingProblemPaidAbroadBody": "Ganas en una moneda y gastas en otra.",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index f6d51c3302..9a4f11e599 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -129,13 +129,13 @@
"landingSupportedRailsTokenNote": "USDC y USDT en todas las redes · ETH en las redes EVM · Tron solo admite USDT",
"landingSupportedRailsBanks": "Bancos y apps de pago locales:",
"landingSupportedRailsFree": "Los depósitos son gratis: Peanut cubre el gas.",
- "landingProblemHeading": "You already know the problem.",
- "landingProblemCrossBorderTitle": "Living across borders",
- "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
- "landingProblemSendHomeTitle": "Sending money home",
- "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
- "landingProblemPaidAbroadTitle": "Paid from abroad",
- "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
+ "landingProblemHeading": "Ya conocés el problema.",
+ "landingProblemCrossBorderTitle": "Vivir entre países",
+ "landingProblemCrossBorderBody": "Cada neobanco te rechaza en la verificación apenas cruzás una frontera.",
+ "landingProblemSendHomeTitle": "Enviar plata a casa",
+ "landingProblemSendHomeBody": "Pagar 4% en cada envío a casa.",
+ "landingProblemPaidAbroadTitle": "Cobrar desde el exterior",
+ "landingProblemPaidAbroadBody": "Ganás en una moneda y gastás en otra.",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index 6397872d7d..c1d0ff2ef3 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -129,13 +129,13 @@
"landingSupportedRailsTokenNote": "USDC e USDT em todas as redes · ETH nas redes EVM · a Tron aceita apenas USDT",
"landingSupportedRailsBanks": "Bancos e apps de pagamento locais:",
"landingSupportedRailsFree": "Os depósitos são grátis — o Peanut paga o gas.",
- "landingProblemHeading": "You already know the problem.",
- "landingProblemCrossBorderTitle": "Living across borders",
- "landingProblemCrossBorderBody": "Bounced by every neobank's verification the moment you cross a border.",
- "landingProblemSendHomeTitle": "Sending money home",
- "landingProblemSendHomeBody": "Paying 4% on every transaction home.",
- "landingProblemPaidAbroadTitle": "Paid from abroad",
- "landingProblemPaidAbroadBody": "You earn in one currency and spend in another.",
+ "landingProblemHeading": "Você já conhece o problema.",
+ "landingProblemCrossBorderTitle": "Viver entre países",
+ "landingProblemCrossBorderBody": "Cada neobanco te barra na verificação assim que você cruza uma fronteira.",
+ "landingProblemSendHomeTitle": "Enviar dinheiro para casa",
+ "landingProblemSendHomeBody": "Pagar 4% em cada envio para casa.",
+ "landingProblemPaidAbroadTitle": "Receber do exterior",
+ "landingProblemPaidAbroadBody": "Você ganha em uma moeda e gasta em outra.",
"faqTitle": "Perguntas frequentes",
"heroMarqueeNoFees": "Sem taxas",
"heroMarqueeInstant": "Na hora",
From 9063114beacc37ae810a5f7ab67eb61c3f4706bb Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 20 Aug 2026 13:42:58 +0000
Subject: [PATCH 09/10] copy(shhhhh): drop the $10 welcome-reward claim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"WHAT'S THE $10? — A welcome reward. Complete verification + first $100 in card
spend -> $10 unlocked to your balance..." promised money that no seeded perk
backs. Removed outright rather than reworded or hedged, in all four catalogs
plus the es-AR delta.
q4 is dropped from faqKeys and the remaining keys keep their names. Renumbering
q5-q8 across four catalogs to close the gap would risk pairing a question with
another answer, for no user-visible gain, so the gap is documented in place.
NOT covered here: home.carousel.card.description still says "$10 unlocks on your
first $100 spend" on the in-app home carousel. Same unbacked claim, different
surface and different owners, so it wants its own change rather than riding a
landing-page PR.
---
src/app/shhhhh/ShhhhhLandingPage.tsx | 6 +++++-
src/i18n/app/messages/en.json | 4 ----
src/i18n/app/messages/es-419.json | 4 ----
src/i18n/app/messages/es-AR.json | 3 ---
src/i18n/app/messages/pt-BR.json | 4 ----
5 files changed, 5 insertions(+), 16 deletions(-)
diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx
index ae7505ba50..3ebaeaa5dc 100644
--- a/src/app/shhhhh/ShhhhhLandingPage.tsx
+++ b/src/app/shhhhh/ShhhhhLandingPage.tsx
@@ -59,7 +59,11 @@ function WaitlistJoined({
const statValues = ['150M+', '1', '1', '0'] as const
const statLabelKeys = ['statMerchants', 'statBalance', 'statCard', 'statMiddlemen'] as const
-const faqKeys = ['q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q7', 'q8'] as const
+// q4 ("WHAT'S THE $10?") is deliberately absent: the welcome-reward claim it
+// made is not backed by a seeded perk, so it was removed rather than reworded.
+// The remaining keys keep their names — renumbering four catalogs to close the
+// gap risks mis-pairing a question with someone else's answer for no gain.
+const faqKeys = ['q1', 'q2', 'q3', 'q5', 'q6', 'q7', 'q8'] as const
// The full skip set: keep the codes in sync with SKIP_BADGE_CODES
// (peanut-api-ts src/card/waitlist.ts, pinned in waitlist.test.ts). Icons come
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 9b01c0c678..9262f8dd55 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -3148,10 +3148,6 @@
"question": "IS MY MONEY SAFE?",
"answer": "It's yours. Non-custodial — your stablecoins stay in your wallet until the moment you use the card. We don't hold custody. We don't lend it out."
},
- "q4": {
- "question": "WHAT'S THE $10?",
- "answer": "A welcome reward. Complete verification + first $100 in card spend → $10 unlocked to your balance. Same deal whether you signed up yourself or got referred. If you referred someone, you also are eligible for $10 when they activate. Subject to eligibility and program terms."
- },
"q5": {
"question": "HOW LONG IS THE WAITLIST?",
"answer": "We're letting in about 50 people a week during closed beta. Order matters. Badges skip the line entirely."
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index ad48538c87..96c4a545d2 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -3148,10 +3148,6 @@
"question": "¿MI PLATA ESTÁ SEGURA?",
"answer": "Es tuya. Sin custodia: tus stablecoins se quedan en tu billetera hasta el momento en que usas la tarjeta. No tenemos la custodia. No la prestamos."
},
- "q4": {
- "question": "¿QUÉ ES LO DE LOS $10?",
- "answer": "Una recompensa de bienvenida. Completa la verificación + $100 de gasto con la tarjeta → se desbloquean $10 en tu saldo. Aplica igual si te registraste por tu cuenta o si te refirieron. Si tú refieres a alguien, también puedes recibir $10 cuando esa persona active su tarjeta. Sujeto a elegibilidad y a los términos del programa."
- },
"q5": {
"question": "¿CUÁNTO DURA LA LISTA DE ESPERA?",
"answer": "Durante la beta cerrada dejamos entrar a unas 50 personas por semana. El orden importa. Las insignias saltan la fila por completo."
diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json
index 7755ff7f3b..e0ea1f1a89 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1245,9 +1245,6 @@
},
"q3": {
"answer": "Es tuya. Sin custodia: tus stablecoins se quedan en tu billetera hasta el momento en que usás la tarjeta. No tenemos la custodia. No la prestamos."
- },
- "q4": {
- "answer": "Una recompensa de bienvenida. Completá la verificación + $100 de gasto con la tarjeta → se desbloquean $10 en tu saldo. Aplica igual si te registraste por tu cuenta o si te refirieron. Si vos referís a alguien, también podés recibir $10 cuando esa persona active su tarjeta. Sujeto a elegibilidad y a los términos del programa."
}
},
"ready": {
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index ac2d6ecbd7..92c76cfd36 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -3148,10 +3148,6 @@
"question": "MEU DINHEIRO ESTÁ SEGURO?",
"answer": "É seu. Sem custódia — suas stablecoins ficam na sua carteira até a hora em que você usa o cartão. Não temos a custódia. Não emprestamos seu dinheiro."
},
- "q4": {
- "question": "E OS $10?",
- "answer": "Uma recompensa de boas-vindas. Complete a verificação + $100 de gasto no cartão → $10 liberados no seu saldo. Vale igual se você se cadastrou sozinho ou veio por indicação. Se você indicar alguém, também pode receber $10 quando essa pessoa ativar o cartão. Sujeito a elegibilidade e aos termos do programa."
- },
"q5": {
"question": "QUANTO TEMPO DEMORA A LISTA DE ESPERA?",
"answer": "Durante o beta fechado, liberamos cerca de 50 pessoas por semana. A ordem importa. Os selos furam a fila por completo."
From a314d326648797d5c8d588f7a32a916d59f692d3 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 20 Aug 2026 16:14:21 +0000
Subject: [PATCH 10/10] copy(home): drop the $10 claim from the card carousel
too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-through on 9063114be, which took the same claim off /shhhhh. The home
carousel still read "Closed beta. Badges skip the line. $10 unlocks on your
first $100 spend." — the third sentence promises money no seeded perk backs,
so it goes; the first two are true and stay.
The es-AR override existed only to voseo the deleted verb ("Desbloqueá" vs
es-419's "Desbloquea"), so it is removed rather than left duplicating es-419.
The voseo title override stays.
No "$10" string remains in any app catalog.
---
src/i18n/app/messages/en.json | 2 +-
src/i18n/app/messages/es-419.json | 2 +-
src/i18n/app/messages/es-AR.json | 3 +--
src/i18n/app/messages/pt-BR.json | 2 +-
4 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 9262f8dd55..b556b776dd 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -180,7 +180,7 @@
"tapToClaim": "Tap to claim your reward.",
"card": {
"title": "Get your Peanut Card ",
- "description": "Closed beta. Badges skip the line. $10 unlocks on your first $100 spend."
+ "description": "Closed beta. Badges skip the line. "
},
"invite": {
"title": "Invite friends. Earn rewards",
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 96c4a545d2..5f71c613fa 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -180,7 +180,7 @@
"tapToClaim": "Toca para reclamar tu recompensa.",
"card": {
"title": "Consigue tu Peanut Card ",
- "description": "Beta cerrada. Las insignias saltan la fila. Desbloquea $10 con tu primer gasto de $100."
+ "description": "Beta cerrada. Las insignias saltan la fila. "
},
"invite": {
"title": "Invita amigos. Gana recompensas",
diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json
index e0ea1f1a89..0dd74b4e4f 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -71,8 +71,7 @@
"usedPeanutTapToClaim": "{inviteeName} usó Peanut. Tocá para reclamar.",
"tapToClaim": "Tocá para reclamar tu recompensa.",
"card": {
- "title": "Conseguí tu Peanut Card ",
- "description": "Beta cerrada. Las insignias saltan la fila. Desbloqueá $10 con tu primer gasto de $100."
+ "title": "Conseguí tu Peanut Card "
},
"invite": {
"title": "Invitá amigos. Ganá recompensas",
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 92c76cfd36..55315cb1af 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -180,7 +180,7 @@
"tapToClaim": "Toque para resgatar sua recompensa.",
"card": {
"title": "Peça seu Peanut Card ",
- "description": "Beta fechado. Emblemas furam a fila. Desbloqueie $10 no seu primeiro gasto de $100."
+ "description": "Beta fechado. Emblemas furam a fila. "
},
"invite": {
"title": "Convide amigos. Ganhe recompensas",