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..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', '/lp/card']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/shhhhh']) {
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..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', '/lp/card']) {
+ for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/shhhhh']) {
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..3ebaeaa5dc 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'
@@ -58,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
@@ -81,24 +86,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 +303,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..d47d5be9d6 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,28 @@ 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, strings.supportedRails])
const [buttonVisible, setButtonVisible] = useState(true)
const [isScrollFrozen, setIsScrollFrozen] = useState(false)
@@ -230,7 +254,43 @@ 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`,
+ // /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,
+ 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 +314,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..d7a506df57
--- /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..a9b2353889
--- /dev/null
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -0,0 +1,80 @@
+'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')}
+
+
+
+
+ {t('disclaimer')}
+
+
+
+
+ {/* 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/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/__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 && (
)
}
@@ -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,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}
) => 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 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
+ 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..f0d7a57f57 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, LandingSupportedRailsStrings } 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,9 @@ export interface LandingStrings {
cardCta: string
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
@@ -49,6 +54,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 +67,23 @@ 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,
+ },
+ 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/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/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/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..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",
@@ -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": {
@@ -3125,7 +3126,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": {
@@ -3147,13 +3148,9 @@
"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 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..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",
@@ -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": {
@@ -3125,7 +3126,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": {
@@ -3147,13 +3148,9 @@
"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 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..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",
@@ -1218,6 +1217,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": {
@@ -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": {
@@ -1244,9 +1244,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 3e0d99d075..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",
@@ -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": {
@@ -3125,7 +3126,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": {
@@ -3147,13 +3148,9 @@
"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 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..0834f10244 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,18 @@
"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.",
+ "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..06d14144c9 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,18 @@
"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": "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 d3bc8905b0..9a4f11e599 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,18 @@
"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": "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/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 e3dd80a077..c1d0ff2ef3 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,18 @@
"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": "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",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 8a99bd42bd..1dfad65917 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
@@ -153,6 +152,22 @@ 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
+ 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