From c160a7a76ab1d9cdc5c5d2fc24fd1e9f5c84461a Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Wed, 12 Aug 2026 14:44:24 +0000
Subject: [PATCH 01/17] =?UTF-8?q?feat(landing):=20rebuild=20homepage=20as?=
=?UTF-8?q?=20persona=20manifesto=20=E2=80=94=20card=20beat=20high,=2011?=
=?UTF-8?q?=20sections=20diet=20to=205=20beats?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Locked plan (mono projects/lp-refresh): card beat imports /shhhhh straight
after the hero, manifesto + problem prose follow, works-today grid carries
the surviving proof (live FX widget, compare links), exclusion ending
closes. /lp/card retired via permanent redirect to /shhhhh; sitemap and
robots entries removed. Sticky bar splits by audience (SIGN UP NOW +
TRY THE DOOR). Weekly admission copy moves 20 -> 50 per product/card.md
ruling 2026-08-12. New landing copy ships EN-first in all catalogs;
localization follows before prod merge.
---
redirects.json | 5 +
src/app/lp/card/CardLandingPage.tsx | 867 ------------------
src/app/lp/card/page.tsx | 21 -
src/app/robots.ts | 1 -
src/app/shhhhh/ShhhhhLandingPage.tsx | 6 +-
src/app/shhhhh/page.tsx | 2 +-
src/app/sitemap.ts | 3 -
src/components/LandingPage/CardBeat.tsx | 168 ++++
src/components/LandingPage/CardPioneers.tsx | 115 ---
.../LandingPage/LandingPageClient.tsx | 187 +---
.../LandingPage/LandingPageContent.tsx | 10 -
src/components/LandingPage/Manifesto.tsx | 24 +
src/components/LandingPage/NotForYou.tsx | 59 ++
src/components/LandingPage/PioneerCard3D.tsx | 158 ----
src/components/LandingPage/ProblemProse.tsx | 37 +
.../LandingPage/StickyMobileCTA.tsx | 96 +-
src/components/LandingPage/WorksToday.tsx | 120 +++
src/components/LandingPage/index.ts | 1 -
src/components/LandingPage/landing.types.ts | 71 ++
src/components/LandingPage/landingStrings.ts | 70 ++
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 | 40 +
src/i18n/es-419.json | 40 +
src/i18n/es-ar.json | 40 +
src/i18n/pt-br.json | 40 +
src/i18n/types.ts | 54 ++
29 files changed, 859 insertions(+), 1390 deletions(-)
delete mode 100644 src/app/lp/card/CardLandingPage.tsx
delete mode 100644 src/app/lp/card/page.tsx
create mode 100644 src/components/LandingPage/CardBeat.tsx
delete mode 100644 src/components/LandingPage/CardPioneers.tsx
create mode 100644 src/components/LandingPage/Manifesto.tsx
create mode 100644 src/components/LandingPage/NotForYou.tsx
delete mode 100644 src/components/LandingPage/PioneerCard3D.tsx
create mode 100644 src/components/LandingPage/ProblemProse.tsx
create mode 100644 src/components/LandingPage/WorksToday.tsx
diff --git a/redirects.json b/redirects.json
index 7ef500812e..e8054ddd3e 100644
--- a/redirects.json
+++ b/redirects.json
@@ -105,6 +105,11 @@
"destination": "/",
"permanent": true
},
+ {
+ "source": "/lp/card",
+ "destination": "/shhhhh",
+ "permanent": true
+ },
{
"source": "/sh",
"destination": "/card",
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.
-
-
-
-
-
-
-
-
-
+
+ {/* A plain link, not a second door: DOOR_TRY marks an
+ attempt at the door, so the waitlist route must not
+ fire it or the event stops meaning one thing. */}
+
+ {cardBeat.waitlistLink}
+
+
+
+
+ {cardBeat.trust}
+
+
+
+
+ {/* 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. */}
+
+
+
+
+
+
+
+
+ {/* Numerals aren't translatable; the labels beside them are. 150M+
+ leads so it takes the top-left cell of the 2x2 mobile grid. */}
+
-
- {/* Suspense needed: NoFees renders ExchangeRateWidget which uses useSearchParams().
+ {/* Suspense needed: WorksToday 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..fc91f35f69 100644
--- a/src/components/LandingPage/LandingPageContent.tsx
+++ b/src/components/LandingPage/LandingPageContent.tsx
@@ -1,10 +1,5 @@
import { Suspense } from 'react'
import { LandingPageClient } from './LandingPageClient'
-import Manteca from './Manteca'
-import { RegulatedRails } from './RegulatedRails'
-import { YourMoney } from './yourMoney'
-import { SecurityBuiltIn } from './securityBuiltIn'
-import { SendInSeconds } from './sendInSeconds'
import Footer from './Footer'
import { faqSchema } from '@/lib/seo/schemas'
import { singletonLocaleFor } from '@/lib/content'
@@ -39,11 +34,6 @@ export function LandingPageContent({ locale }: { locale: Locale }) {
marqueeMessages={marqueeMessages}
locale={locale}
strings={landingStrings(getTranslations(locale))}
- mantecaSlot={}
- regulatedRailsSlot={}
- yourMoneySlot={}
- securitySlot={}
- sendInSecondsSlot={}
footerSlot={}
/>
diff --git a/src/components/LandingPage/Manifesto.tsx b/src/components/LandingPage/Manifesto.tsx
new file mode 100644
index 0000000000..8d5b9425f9
--- /dev/null
+++ b/src/components/LandingPage/Manifesto.tsx
@@ -0,0 +1,24 @@
+import type { LandingStrings } from './landingStrings'
+
+// White on the blue beat is 2.33:1, so the headline is ringed in black on every
+// side instead of carrying a two-sided drop shadow. The ring closes the gap
+// between lines at 0.95 leading, hence the looser leading on small screens.
+const HEADLINE_RING = '3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
+
+export function Manifesto({ strings }: { strings: LandingStrings }) {
+ const { manifesto } = strings
+
+ return (
+
+
+
+ {manifesto.heading}
+
+
{manifesto.subline}
+
+
+ )
+}
diff --git a/src/components/LandingPage/NotForYou.tsx b/src/components/LandingPage/NotForYou.tsx
new file mode 100644
index 0000000000..4a6527ceeb
--- /dev/null
+++ b/src/components/LandingPage/NotForYou.tsx
@@ -0,0 +1,59 @@
+import Link from 'next/link'
+import { Button } from '@/components/0_Bruddle/Button'
+import type { LandingStrings } from './landingStrings'
+
+// On the black beat a black shadow vanishes, so the door button carries a pink
+// one — the border stays black, like every other button on the page.
+const DOOR_SHADOW = 'shadow-[4px_4px_0_#FF90E8]'
+
+function Sparkle({ filled }: { filled: boolean }) {
+ return (
+
+ )
+}
+
+export function NotForYou({ strings }: { strings: LandingStrings }) {
+ const { notForYou } = strings
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/index.ts b/src/components/LandingPage/index.ts
index 417673b2a0..3288a182e8 100644
--- a/src/components/LandingPage/index.ts
+++ b/src/components/LandingPage/index.ts
@@ -7,4 +7,3 @@ export * from './securityBuiltIn'
export * from './sendInSeconds'
export * from './yourMoney'
export * from './RegulatedRails'
-export * from './CardPioneers'
diff --git a/src/components/LandingPage/landing.types.ts b/src/components/LandingPage/landing.types.ts
index 0e5b8d24a7..3e3d4751b6 100644
--- a/src/components/LandingPage/landing.types.ts
+++ b/src/components/LandingPage/landing.types.ts
@@ -10,3 +10,74 @@ export type CTAButton = {
icon?: IconName
onClick?: (e: React.MouseEvent) => void
}
+
+/**
+ * Copy for the card beat ().
+ *
+ * Two lines wrap an element rather than a string — the /setup anchor in the
+ * bridge line and the counter chip in the body — so each arrives pre-split
+ * into halves. A split-on-placeholder would drop the tail silently once a
+ * translator loses the placeholder; separate keys cannot fail that way.
+ */
+export interface LandingCardBeatStrings {
+ bridgeBefore: string
+ bridgeLinkLabel: string
+ bridgeAfter: string
+ kicker: string
+ heading: string
+ tagline: string
+ bodyBefore: string
+ /** Carries "{count}" — the component substitutes the live number. */
+ counterLabel: string
+ bodyAfter: string
+ custody: string
+ trust: string
+ waitlistLink: string
+ /** Stat-tile labels. The numerals themselves are not translatable. */
+ statMerchants: string
+ statBalance: string
+ statCard: string
+ statMonthlyFees: string
+}
+
+/** Copy for the manifesto beat (). */
+export interface LandingManifestoStrings {
+ heading: string
+ subline: string
+}
+
+/** Copy for the problem beat (). Pointers anchor to #works-today. */
+export interface LandingProblemStrings {
+ heading: string
+ prose: string
+ pointerPassport: string
+ pointerRate: string
+ pointerMoneyOut: string
+}
+
+/**
+ * Copy for the "what works today" beat (). The drop-link body,
+ * the markup-row label and the rate-widget labels stay on the top-level bag —
+ * they predate this beat and are shared with /exchange.
+ */
+export interface LandingWorksTodayStrings {
+ heading: string
+ subline: string
+ payLocalTitle: string
+ payLocalBody: string
+ payLocalNote: string
+ payLocalMoneyOut: string
+ payLocalChipEurPix: string
+ payLocalChipUsdMercadoPago: string
+ dropLinkTitle: string
+ rateTitle: string
+ securityTitle: string
+ securityBody: string
+}
+
+/** Copy for the ending beat (). */
+export interface LandingNotForYouStrings {
+ heading: string
+ body: string
+ signUpLink: string
+}
diff --git a/src/components/LandingPage/landingStrings.ts b/src/components/LandingPage/landingStrings.ts
index 43303ea7ac..a6ac0b6a84 100644
--- a/src/components/LandingPage/landingStrings.ts
+++ b/src/components/LandingPage/landingStrings.ts
@@ -1,5 +1,12 @@
import en from '@/i18n/en.json'
import { type Translations } from '@/i18n/types'
+import type {
+ LandingCardBeatStrings,
+ LandingManifestoStrings,
+ LandingNotForYouStrings,
+ LandingProblemStrings,
+ LandingWorksTodayStrings,
+} from './landing.types'
// Narrowed copy bag handed from the server landing page down through
// LandingPageClient. Keeps the client bundle from importing all locale
@@ -25,6 +32,15 @@ export interface LandingStrings {
cardCta: string
wallOfLove: string
wallOfLoveBody: string
+ /** Door CTA shared by CardBeat, NotForYou and StickyMobileCTA. */
+ tryTheDoor: string
+ cardBeat: LandingCardBeatStrings
+ manifesto: LandingManifestoStrings
+ problem: LandingProblemStrings
+ worksToday: LandingWorksTodayStrings
+ notForYou: LandingNotForYouStrings
+ /** Words of the closed-beta marquee strip, in order. */
+ marqueeClosedBeta: string[]
/** Passed straight through to ExchangeRateWidget's `labels`. */
exchange: {
youSend: string
@@ -61,6 +77,60 @@ export function landingStrings(i18n: Translations): LandingStrings {
cardCta: i18n.landingCardCta,
wallOfLove: i18n.landingWallOfLove,
wallOfLoveBody: i18n.landingWallOfLoveBody,
+ tryTheDoor: i18n.landingTryTheDoor,
+ cardBeat: {
+ // The catalogs keep every value trimmed (i18n/__tests__/messages.test.ts)
+ // and JSX drops the newline between two expressions, so the spaces
+ // around the inline /setup link are added here. The counter chip
+ // carries its own margin, so the body halves need none.
+ bridgeBefore: `${i18n.landingCardBeatBridgeBefore} `,
+ bridgeLinkLabel: i18n.landingCardBeatBridgeLink,
+ bridgeAfter: ` ${i18n.landingCardBeatBridgeAfter}`,
+ kicker: i18n.landingCardBeatKicker,
+ heading: i18n.landingCardBeatHeading,
+ tagline: i18n.landingCardBeatTagline,
+ bodyBefore: i18n.landingCardBeatBodyBefore,
+ counterLabel: i18n.landingCardBeatCounterLabel,
+ bodyAfter: i18n.landingCardBeatBodyAfter,
+ custody: i18n.landingCardBeatCustody,
+ trust: i18n.landingCardBeatTrust,
+ waitlistLink: i18n.landingCardBeatWaitlistLink,
+ statMerchants: i18n.landingCardBeatStatMerchants,
+ statBalance: i18n.landingCardBeatStatBalance,
+ statCard: i18n.landingCardBeatStatCard,
+ statMonthlyFees: i18n.landingCardBeatStatMonthlyFees,
+ },
+ manifesto: {
+ heading: i18n.landingManifestoHeading,
+ subline: i18n.landingManifestoSubline,
+ },
+ problem: {
+ heading: i18n.landingProblemHeading,
+ prose: i18n.landingProblemProse,
+ pointerPassport: i18n.landingProblemPointerPassport,
+ pointerRate: i18n.landingProblemPointerRate,
+ pointerMoneyOut: i18n.landingProblemPointerMoneyOut,
+ },
+ worksToday: {
+ heading: i18n.landingWorksTodayHeading,
+ subline: i18n.landingWorksTodaySubline,
+ payLocalTitle: i18n.landingWorksTodayPayLocalTitle,
+ payLocalBody: i18n.landingPayLocalBody,
+ payLocalNote: i18n.landingPayLocalSettles,
+ payLocalMoneyOut: i18n.landingWorksTodayMoneyOut,
+ payLocalChipEurPix: i18n.landingWorksTodayChipEurPix,
+ payLocalChipUsdMercadoPago: i18n.landingWorksTodayChipUsdMercadoPago,
+ dropLinkTitle: i18n.landingWorksTodayDropLinkTitle,
+ rateTitle: i18n.landingWorksTodayRateTitle,
+ securityTitle: i18n.landingWorksTodaySecurityTitle,
+ securityBody: i18n.landingWorksTodaySecurityBody,
+ },
+ notForYou: {
+ heading: i18n.landingNotForYouHeading,
+ body: i18n.landingNotForYouBody,
+ signUpLink: i18n.landingNotForYouSignUpLink,
+ },
+ marqueeClosedBeta: [i18n.landingMarqueeClosedBeta, i18n.landingMarqueeShhhh, i18n.landingMarqueeWordTravels],
exchange: {
youSend: i18n.exchangeYouSend,
recipientGets: i18n.exchangeRecipientGets,
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index 505603da0b..e332bc0bd0 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -2902,7 +2902,7 @@
"badgeBody": "You skip the line. We're whitelisting holders of:",
"badgeFooter": "Sign in, we check the badges on your account, you go straight to verification. No peanut account yet? Sign up first — takes a minute.",
"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": {
@@ -2930,7 +2930,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 37ad8d4268..9b0bf0fa40 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -2902,7 +2902,7 @@
"badgeBody": "Te saltas la fila. Estamos habilitando a quienes tengan:",
"badgeFooter": "Inicia sesión, revisamos las insignias de tu cuenta y pasas directo a la verificación. ¿Todavía no tienes cuenta de peanut? Regístrate primero: toma un minuto.",
"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": {
@@ -2930,7 +2930,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 06f93c9539..96cf207f8c 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1222,7 +1222,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 ef19f65c9a..6e529e6072 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -2902,7 +2902,7 @@
"badgeBody": "Você fura a fila. Estamos liberando quem tiver:",
"badgeFooter": "Entre, conferimos os selos da sua conta e você vai direto para a verificação. Ainda não tem conta no peanut? Crie uma primeiro — leva um minuto.",
"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": {
@@ -2930,7 +2930,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 fcd16187a3..006bd98244 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -117,6 +117,46 @@
"landingCardCta": "GET MY CARD",
"landingWallOfLove": "WALL OF LOVE",
"landingWallOfLoveBody": "See what people are saying about Peanut on",
+ "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
+ "landingCardBeatBridgeLink": "The app is live today",
+ "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
+ "landingCardBeatKicker": "Shhhhh · Closed beta",
+ "landingCardBeatHeading": "The card is out.",
+ "landingCardBeatTagline": "For you. Maybe.",
+ "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
+ "landingCardBeatCounterLabel": "only {count}",
+ "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
+ "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
+ "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
+ "landingCardBeatWaitlistLink": "or join the card waitlist →",
+ "landingCardBeatStatMerchants": "Visa-accepting merchants",
+ "landingCardBeatStatBalance": "balance",
+ "landingCardBeatStatCard": "card",
+ "landingCardBeatStatMonthlyFees": "monthly fees",
+ "landingTryTheDoor": "TRY THE DOOR",
+ "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
+ "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
+ "landingProblemPointerPassport": "How the passport check works",
+ "landingProblemPointerRate": "What the rate looks like",
+ "landingProblemPointerMoneyOut": "How money gets out",
+ "landingWorksTodayHeading": "What works today.",
+ "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
+ "landingWorksTodayPayLocalTitle": "Pay like a local",
+ "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
+ "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
+ "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
+ "landingWorksTodayDropLinkTitle": "As easy as a text",
+ "landingWorksTodayRateTitle": "See the rate first",
+ "landingWorksTodaySecurityTitle": "Total security",
+ "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
+ "landingNotForYouHeading": "Probably not for you.",
+ "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
+ "landingNotForYouSignUpLink": "or sign up →",
+ "landingMarqueeClosedBeta": "Closed beta",
+ "landingMarqueeShhhh": "Shhhh",
+ "landingMarqueeWordTravels": "Word travels",
"faqTitle": "FAQ",
"heroMarqueeNoFees": "No fees",
"heroMarqueeInstant": "Instant",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index ae597625e4..5accfdf07f 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -117,6 +117,46 @@
"landingCardCta": "QUIERO MI TARJETA",
"landingWallOfLove": "MURO DE AMOR",
"landingWallOfLoveBody": "Mira lo que dicen de Peanut en",
+ "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
+ "landingCardBeatBridgeLink": "The app is live today",
+ "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
+ "landingCardBeatKicker": "Shhhhh · Closed beta",
+ "landingCardBeatHeading": "The card is out.",
+ "landingCardBeatTagline": "For you. Maybe.",
+ "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
+ "landingCardBeatCounterLabel": "only {count}",
+ "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
+ "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
+ "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
+ "landingCardBeatWaitlistLink": "or join the card waitlist →",
+ "landingCardBeatStatMerchants": "Visa-accepting merchants",
+ "landingCardBeatStatBalance": "balance",
+ "landingCardBeatStatCard": "card",
+ "landingCardBeatStatMonthlyFees": "monthly fees",
+ "landingTryTheDoor": "TRY THE DOOR",
+ "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
+ "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
+ "landingProblemPointerPassport": "How the passport check works",
+ "landingProblemPointerRate": "What the rate looks like",
+ "landingProblemPointerMoneyOut": "How money gets out",
+ "landingWorksTodayHeading": "What works today.",
+ "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
+ "landingWorksTodayPayLocalTitle": "Pay like a local",
+ "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
+ "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
+ "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
+ "landingWorksTodayDropLinkTitle": "As easy as a text",
+ "landingWorksTodayRateTitle": "See the rate first",
+ "landingWorksTodaySecurityTitle": "Total security",
+ "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
+ "landingNotForYouHeading": "Probably not for you.",
+ "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
+ "landingNotForYouSignUpLink": "or sign up →",
+ "landingMarqueeClosedBeta": "Closed beta",
+ "landingMarqueeShhhh": "Shhhh",
+ "landingMarqueeWordTravels": "Word travels",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index a410b0f5bc..9242248772 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -117,6 +117,46 @@
"landingCardCta": "QUIERO MI TARJETA",
"landingWallOfLove": "MURO DE AMOR",
"landingWallOfLoveBody": "Mirá lo que dicen de Peanut en",
+ "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
+ "landingCardBeatBridgeLink": "The app is live today",
+ "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
+ "landingCardBeatKicker": "Shhhhh · Closed beta",
+ "landingCardBeatHeading": "The card is out.",
+ "landingCardBeatTagline": "For you. Maybe.",
+ "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
+ "landingCardBeatCounterLabel": "only {count}",
+ "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
+ "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
+ "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
+ "landingCardBeatWaitlistLink": "or join the card waitlist →",
+ "landingCardBeatStatMerchants": "Visa-accepting merchants",
+ "landingCardBeatStatBalance": "balance",
+ "landingCardBeatStatCard": "card",
+ "landingCardBeatStatMonthlyFees": "monthly fees",
+ "landingTryTheDoor": "TRY THE DOOR",
+ "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
+ "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
+ "landingProblemPointerPassport": "How the passport check works",
+ "landingProblemPointerRate": "What the rate looks like",
+ "landingProblemPointerMoneyOut": "How money gets out",
+ "landingWorksTodayHeading": "What works today.",
+ "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
+ "landingWorksTodayPayLocalTitle": "Pay like a local",
+ "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
+ "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
+ "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
+ "landingWorksTodayDropLinkTitle": "As easy as a text",
+ "landingWorksTodayRateTitle": "See the rate first",
+ "landingWorksTodaySecurityTitle": "Total security",
+ "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
+ "landingNotForYouHeading": "Probably not for you.",
+ "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
+ "landingNotForYouSignUpLink": "or sign up →",
+ "landingMarqueeClosedBeta": "Closed beta",
+ "landingMarqueeShhhh": "Shhhh",
+ "landingMarqueeWordTravels": "Word travels",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index d4ea0c27cb..65807dbac8 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -117,6 +117,46 @@
"landingCardCta": "QUERO MEU CARTÃO",
"landingWallOfLove": "MURAL DE AMOR",
"landingWallOfLoveBody": "Veja o que as pessoas dizem do Peanut no",
+ "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
+ "landingCardBeatBridgeLink": "The app is live today",
+ "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
+ "landingCardBeatKicker": "Shhhhh · Closed beta",
+ "landingCardBeatHeading": "The card is out.",
+ "landingCardBeatTagline": "For you. Maybe.",
+ "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
+ "landingCardBeatCounterLabel": "only {count}",
+ "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
+ "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
+ "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
+ "landingCardBeatWaitlistLink": "or join the card waitlist →",
+ "landingCardBeatStatMerchants": "Visa-accepting merchants",
+ "landingCardBeatStatBalance": "balance",
+ "landingCardBeatStatCard": "card",
+ "landingCardBeatStatMonthlyFees": "monthly fees",
+ "landingTryTheDoor": "TRY THE DOOR",
+ "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
+ "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingProblemHeading": "You already know the problem.",
+ "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
+ "landingProblemPointerPassport": "How the passport check works",
+ "landingProblemPointerRate": "What the rate looks like",
+ "landingProblemPointerMoneyOut": "How money gets out",
+ "landingWorksTodayHeading": "What works today.",
+ "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
+ "landingWorksTodayPayLocalTitle": "Pay like a local",
+ "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
+ "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
+ "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
+ "landingWorksTodayDropLinkTitle": "As easy as a text",
+ "landingWorksTodayRateTitle": "See the rate first",
+ "landingWorksTodaySecurityTitle": "Total security",
+ "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
+ "landingNotForYouHeading": "Probably not for you.",
+ "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
+ "landingNotForYouSignUpLink": "or sign up →",
+ "landingMarqueeClosedBeta": "Closed beta",
+ "landingMarqueeShhhh": "Shhhh",
+ "landingMarqueeWordTravels": "Word travels",
"faqTitle": "Perguntas frequentes",
"heroMarqueeNoFees": "Sem taxas",
"heroMarqueeInstant": "Na hora",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 8cd2d20bf2..d3e516a72a 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -149,6 +149,60 @@ export interface Translations {
landingWallOfLove: string
landingWallOfLoveBody: string
+ // Landing page — card beat (closed-beta card, /shhhhh door)
+ // The bridge line and the body wrap inline elements (a /setup link, the
+ // scarcity counter), so each is split around the element it wraps.
+ landingCardBeatBridgeBefore: string
+ landingCardBeatBridgeLink: string
+ landingCardBeatBridgeAfter: string
+ landingCardBeatKicker: string
+ landingCardBeatHeading: string
+ landingCardBeatTagline: string
+ landingCardBeatBodyBefore: string
+ landingCardBeatCounterLabel: string // "only {count}"
+ landingCardBeatBodyAfter: string
+ landingCardBeatCustody: string
+ landingCardBeatTrust: string
+ landingCardBeatWaitlistLink: string
+ landingCardBeatStatMerchants: string
+ landingCardBeatStatBalance: string
+ landingCardBeatStatCard: string
+ landingCardBeatStatMonthlyFees: string
+ landingTryTheDoor: string // shared by the card beat, the ending beat and the sticky bar
+
+ // Landing page — manifesto beat
+ landingManifestoHeading: string
+ landingManifestoSubline: string
+
+ // Landing page — problem beat (the pointer arrow is drawn by the component)
+ landingProblemHeading: string
+ landingProblemProse: string
+ landingProblemPointerPassport: string
+ landingProblemPointerRate: string
+ landingProblemPointerMoneyOut: string
+
+ // Landing page — what-works-today beat
+ landingWorksTodayHeading: string
+ landingWorksTodaySubline: string
+ landingWorksTodayPayLocalTitle: string
+ landingWorksTodayMoneyOut: string
+ landingWorksTodayChipEurPix: string
+ landingWorksTodayChipUsdMercadoPago: string
+ landingWorksTodayDropLinkTitle: string
+ landingWorksTodayRateTitle: string
+ landingWorksTodaySecurityTitle: string
+ landingWorksTodaySecurityBody: string
+
+ // Landing page — ending beat
+ landingNotForYouHeading: string
+ landingNotForYouBody: string
+ landingNotForYouSignUpLink: string
+
+ // Landing page — closed-beta marquee strip
+ landingMarqueeClosedBeta: string
+ landingMarqueeShhhh: string
+ landingMarqueeWordTravels: string
+
// Legal page headers (privacy/terms carry no in their verbatim markdown)
legalHeroSubtitlePrivacy: string
legalHeroSubtitleTerms: string
From 50b3b7ff3a508b386007a31cfe7e62b2d4feb849 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 06:54:43 +0000
Subject: [PATCH 02/17] =?UTF-8?q?fix(landing):=20apply=20review=20round=20?=
=?UTF-8?q?=E2=80=94=20marquee=20word=20lists=20own=20the=20strips,=20FX?=
=?UTF-8?q?=20widget=20landing=20mode,=20sticky-bar=20migration=20branch?=
=?UTF-8?q?=20+=20robust=20suppression,=20card=20kill=20switch,=20shared?=
=?UTF-8?q?=20ScarcityCounter,=20dead=20code=20out?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
17-item consolidated fix from 7 review angles + visual/copy QA:
zero-fee claim now appears exactly once; ExchangeRateWidget gains
hideCta/hideFees/default corridor props (USD->BRL — widget enforces a
USD leg); StickyMobileCTA regains the pwa-sunset store branch and
suppresses via data-own-cta + MutationObserver instead of one-shot id
lookup; disableCardBeat kill switch; DOOR_TRY gains surface on /shhhhh;
/lp/card purged from build scripts, /shhhhh added to the sitemap;
ScarcityCounter shared by homepage + /shhhhh with ADMISSIONS_PER_WEEK=50
in doorTally.utils; TweetCarousel + 10 orphaned i18n keys deleted;
static beats back to server slots; card face ssr:false behind a
reserved box; content submodule bumped to the Sumsub FAQ fix.
---
scripts/generate-valid-links.ts | 2 +-
scripts/ping-indexnow.ts | 2 +-
scripts/verify-content.ts | 2 +-
src/app/shhhhh/ShhhhhLandingPage.tsx | 25 +-
src/app/sitemap.ts | 3 +
src/components/Card/doorTally.utils.ts | 9 +-
.../Global/ExchangeRateWidget/index.tsx | 67 +++-
src/components/LandingPage/CardBeat.tsx | 88 ++---
.../LandingPage/LandingPageClient.tsx | 61 ++-
.../LandingPage/LandingPageContent.tsx | 12 +-
src/components/LandingPage/NotForYou.tsx | 25 +-
.../LandingPage/ScarcityCounter.tsx | 40 ++
src/components/LandingPage/Sparkle.tsx | 18 +
.../LandingPage/StickyMobileCTA.tsx | 173 ++++++--
src/components/LandingPage/TweetCarousel.tsx | 370 ------------------
src/components/LandingPage/WorksToday.tsx | 43 +-
src/components/LandingPage/hero.tsx | 18 +-
src/components/LandingPage/landingStrings.ts | 40 +-
src/components/LandingPage/noFees.tsx | 8 +-
src/config/underMaintenance.config.ts | 11 +-
src/constants/tweets.consts.ts | 92 -----
src/content | 2 +-
src/i18n/app/__tests__/shhhhh-catalog.test.ts | 2 +-
src/i18n/en.json | 17 +-
src/i18n/es-419.json | 17 +-
src/i18n/es-ar.json | 17 +-
src/i18n/pt-br.json | 17 +-
src/i18n/types.ts | 23 +-
src/lib/landingContent.ts | 4 -
29 files changed, 431 insertions(+), 777 deletions(-)
create mode 100644 src/components/LandingPage/ScarcityCounter.tsx
create mode 100644 src/components/LandingPage/Sparkle.tsx
delete mode 100644 src/components/LandingPage/TweetCarousel.tsx
delete mode 100644 src/constants/tweets.consts.ts
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 9f7374ce78..35c520cad1 100644
--- a/scripts/verify-content.ts
+++ b/scripts/verify-content.ts
@@ -152,7 +152,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/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx
index 0648b212aa..b50ab4c4de 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'
@@ -68,24 +69,6 @@ const badges: Array<{ code: string; name: string; src: 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(51)
- useEffect(() => {
- const timer = setTimeout(() => setCount(50), 2500)
- return () => clearTimeout(timer)
- }, [])
- return (
-
- {t('onlyCount', { count })}
-
- )
-}
-
function StickyShhhhhCTA({ onClick }: { onClick: () => void }) {
const t = useTranslations('shhhhh.hero')
const [visible, setVisible] = useState(false)
@@ -200,6 +183,8 @@ export default function ShhhhhLandingPage() {
const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(window.location.search))
posthog.capture(ANALYTICS_EVENTS.DOOR_TRY, {
+ // same event as the homepage card beat; the surface splits the two
+ surface: 'shhhhh',
signed_in: !!user,
campaign_tags: badgeCampaigns,
})
@@ -302,7 +287,9 @@ export default function ShhhhhLandingPage() {
{t('hero.tagline')}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 30178721e5..0512b51cd3 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -39,6 +39,9 @@ async function generateSitemap(): Promise {
// 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/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts
index 98dab41484..0e554753ea 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 card beat 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/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx
index 788efd4f51..51f2b91359 100644
--- a/src/components/Global/ExchangeRateWidget/index.tsx
+++ b/src/components/Global/ExchangeRateWidget/index.tsx
@@ -35,22 +35,51 @@ const DEFAULT_LABELS: ExchangeRateWidgetLabels = {
arrivesMinutes: 'Should arrive in minutes.',
}
-interface IExchangeRateWidgetProps {
- ctaLabel: string
- ctaIcon: IconName
- ctaAction: (sourceCurrency: string, destinationCurrency: string) => void
+interface IExchangeRateWidgetBaseProps {
labels?: Partial
+ /** Hides the bank-fee / Peanut-fee rows and the "should arrive in…" line. */
+ hideFees?: boolean
+ /** Currency pair the widget opens on before the URL says otherwise. */
+ defaultFrom?: string
+ defaultTo?: string
}
-const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, ctaAction, labels }) => {
+// The CTA is all-or-nothing: a caller either owns the button or hides it, so
+// the label/icon/action trio only exists on the branch that renders it.
+type IExchangeRateWidgetProps = IExchangeRateWidgetBaseProps &
+ (
+ | {
+ hideCta: true
+ ctaLabel?: never
+ ctaIcon?: never
+ ctaAction?: never
+ }
+ | {
+ hideCta?: false
+ ctaLabel: string
+ ctaIcon: IconName
+ ctaAction: (sourceCurrency: string, destinationCurrency: string) => void
+ }
+ )
+
+const ExchangeRateWidget: FC = ({
+ ctaLabel,
+ ctaIcon,
+ ctaAction,
+ labels,
+ hideCta,
+ hideFees,
+ defaultFrom = 'USD',
+ defaultTo = 'EUR',
+}) => {
const l = { ...DEFAULT_LABELS, ...labels }
// shallow + history:'replace' uses window.history.replaceState — bypasses
// Next.js navigation so URL updates don't (occasionally) scroll the page
// to the top through the parent Suspense boundary.
const [query, setQuery] = useQueryStates(
{
- from: parseAsString.withDefault('USD'),
- to: parseAsString.withDefault('EUR'),
+ from: parseAsString.withDefault(defaultFrom),
+ to: parseAsString.withDefault(defaultTo),
amount: parseAsFloat.withDefault(10),
},
{ shallow: true, history: 'replace', scroll: false }
@@ -313,7 +342,7 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c
)}
@@ -133,31 +127,25 @@ export function CardBeat({ strings }: { strings: LandingStrings }) {
number unreadable. Rotation sits on the wrapper so the inner
host still measures its layout width for the fit-to-width scale. */}
-
+
- {/* Numerals aren't translatable; the labels beside them are. 150M+
- leads so it takes the top-left cell of the 2x2 mobile grid. */}
+ {/* 150M+ leads so it takes the top-left cell of the 2x2 mobile grid. */}
diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts
index 99feac8e71..dab1e1e0ec 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 disableCardBeat 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. disableCardBeat: hides the card beat on the landing page
+ * - removes the card section, the closed-beta marquee strip under it, and the "try the door" button in the sticky mobile bar
+ * - /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
+ disableCardBeat: 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.
+ disableCardBeat: false, // set to true to hide the landing-page card beat (section + closed-beta strip + sticky-bar door button)
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/constants/tweets.consts.ts b/src/constants/tweets.consts.ts
deleted file mode 100644
index 7fd8d58398..0000000000
--- a/src/constants/tweets.consts.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @joinpeanut Wall of Love
- *
- * Curated third-party mentions from X (Twitter) during Devconnect Buenos Aires.
- *
- * @dataset
- * - Date range: Oct 1 – Nov 26, 2025
- * - Count: ~140 unique posts
- * - Filters: Excludes team posts, neutral mentions, bots
- *
- * @scoring impact_score (0.55–0.99)
- * - 60% content quality (enthusiasm, specificity, authenticity)
- * - 30% author influence (followers, verified status, known builder/VC/EF)
- * - 10% engagement signals
- * - Caps: Internal team posts ≤0.55, low-effort posts ≤0.62
- *
- * @usage
- * Sorted descending by impact_score for carousel display.
- * Update quarterly by re-running collection with same criteria.
- *
- * @recreation Use Grok (has native X search access):
- *
- * "Search X for positive third-party mentions of @joinpeanut from [DATE_START] to [DATE_END].
- * Exclude team posts (@uwwgo, @0xkkonrad, @andxqueen), neutral mentions, and bots.
- * For each post return: url, author, handle, content, verified, followers_count, avatar, timestamp.
- * Add impact_score (0.60–0.98): 60% enthusiasm + 30% influence + 10% engagement.
- * Cap bots at 0.65, boost EF/Arbitrum/known devs. Sort by impact_score desc.
- * Return as JSON array."
- *
- * If truncated, ask for batches ("top 50", then "remaining entries").
- *
- * @lastUpdated 2025-11-26
- */
-
-export interface TweetMedia {
- type: 'photo' | 'video'
- url: string
-}
-
-export interface Tweet {
- url: string
- author: string
- handle: string
- content: string
- timestamp?: string
- verified?: boolean
- followers_count?: number
- avatar?: string
- impact_score?: number
- is_reply?: boolean
- reply_to_url?: string | null
- media?: TweetMedia[]
- visible?: boolean
-}
-
-interface TweetsData {
- total_positive_mentions: number
- date_range: string
- scoring: string
- note: string
- tweets: Tweet[]
-}
-
-// Tweets manifest is authored in mono at content/_system/generated/testimonials.json
-// and ships to peanut-ui through the peanut-content mirror. Same shape as before.
-import tweetsJson from '@/content/generated/testimonials.json'
-
-const MAX_TWEETS = 100
-const MAX_PER_AUTHOR = 3
-
-const tweetsData = tweetsJson as TweetsData
-
-/** All tweets from dataset, sorted by impact_score desc */
-export const ALL_TWEETS: Tweet[] = tweetsData.tweets
-
-/** Filtered tweets for carousel: top 100, max 3 per author */
-export const TWEETS: Tweet[] = (() => {
- const authorCount = new Map()
- const filtered: Tweet[] = []
-
- for (const tweet of ALL_TWEETS.filter((t) => t.visible !== false)) {
- const count = authorCount.get(tweet.handle) || 0
- if (count >= MAX_PER_AUTHOR) continue
-
- authorCount.set(tweet.handle, count + 1)
- filtered.push(tweet)
-
- if (filtered.length >= MAX_TWEETS) break
- }
-
- return filtered
-})()
diff --git a/src/content b/src/content
index 2a1c593700..4a153e2dcd 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 2a1c593700d679a0a04c9a4fdbc1dee8230b6c9e
+Subproject commit 4a153e2dcd4ff3a2a6c4293d33c1f0bd876578e7
diff --git a/src/i18n/app/__tests__/shhhhh-catalog.test.ts b/src/i18n/app/__tests__/shhhhh-catalog.test.ts
index 93fdfae1f9..b5c299d29b 100644
--- a/src/i18n/app/__tests__/shhhhh-catalog.test.ts
+++ b/src/i18n/app/__tests__/shhhhh-catalog.test.ts
@@ -14,7 +14,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/en.json b/src/i18n/en.json
index 006bd98244..8c5a1442df 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -107,16 +107,6 @@
"landingDropLinkBody": "Chat or IRL. Works on WhatsApp, SMS, QR codes. No bank details, they don't even need to sign up or install Peanut.",
"landingSendNow": "SEND NOW",
"landingSignUpNow": "SIGN UP NOW",
- "landingCurrencyPlaceholder": "Currency or country",
- "landingCardHeading": "PAY EVERYWHERE.",
- "landingCardBody": "Get the Peanut Card and pay anywhere in the world.",
- "landingCardBullet1": "No monthly fees. No annual fees.",
- "landingCardBullet2": "Earn forever for every invite",
- "landingCardBullet3": "Non-custodial - your funds, your control",
- "landingCardDisclaimer": "Standard fees and limits apply per the cardholder terms.",
- "landingCardCta": "GET MY CARD",
- "landingWallOfLove": "WALL OF LOVE",
- "landingWallOfLoveBody": "See what people are saying about Peanut on",
"landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
"landingCardBeatBridgeLink": "The app is live today",
"landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
@@ -157,6 +147,13 @@
"landingMarqueeClosedBeta": "Closed beta",
"landingMarqueeShhhh": "Shhhh",
"landingMarqueeWordTravels": "Word travels",
+ "landingMarqueeInstant": "Instant",
+ "landingMarquee247": "24/7",
+ "landingMarqueeUsd": "USD",
+ "landingMarqueeEur": "EUR",
+ "landingMarqueeStablecoins": "USDT/USDC",
+ "landingMarqueeGlobal": "Global",
+ "landingMarqueeSelfCustodial": "Self-custodial",
"faqTitle": "FAQ",
"heroMarqueeNoFees": "No fees",
"heroMarqueeInstant": "Instant",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 5accfdf07f..a3bec81ebd 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -107,16 +107,6 @@
"landingDropLinkBody": "Por chat o en persona. Funciona en WhatsApp, SMS y códigos QR. Sin datos bancarios, y no necesitan registrarse ni instalar Peanut.",
"landingSendNow": "ENVIAR AHORA",
"landingSignUpNow": "CREAR CUENTA YA",
- "landingCurrencyPlaceholder": "Moneda o país",
- "landingCardHeading": "PAGA EN TODAS PARTES.",
- "landingCardBody": "Pide la Peanut Card y paga en cualquier parte del mundo.",
- "landingCardBullet1": "Sin cuota mensual. Sin cuota anual.",
- "landingCardBullet2": "Gana para siempre por cada invitación",
- "landingCardBullet3": "No custodial: tus fondos, tu control",
- "landingCardDisclaimer": "Se aplican las comisiones y los límites estándar según los términos del titular de la tarjeta.",
- "landingCardCta": "QUIERO MI TARJETA",
- "landingWallOfLove": "MURO DE AMOR",
- "landingWallOfLoveBody": "Mira lo que dicen de Peanut en",
"landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
"landingCardBeatBridgeLink": "The app is live today",
"landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
@@ -157,6 +147,13 @@
"landingMarqueeClosedBeta": "Closed beta",
"landingMarqueeShhhh": "Shhhh",
"landingMarqueeWordTravels": "Word travels",
+ "landingMarqueeInstant": "Instant",
+ "landingMarquee247": "24/7",
+ "landingMarqueeUsd": "USD",
+ "landingMarqueeEur": "EUR",
+ "landingMarqueeStablecoins": "USDT/USDC",
+ "landingMarqueeGlobal": "Global",
+ "landingMarqueeSelfCustodial": "Self-custodial",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index 9242248772..d01f42bd73 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -107,16 +107,6 @@
"landingDropLinkBody": "Por chat o en persona. Funciona en WhatsApp, SMS y códigos QR. Sin datos bancarios, y no necesitan registrarse ni instalar Peanut.",
"landingSendNow": "ENVIÁ AHORA",
"landingSignUpNow": "CREÁ TU CUENTA YA",
- "landingCurrencyPlaceholder": "Moneda o país",
- "landingCardHeading": "PAGÁ EN TODAS PARTES.",
- "landingCardBody": "Pedí la Peanut Card y pagá en cualquier parte del mundo.",
- "landingCardBullet1": "Sin cuota mensual. Sin cuota anual.",
- "landingCardBullet2": "Ganá para siempre por cada invitación",
- "landingCardBullet3": "No custodial: tus fondos, tu control",
- "landingCardDisclaimer": "Se aplican las comisiones y los límites estándar según los términos del titular de la tarjeta.",
- "landingCardCta": "QUIERO MI TARJETA",
- "landingWallOfLove": "MURO DE AMOR",
- "landingWallOfLoveBody": "Mirá lo que dicen de Peanut en",
"landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
"landingCardBeatBridgeLink": "The app is live today",
"landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
@@ -157,6 +147,13 @@
"landingMarqueeClosedBeta": "Closed beta",
"landingMarqueeShhhh": "Shhhh",
"landingMarqueeWordTravels": "Word travels",
+ "landingMarqueeInstant": "Instant",
+ "landingMarquee247": "24/7",
+ "landingMarqueeUsd": "USD",
+ "landingMarqueeEur": "EUR",
+ "landingMarqueeStablecoins": "USDT/USDC",
+ "landingMarqueeGlobal": "Global",
+ "landingMarqueeSelfCustodial": "Self-custodial",
"faqTitle": "Preguntas frecuentes",
"heroMarqueeNoFees": "Sin comisiones",
"heroMarqueeInstant": "Al instante",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index 65807dbac8..4bfff929c9 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -107,16 +107,6 @@
"landingDropLinkBody": "Por chat ou pessoalmente. Funciona no WhatsApp, SMS e códigos QR. Sem dados bancários, e a pessoa nem precisa se cadastrar ou instalar o Peanut.",
"landingSendNow": "ENVIAR AGORA",
"landingSignUpNow": "CRIAR CONTA AGORA",
- "landingCurrencyPlaceholder": "Moeda ou país",
- "landingCardHeading": "PAGUE EM QUALQUER LUGAR.",
- "landingCardBody": "Peça o Peanut Card e pague em qualquer lugar do mundo.",
- "landingCardBullet1": "Sem taxa mensal. Sem anuidade.",
- "landingCardBullet2": "Ganhe para sempre por cada convite",
- "landingCardBullet3": "Não custodial: seus fundos, seu controle",
- "landingCardDisclaimer": "Aplicam-se as taxas e os limites padrão conforme os termos do titular do cartão.",
- "landingCardCta": "QUERO MEU CARTÃO",
- "landingWallOfLove": "MURAL DE AMOR",
- "landingWallOfLoveBody": "Veja o que as pessoas dizem do Peanut no",
"landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
"landingCardBeatBridgeLink": "The app is live today",
"landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
@@ -157,6 +147,13 @@
"landingMarqueeClosedBeta": "Closed beta",
"landingMarqueeShhhh": "Shhhh",
"landingMarqueeWordTravels": "Word travels",
+ "landingMarqueeInstant": "Instant",
+ "landingMarquee247": "24/7",
+ "landingMarqueeUsd": "USD",
+ "landingMarqueeEur": "EUR",
+ "landingMarqueeStablecoins": "USDT/USDC",
+ "landingMarqueeGlobal": "Global",
+ "landingMarqueeSelfCustodial": "Self-custodial",
"faqTitle": "Perguntas frequentes",
"heroMarqueeNoFees": "Sem taxas",
"heroMarqueeInstant": "Na hora",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index d3e516a72a..160972c94c 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -134,20 +134,6 @@ export interface Translations {
// Landing page — CTAs and inputs
landingSendNow: string
landingSignUpNow: string
- landingCurrencyPlaceholder: string
-
- // Landing page — Peanut Card section
- landingCardHeading: string
- landingCardBody: string
- landingCardBullet1: string
- landingCardBullet2: string
- landingCardBullet3: string
- landingCardDisclaimer: string
- landingCardCta: string
-
- // Landing page — testimonials
- landingWallOfLove: string
- landingWallOfLoveBody: string
// Landing page — card beat (closed-beta card, /shhhhh door)
// The bridge line and the body wrap inline elements (a /setup link, the
@@ -198,10 +184,17 @@ export interface Translations {
landingNotForYouBody: string
landingNotForYouSignUpLink: string
- // Landing page — closed-beta marquee strip
+ // Landing page — marquee strips
landingMarqueeClosedBeta: string
landingMarqueeShhhh: string
landingMarqueeWordTravels: string
+ landingMarqueeInstant: string
+ landingMarquee247: string
+ landingMarqueeUsd: string
+ landingMarqueeEur: string
+ landingMarqueeStablecoins: string
+ landingMarqueeGlobal: string
+ landingMarqueeSelfCustodial: string
// Legal page headers (privacy/terms carry no in their verbatim markdown)
legalHeroSubtitlePrivacy: string
diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts
index 95c77a8539..c8ea1f99bd 100644
--- a/src/lib/landingContent.ts
+++ b/src/lib/landingContent.ts
@@ -16,7 +16,6 @@ interface LandingFrontmatter {
hero?: {
primary_cta?: { label?: string; href?: string; subtext?: string }
}
- marquee?: string[]
faqs?: {
heading?: string
questions?: Array<{ id: string; question: string; answer: string }>
@@ -33,7 +32,6 @@ export interface LandingContent {
questions: Array<{ id: string; question: string; answer: string }>
marquee: { visible: boolean; message: string }
}
- marqueeMessages: string[]
}
// Fallback used if the singleton MD is missing (e.g. submodule hasn't synced).
@@ -42,7 +40,6 @@ export interface LandingContent {
const DEFAULTS: LandingContent = {
heroConfig: { primaryCta: { label: 'SIGN UP', href: '/setup' } },
faqData: { heading: 'FAQ.', questions: [], marquee: { visible: false, message: 'Peanut' } },
- marqueeMessages: [],
}
// Code-defined FAQ item advertising supported networks/tokens/bank rails.
@@ -97,6 +94,5 @@ function readLandingContent(locale: Locale): LandingContent {
? fm.faqs.marquee
: DEFAULTS.faqData.marquee,
},
- marqueeMessages: (fm.marquee ?? DEFAULTS.marqueeMessages).filter((m): m is string => typeof m === 'string'),
}
}
From 1360e9e45825aabafbdc2820f726a4e2e60bf2c9 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 13:50:46 +0000
Subject: [PATCH 03/17] feat(landing): rebuild the homepage as the approved v3
fold order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The manifesto rebuild rewrote sections that already earn their keep. Most
of the approved v3 page is the live folds in a new order, so restore them
and re-sequence instead of writing new ones.
Two folds are new. A black door fold distils the /shhhhh hero — same
catalog, same scarcity counter — and every click in it goes to /shhhhh, so
the fold makes the offer and the page does the work. A cream problem fold
names the three people we built peanut for, right before the product folds
answer them.
Restored from dev: the tweet wall, the hero, NoFees, the sticky mobile bar,
the exchange-rate widget, the landing string bag, and the marquee words
from the content system. Dropped with the rebuild: CardBeat, Manifesto,
ProblemProse, WorksToday, NotForYou. The DropLink fold leaves the homepage
because v3 has no slot for it; the component stays for /exchange.
Manteca turns blue: it now follows RegulatedRails, which is cream. On the
black fold the button and the card carry pink drop shadows, because a black
shadow is invisible there. disableCardBeat becomes disableLandingCardFold
and hides the door fold together with the closed-beta strip under it.
---
src/app/shhhhh/ShhhhhLandingPage.tsx | 2 -
src/components/Card/doorTally.utils.ts | 2 +-
.../Card/share-asset/PixelatedCardFace.tsx | 7 +-
.../Global/ExchangeRateWidget/index.tsx | 67 +---
src/components/LandingPage/CardBeat.tsx | 156 --------
.../LandingPage/LandingPageClient.tsx | 225 +++++++++--
.../LandingPage/LandingPageContent.tsx | 25 +-
src/components/LandingPage/Manifesto.tsx | 24 --
src/components/LandingPage/Manteca.tsx | 14 +-
src/components/LandingPage/NotForYou.tsx | 52 ---
src/components/LandingPage/ProblemFold.tsx | 101 +++++
src/components/LandingPage/ProblemProse.tsx | 37 --
.../LandingPage/ScarcityCounter.tsx | 6 +-
src/components/LandingPage/ShhhhhFold.tsx | 82 ++++
src/components/LandingPage/Sparkle.tsx | 18 -
.../LandingPage/StickyMobileCTA.tsx | 201 +++-------
src/components/LandingPage/TweetCarousel.tsx | 370 ++++++++++++++++++
src/components/LandingPage/WorksToday.tsx | 101 -----
src/components/LandingPage/hero.tsx | 18 +-
src/components/LandingPage/landing.types.ts | 74 +---
src/components/LandingPage/landingStrings.ts | 113 ++----
src/components/LandingPage/noFees.tsx | 8 +-
src/config/underMaintenance.config.ts | 10 +-
src/constants/tweets.consts.ts | 92 +++++
src/i18n/en.json | 62 +--
src/i18n/es-419.json | 62 +--
src/i18n/es-ar.json | 62 +--
src/i18n/pt-br.json | 62 +--
src/i18n/types.ts | 82 ++--
src/lib/landingContent.ts | 4 +
30 files changed, 1095 insertions(+), 1044 deletions(-)
delete mode 100644 src/components/LandingPage/CardBeat.tsx
delete mode 100644 src/components/LandingPage/Manifesto.tsx
delete mode 100644 src/components/LandingPage/NotForYou.tsx
create mode 100644 src/components/LandingPage/ProblemFold.tsx
delete mode 100644 src/components/LandingPage/ProblemProse.tsx
create mode 100644 src/components/LandingPage/ShhhhhFold.tsx
delete mode 100644 src/components/LandingPage/Sparkle.tsx
create mode 100644 src/components/LandingPage/TweetCarousel.tsx
delete mode 100644 src/components/LandingPage/WorksToday.tsx
create mode 100644 src/constants/tweets.consts.ts
diff --git a/src/app/shhhhh/ShhhhhLandingPage.tsx b/src/app/shhhhh/ShhhhhLandingPage.tsx
index b50ab4c4de..329d44577f 100644
--- a/src/app/shhhhh/ShhhhhLandingPage.tsx
+++ b/src/app/shhhhh/ShhhhhLandingPage.tsx
@@ -183,8 +183,6 @@ export default function ShhhhhLandingPage() {
const badgeCampaigns = badgeCampaignsFromSearchParams(new URLSearchParams(window.location.search))
posthog.capture(ANALYTICS_EVENTS.DOOR_TRY, {
- // same event as the homepage card beat; the surface splits the two
- surface: 'shhhhh',
signed_in: !!user,
campaign_tags: badgeCampaigns,
})
diff --git a/src/components/Card/doorTally.utils.ts b/src/components/Card/doorTally.utils.ts
index 0e554753ea..d92fcd94f0 100644
--- a/src/components/Card/doorTally.utils.ts
+++ b/src/components/Card/doorTally.utils.ts
@@ -13,7 +13,7 @@
/**
* Admissions per week in the closed beta (product/card.md). Drives the
- * ScarcityCounter pill on the homepage card beat and on /shhhhh, so both
+ * ScarcityCounter pill on the homepage door fold and on /shhhhh, so both
* surfaces quote the same number.
*/
export const ADMISSIONS_PER_WEEK = 50
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 = ({
void
labels?: Partial
- /** Hides the bank-fee / Peanut-fee rows and the "should arrive in…" line. */
- hideFees?: boolean
- /** Currency pair the widget opens on before the URL says otherwise. */
- defaultFrom?: string
- defaultTo?: string
}
-// The CTA is all-or-nothing: a caller either owns the button or hides it, so
-// the label/icon/action trio only exists on the branch that renders it.
-type IExchangeRateWidgetProps = IExchangeRateWidgetBaseProps &
- (
- | {
- hideCta: true
- ctaLabel?: never
- ctaIcon?: never
- ctaAction?: never
- }
- | {
- hideCta?: false
- ctaLabel: string
- ctaIcon: IconName
- ctaAction: (sourceCurrency: string, destinationCurrency: string) => void
- }
- )
-
-const ExchangeRateWidget: FC = ({
- ctaLabel,
- ctaIcon,
- ctaAction,
- labels,
- hideCta,
- hideFees,
- defaultFrom = 'USD',
- defaultTo = 'EUR',
-}) => {
+const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, ctaAction, labels }) => {
const l = { ...DEFAULT_LABELS, ...labels }
// shallow + history:'replace' uses window.history.replaceState — bypasses
// Next.js navigation so URL updates don't (occasionally) scroll the page
// to the top through the parent Suspense boundary.
const [query, setQuery] = useQueryStates(
{
- from: parseAsString.withDefault(defaultFrom),
- to: parseAsString.withDefault(defaultTo),
+ from: parseAsString.withDefault('USD'),
+ to: parseAsString.withDefault('EUR'),
amount: parseAsFloat.withDefault(10),
},
{ shallow: true, history: 'replace', scroll: false }
@@ -342,7 +313,7 @@ const ExchangeRateWidget: FC = ({
)}
- {cardBeat.bodyBefore}
- {/* local interpolation — importing t() from @/i18n would
- pull every locale catalog into this client bundle */}
- cardBeat.counterLabel.replace('{count}', String(count))}
- />
- {cardBeat.bodyAfter}
-
-
{cardBeat.custody}
-
-
-
- {strings.tryTheDoor}
-
- {/* A plain link, not a second door: DOOR_TRY marks an
- attempt at the door, so the waitlist route must not
- fire it or the event stops meaning one thing. */}
-
- {cardBeat.waitlistLink}
-
-
-
-
- {cardBeat.trust}
-
-
-
-
- {/* 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. */}
-
-
-
-
-
-
-
-
- {/* 150M+ leads so it takes the top-left cell of the 2x2 mobile grid. */}
-
- {CARD_BEAT_STATS.map((id) => (
-
-
- {stats[id].value}
-
-
- {stats[id].label}
-
-
- ))}
-
-
-
- )
-}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index c0d2a413c8..638b2d7939 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -1,15 +1,14 @@
'use client'
import { useFooterVisibility } from '@/context/footerVisibility'
-import { Suspense, useMemo, type ReactNode } from 'react'
-import { FAQs } from '@/components/LandingPage/faq'
-import { Hero } from '@/components/LandingPage/hero'
-import { Marquee } from '@/components/LandingPage/marquee'
-import { CardBeat } from '@/components/LandingPage/CardBeat'
-import { WorksToday } from '@/components/LandingPage/WorksToday'
+import { Suspense, useEffect, useMemo, useState, useRef, useCallback, type ReactNode } from 'react'
+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'
import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
import type { LandingStrings } from './landingStrings'
import type { Locale } from '@/i18n/types'
import StoreBadges from '@/components/Migration/StoreBadges'
@@ -19,7 +18,6 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useMigrationFlag } from '@/hooks/useMigrationFlag'
import { useTranslations } from 'next-intl'
import { trackStoreClick } from '@/utils/migration.utils'
-import underMaintenanceConfig from '@/config/underMaintenance.config'
type FAQQuestion = {
id: string
@@ -36,24 +34,31 @@ type LandingPageClientProps = {
questions: FAQQuestion[]
marquee: { visible: boolean; message: string }
}
+ marqueeMessages: string[]
locale: Locale
strings: LandingStrings
- // Server-rendered slots — the beats with no client state stay off the
- // client bundle and arrive as markup.
- manifestoSlot: ReactNode
+ // Server-rendered slots
problemSlot: ReactNode
- notForYouSlot: ReactNode
+ mantecaSlot: ReactNode
+ regulatedRailsSlot: ReactNode
+ yourMoneySlot: ReactNode
+ securitySlot: ReactNode
+ sendInSecondsSlot: ReactNode
footerSlot: ReactNode
}
export function LandingPageClient({
heroConfig,
faqData,
+ marqueeMessages,
locale,
strings,
- manifestoSlot,
problemSlot,
- notForYouSlot,
+ mantecaSlot,
+ regulatedRailsSlot,
+ yourMoneySlot,
+ securitySlot,
+ sendInSecondsSlot,
footerSlot,
}: LandingPageClientProps) {
const { isFooterVisible } = useFooterVisibility()
@@ -61,11 +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 card beat and the closed-beta strip that follows it are
- // one promise, so they go dark together.
- const cardBeatOn = !underMaintenanceConfig.disableCardBeat
+ // 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
@@ -88,7 +96,8 @@ export function LandingPageClient({
}
}, [migrationOn, deviceType, isDesktop, heroConfig.primaryCta, tMigration])
- // Memoized so the rich answer element isn't rebuilt on every render.
+ // 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) =>
@@ -97,13 +106,161 @@ export function LandingPageClient({
[faqData.questions]
)
- const buttonVisible = !isFooterVisible
+ const [buttonVisible, setButtonVisible] = useState(true)
+ const [isScrollFrozen, setIsScrollFrozen] = useState(false)
+ const [buttonScale, setButtonScale] = useState(1)
+ const [animationComplete, setAnimationComplete] = useState(false)
+ const [shrinkingPhase, setShrinkingPhase] = useState(false)
+ const [hasGrown, setHasGrown] = useState(false)
+ const sendInSecondsRef = useRef(null)
+ const frozenScrollY = useRef(0)
+ const virtualScrollY = useRef(0)
+ const touchStartY = useRef(0)
+
+ // Use refs to avoid re-attaching listeners on every state change
+ const isScrollFrozenRef = useRef(isScrollFrozen)
+ const animationCompleteRef = useRef(animationComplete)
+ const shrinkingPhaseRef = useRef(shrinkingPhase)
+ const hasGrownRef = useRef(hasGrown)
+ isScrollFrozenRef.current = isScrollFrozen
+ animationCompleteRef.current = animationComplete
+ shrinkingPhaseRef.current = shrinkingPhase
+ hasGrownRef.current = hasGrown
+
+ useEffect(() => {
+ if (isFooterVisible) {
+ setButtonVisible(false)
+ } else {
+ setButtonVisible(true)
+ }
+ }, [isFooterVisible])
+
+ // Shared logic: accumulate virtual scroll delta and animate the button scale
+ const handleScrollDelta = useCallback((deltaY: number) => {
+ if (!isScrollFrozenRef.current || animationCompleteRef.current) return
+ if (deltaY <= 0) return
+
+ virtualScrollY.current += deltaY
+
+ const maxVirtualScroll = 500
+ const newScale = Math.min(1.5, 1 + (virtualScrollY.current / maxVirtualScroll) * 0.5)
+ setButtonScale(newScale)
+
+ if (newScale >= 1.5) {
+ setAnimationComplete(true)
+ setHasGrown(true)
+ document.body.style.overflow = ''
+ setIsScrollFrozen(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleScroll = () => {
+ if (sendInSecondsRef.current) {
+ const targetElement = document.getElementById('sticky-button-target')
+ if (!targetElement) return
+
+ const targetRect = targetElement.getBoundingClientRect()
+ const currentScrollY = window.scrollY
+
+ const stickyButtonTop = window.innerHeight - 16 - 52
+ const stickyButtonBottom = window.innerHeight - 16
+
+ const shouldFreeze =
+ targetRect.top <= stickyButtonBottom - 60 &&
+ targetRect.bottom >= stickyButtonTop - 60 &&
+ !animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ !hasGrownRef.current
+
+ if (shouldFreeze && !isScrollFrozenRef.current) {
+ setIsScrollFrozen(true)
+ frozenScrollY.current = currentScrollY
+ virtualScrollY.current = 0
+ document.body.style.overflow = 'hidden'
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (
+ animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ currentScrollY > frozenScrollY.current + 50
+ ) {
+ setShrinkingPhase(true)
+ } else if (shrinkingPhaseRef.current) {
+ const shrinkDistance = Math.max(0, currentScrollY - (frozenScrollY.current + 50))
+ const maxShrinkDistance = 200
+ const shrinkProgress = Math.min(1, shrinkDistance / maxShrinkDistance)
+ const newScale = 1.5 - shrinkProgress * 0.5
+ setButtonScale(Math.max(1, newScale))
+ } else if (animationCompleteRef.current && currentScrollY < frozenScrollY.current - 100) {
+ setAnimationComplete(false)
+ setShrinkingPhase(false)
+ setButtonScale(1)
+ setHasGrown(false)
+ }
+ }
+ }
+
+ const handleWheel = (event: WheelEvent) => {
+ if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ event.preventDefault()
+ handleScrollDelta(event.deltaY)
+ }
+ }
+
+ const handleTouchStart = (event: TouchEvent) => {
+ touchStartY.current = event.touches[0].clientY
+ }
+
+ const handleTouchMove = (event: TouchEvent) => {
+ if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ event.preventDefault()
+ const deltaY = touchStartY.current - event.touches[0].clientY
+ touchStartY.current = event.touches[0].clientY
+ handleScrollDelta(deltaY)
+ }
+ }
+
+ window.addEventListener('scroll', handleScroll)
+ window.addEventListener('wheel', handleWheel, { passive: false })
+ window.addEventListener('touchstart', handleTouchStart, { passive: true })
+ window.addEventListener('touchmove', handleTouchMove, { passive: false })
+ handleScroll()
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll)
+ window.removeEventListener('wheel', handleWheel)
+ window.removeEventListener('touchstart', handleTouchStart)
+ window.removeEventListener('touchmove', handleTouchMove)
+ document.body.style.overflow = ''
+ }
+ }, [handleScrollDelta])
+
+ const marqueeProps = { visible: true, message: marqueeMessages }
+
+ // 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,
+ message: [
+ tDoorMarquee('iykyk'),
+ tDoorMarquee('wordTravels'),
+ tDoorMarquee('closedBeta'),
+ tDoorMarquee('shhhh'),
+ tDoorMarquee('peanutClub'),
+ ],
+ }),
+ [tDoorMarquee]
+ )
return (
<>
-
- {cardBeatOn && (
+
+ {doorFoldOn && (
<>
-
-
+
+
>
)}
- {manifestoSlot}
{problemSlot}
-
- {/* Suspense needed: WorksToday renders ExchangeRateWidget which uses useSearchParams().
+
+ {/* 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. */}
-
+
-
- {notForYouSlot}
-
+
+ {yourMoneySlot}
+
+
+
+ {regulatedRailsSlot}
+
+ {mantecaSlot}
+
+ {securitySlot}
+
+
{sendInSecondsSlot}
+
+
{footerSlot}
>
diff --git a/src/components/LandingPage/LandingPageContent.tsx b/src/components/LandingPage/LandingPageContent.tsx
index d9fff05c49..57807b0d14 100644
--- a/src/components/LandingPage/LandingPageContent.tsx
+++ b/src/components/LandingPage/LandingPageContent.tsx
@@ -1,8 +1,11 @@
import { Suspense } from 'react'
import { LandingPageClient } from './LandingPageClient'
-import { Manifesto } from './Manifesto'
-import { ProblemProse } from './ProblemProse'
-import { NotForYou } from './NotForYou'
+import Manteca from './Manteca'
+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'
@@ -12,11 +15,15 @@ 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 } = getLandingContent(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.
@@ -35,11 +42,15 @@ export function LandingPageContent({ locale }: { locale: Locale }) {
}
- problemSlot={}
- notForYouSlot={}
+ problemSlot={}
+ mantecaSlot={}
+ regulatedRailsSlot={}
+ yourMoneySlot={}
+ securitySlot={}
+ sendInSecondsSlot={}
footerSlot={}
/>
diff --git a/src/components/LandingPage/Manifesto.tsx b/src/components/LandingPage/Manifesto.tsx
deleted file mode 100644
index 8d5b9425f9..0000000000
--- a/src/components/LandingPage/Manifesto.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { LandingStrings } from './landingStrings'
-
-// White on the blue beat is 2.33:1, so the headline is ringed in black on every
-// side instead of carrying a two-sided drop shadow. The ring closes the gap
-// between lines at 0.95 leading, hence the looser leading on small screens.
-const HEADLINE_RING = '3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
-
-export function Manifesto({ strings }: { strings: LandingStrings }) {
- const { manifesto } = strings
-
- return (
-
-
diff --git a/src/components/LandingPage/NotForYou.tsx b/src/components/LandingPage/NotForYou.tsx
deleted file mode 100644
index ed1de6f538..0000000000
--- a/src/components/LandingPage/NotForYou.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import Link from 'next/link'
-import { Button } from '@/components/0_Bruddle/Button'
-import { Sparkle } from './Sparkle'
-import type { LandingStrings } from './landingStrings'
-
-// On the black beat a black shadow vanishes, so the door button carries a pink
-// one — the border stays black, like every other button on the page. Arbitrary
-// rather than shadow-4 because no pink shadow token exists. It is the button's
-// only shadow: passing shadowSize as well would stack two of them.
-const DOOR_SHADOW = 'shadow-[4px_4px_0_#FF90E8]'
-
-export function NotForYou({ strings }: { strings: LandingStrings }) {
- const { notForYou } = strings
-
- return (
-
-
-
- )
-}
diff --git a/src/components/LandingPage/ScarcityCounter.tsx b/src/components/LandingPage/ScarcityCounter.tsx
index c9c4459d28..4aa43215d0 100644
--- a/src/components/LandingPage/ScarcityCounter.tsx
+++ b/src/components/LandingPage/ScarcityCounter.tsx
@@ -9,9 +9,9 @@ import { ADMISSIONS_PER_WEEK } from '@/components/Card/doorTally.utils'
const COUNTER_START = ADMISSIONS_PER_WEEK + 1
/**
- * "Only N a week" scarcity pill, shared by the homepage card beat and the
- * /shhhhh hero. `label` builds the copy for a given count — each surface has
- * its own catalog, so the interpolation stays at the call site.
+ * "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()
diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx
new file mode 100644
index 0000000000..64f039de04
--- /dev/null
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -0,0 +1,82 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import Link from 'next/link'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/0_Bruddle/Button'
+import { ScarcityCounter } from './ScarcityCounter'
+
+// 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 the card's black drop shadow and the button's black shadow
+// both disappear, so both carry the brand pink here instead. Deliberate
+// deviation from the all-black-shadow rule — it only applies on this fold.
+const PINK = '#FF90E8'
+
+/**
+ * The homepage door to /shhhhh. Distils §1 of the /shhhhh page — same catalog,
+ * same counter — and every click here goes to /shhhhh. The fold makes the
+ * offer; the page does the work (sign-in, badge check, waitlist).
+ */
+export function ShhhhhFold() {
+ const t = useTranslations('shhhhh.hero')
+
+ return (
+
+
+
+ {/* 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/Sparkle.tsx b/src/components/LandingPage/Sparkle.tsx
deleted file mode 100644
index f664e05965..0000000000
--- a/src/components/LandingPage/Sparkle.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Four-point sparkle drawn in currentColor-free white, for the black beats.
- * The shared `Sparkle` in @/assets/illustrations is filled #121212, so it
- * disappears on black — this one is the white-on-black twin, not a third
- * star shape.
- */
-export function Sparkle({ filled }: { filled: boolean }) {
- return (
-
- )
-}
diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx
index 18606ed211..5f5279f27c 100644
--- a/src/components/LandingPage/StickyMobileCTA.tsx
+++ b/src/components/LandingPage/StickyMobileCTA.tsx
@@ -3,67 +3,30 @@
import { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
-import { useTranslations } from 'next-intl'
import { Button } from '@/components/0_Bruddle/Button'
-import underMaintenanceConfig from '@/config/underMaintenance.config'
+import type { LandingStrings } from './landingStrings'
import { MIGRATION_SURFACES, STORE_URL } from '@/constants/migration.consts'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useMigrationFlag } from '@/hooks/useMigrationFlag'
+import { useTranslations } from 'next-intl'
import { trackStoreClick } from '@/utils/migration.utils'
-import type { LandingStrings } from './landingStrings'
-
-/** Scroll distance before the bar comes up. */
-const SHOW_AFTER_PX = 300
-
-/** Distance from the document bottom where the footer takes over. */
-const BOTTOM_GUARD_PX = 100
-
-/** The bar is `md:hidden`, so above this width it listens to nothing. */
-const MOBILE_QUERY = '(max-width: 767px)'
-
-/**
- * Beats that repeat the CTAs of this bar mark themselves with this attribute,
- * and the bar steps aside while any of them is on screen. An attribute, not a
- * list of ids: WorksToday mounts late (it sits inside a Suspense boundary) and
- * a renamed id would drop out of a list without anyone noticing.
- */
-export const OWN_CTA_ATTRIBUTE = 'data-own-cta'
export function StickyMobileCTA({ strings }: { strings: LandingStrings }) {
- const [isMobile, setIsMobile] = useState(false)
- const [scrolledPast, setScrolledPast] = useState(false)
- const [atBottom, setAtBottom] = useState(false)
- const [ownCtaOnScreen, setOwnCtaOnScreen] = useState(false)
- const sentinelRef = useRef(null)
+ const [visible, setVisible] = useState(false)
const rafId = useRef(0)
- const lastScrolledPast = useRef(false)
-
+ const lastVisible = useRef(false)
const migrationOn = useMigrationFlag()
- const { deviceType } = useDeviceType()
- // app-locale translation, same as the hero's migration CTA
const tMigration = useTranslations('migration')
- const isPhone = deviceType !== DeviceType.WEB
+ const { deviceType } = useDeviceType()
const store = deviceType === DeviceType.ANDROID ? 'android' : 'ios'
- // pwa-sunset: phones get the store deep-link instead of the signup door pair
- const showStoreCta = migrationOn && isPhone
- const showDoor = !showStoreCta && !underMaintenanceConfig.disableCardBeat
useEffect(() => {
- const query = window.matchMedia(MOBILE_QUERY)
- const apply = () => setIsMobile(query.matches)
- apply()
- query.addEventListener('change', apply)
- return () => query.removeEventListener('change', apply)
- }, [])
-
- useEffect(() => {
- if (!isMobile) return
-
const check = () => {
- const next = window.scrollY >= SHOW_AFTER_PX
- if (next !== lastScrolledPast.current) {
- lastScrolledPast.current = next
- setScrolledPast(next)
+ const next = window.scrollY >= 300
+
+ if (next !== lastVisible.current) {
+ lastVisible.current = next
+ setVisible(next)
}
}
@@ -78,114 +41,46 @@ export function StickyMobileCTA({ strings }: { strings: LandingStrings }) {
window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(rafId.current)
}
- }, [isMobile])
-
- // The sentinel sits at the end of the page, so it comes into view exactly
- // when the old `document.body.scrollHeight` read said "near the bottom" —
- // without measuring the layout on every scroll frame.
- useEffect(() => {
- if (!isMobile) return
- const sentinel = sentinelRef.current
- if (!sentinel) return
-
- const observer = new IntersectionObserver(([entry]) => setAtBottom(entry.isIntersecting), {
- rootMargin: `0px 0px ${BOTTOM_GUARD_PX}px 0px`,
- })
- observer.observe(sentinel)
- return () => observer.disconnect()
- }, [isMobile])
-
- useEffect(() => {
- if (!isMobile) return
-
- const onScreen = new Set()
- const observed = new Set()
- const observer = new IntersectionObserver((entries) => {
- for (const entry of entries) {
- if (entry.isIntersecting) onScreen.add(entry.target)
- else onScreen.delete(entry.target)
- }
- setOwnCtaOnScreen(onScreen.size > 0)
- })
-
- // re-run on every DOM change so sections that mount after this effect
- // (Suspense, kill switches) still get observed
- const sync = () => {
- for (const section of document.querySelectorAll(`[${OWN_CTA_ATTRIBUTE}]`)) {
- if (observed.has(section)) continue
- observed.add(section)
- observer.observe(section)
- }
- }
- sync()
-
- const mutations = new MutationObserver(sync)
- mutations.observe(document.body, { childList: true, subtree: true })
- return () => {
- mutations.disconnect()
- observer.disconnect()
- }
- }, [isMobile])
+ }, [])
return (
- <>
-
-
- {isMobile && scrolledPast && !atBottom && !ownCtaOnScreen && (
-
- {showStoreCta ? (
- trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO)}
- className="pointer-events-auto flex-1"
+
+ {visible && (
+
+ {migrationOn ? (
+ // this bar is md:hidden so the visitor is on a phone —
+ // deep-link their store during the migration window
+ trackStoreClick(store, MIGRATION_SURFACES.LANDING_HERO)}
+ >
+
-
- {tMigration('downloadNow')}
-
-
- ) : (
- <>
-
-
- {strings.signUpNow}
-
-
- {showDoor && (
-
-
- {strings.tryTheDoor}
-
-
- )}
- >
- )}
-
- )}
-
- >
+ {tMigration('downloadNow')}
+
+
+ ) : (
+
+
+ {strings.signUpNow}
+
+
+ )}
+
+ )}
+
)
}
diff --git a/src/components/LandingPage/TweetCarousel.tsx b/src/components/LandingPage/TweetCarousel.tsx
new file mode 100644
index 0000000000..a4648d6709
--- /dev/null
+++ b/src/components/LandingPage/TweetCarousel.tsx
@@ -0,0 +1,370 @@
+'use client'
+
+import { TWEETS, type Tweet } from '@/constants/tweets.consts'
+import Image from 'next/image'
+import { useMemo, useState } from 'react'
+import Marquee from 'react-fast-marquee'
+import type { LandingStrings } from './landingStrings'
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+/** Scroll speed in pixels per second */
+const MARQUEE_SPEED = 40
+
+/** Shadow offset for consistent column height calculation */
+const CARD_SHADOW_OFFSET = 6
+
+/**
+ * Card height constants - all column types align to FEATURED_HEIGHT
+ * - Featured: 440px (single card)
+ * - Standard: 212px × 2 + 16px gap = 440px
+ * - Tiny: 136px × 3 + 32px gap = 440px
+ */
+const FEATURED_HEIGHT = 440
+const STANDARD_HEIGHT = 212
+const TINY_HEIGHT = 136
+
+/** Content length threshold for "tiny" tweets (characters) */
+const TINY_CONTENT_THRESHOLD = 100
+
+// =============================================================================
+// Types
+// =============================================================================
+
+type ColumnType =
+ | { type: 'featured'; tweet: Tweet }
+ | { type: 'standard'; tweets: Tweet[] }
+ | { type: 'tiny'; tweets: Tweet[] }
+
+// =============================================================================
+// Shared Components
+// =============================================================================
+
+/** Avatar with lazy loading and fallback to author initial */
+const Avatar = ({ tweet }: { tweet: Tweet }) => {
+ const [imgError, setImgError] = useState(false)
+
+ if (!tweet.avatar || imgError) {
+ return (
+
-
- )
-}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index c0cc2087a3..9353f6f23c 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -81,6 +81,7 @@ type HeroProps = {
primaryCta?: CTAButton
secondaryCta?: CTAButton
buttonVisible?: boolean
+ buttonScale?: number
/** replaces the primary button entirely (store-button pair on desktop during the migration window) */
customCta?: React.ReactNode
}
@@ -92,11 +93,12 @@ const getInitialAnimation = (variant: 'primary' | 'secondary') => ({
rotate: 0.75,
})
-const getAnimateAnimation = (variant: 'primary' | 'secondary', buttonVisible?: boolean) => ({
+const getAnimateAnimation = (variant: 'primary' | 'secondary', buttonVisible?: boolean, buttonScale?: number) => ({
opacity: buttonVisible ? 1 : 0,
translateY: buttonVisible ? 0 : 20,
translateX: buttonVisible ? 0 : 20,
rotate: buttonVisible ? 0 : 1,
+ scale: buttonScale || 1,
pointerEvents: buttonVisible ? ('auto' as const) : ('none' as const),
})
@@ -111,13 +113,21 @@ 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)]'}`
-export function Hero({ primaryCta, secondaryCta, buttonVisible, customCta, strings, locale }: HeroProps) {
+export function Hero({
+ primaryCta,
+ secondaryCta,
+ buttonVisible,
+ buttonScale = 1,
+ customCta,
+ strings,
+ locale,
+}: HeroProps) {
const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => {
return (
@@ -146,7 +156,7 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, customCta, strin
{customCta}
diff --git a/src/components/LandingPage/landing.types.ts b/src/components/LandingPage/landing.types.ts
index 3e3d4751b6..242203e67d 100644
--- a/src/components/LandingPage/landing.types.ts
+++ b/src/components/LandingPage/landing.types.ts
@@ -12,72 +12,16 @@ export type CTAButton = {
}
/**
- * Copy for the card beat ().
- *
- * Two lines wrap an element rather than a string — the /setup anchor in the
- * bridge line and the counter chip in the body — so each arrives pre-split
- * into halves. A split-on-placeholder would drop the tail silently once a
- * translator loses the placeholder; separate keys cannot fail that way.
+ * 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 LandingCardBeatStrings {
- bridgeBefore: string
- bridgeLinkLabel: string
- bridgeAfter: string
- kicker: string
- heading: string
- tagline: string
- bodyBefore: string
- /** Carries "{count}" — the component substitutes the live number. */
- counterLabel: string
- bodyAfter: string
- custody: string
- trust: string
- waitlistLink: string
- /** Stat-tile labels. The numerals themselves are not translatable. */
- statMerchants: string
- statBalance: string
- statCard: string
- statMonthlyFees: string
-}
-
-/** Copy for the manifesto beat (). */
-export interface LandingManifestoStrings {
- heading: string
- subline: string
-}
-
-/** Copy for the problem beat (). Pointers anchor to #works-today. */
export interface LandingProblemStrings {
heading: string
- prose: string
- pointerPassport: string
- pointerRate: string
- pointerMoneyOut: string
-}
-
-/**
- * Copy for the "what works today" beat (). The drop-link body,
- * the markup-row label and the rate-widget labels stay on the top-level bag —
- * they predate this beat and are shared with /exchange.
- */
-export interface LandingWorksTodayStrings {
- heading: string
- subline: string
- payLocalTitle: string
- payLocalBody: string
- payLocalNote: string
- payLocalMoneyOut: string
- payLocalChipEurPix: string
- payLocalChipUsdMercadoPago: string
- dropLinkTitle: string
- rateTitle: string
- securityTitle: string
- securityBody: string
-}
-
-/** Copy for the ending beat (). */
-export interface LandingNotForYouStrings {
- heading: string
- body: string
- signUpLink: string
+ crossBorderTitle: string
+ crossBorderBody: string
+ sendHomeTitle: string
+ sendHomeBody: string
+ paidAbroadTitle: string
+ paidAbroadBody: string
}
diff --git a/src/components/LandingPage/landingStrings.ts b/src/components/LandingPage/landingStrings.ts
index 836886df7f..598d7e44a0 100644
--- a/src/components/LandingPage/landingStrings.ts
+++ b/src/components/LandingPage/landingStrings.ts
@@ -1,12 +1,6 @@
import en from '@/i18n/en.json'
import { type Translations } from '@/i18n/types'
-import type {
- LandingCardBeatStrings,
- LandingManifestoStrings,
- LandingNotForYouStrings,
- LandingProblemStrings,
- LandingWorksTodayStrings,
-} from './landing.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
@@ -22,19 +16,17 @@ export interface LandingStrings {
seeMarkupOn: string
dropLinkHeading: string
dropLinkBody: string
- /** Door CTA shared by CardBeat, NotForYou and StickyMobileCTA. */
- tryTheDoor: string
- cardBeat: LandingCardBeatStrings
- manifesto: LandingManifestoStrings
+ currencyPlaceholder: string
+ cardHeading: string
+ cardBody: string
+ cardBullet1: string
+ cardBullet2: string
+ cardBullet3: string
+ cardDisclaimer: string
+ cardCta: string
+ wallOfLove: string
+ wallOfLoveBody: string
problem: LandingProblemStrings
- worksToday: LandingWorksTodayStrings
- notForYou: LandingNotForYouStrings
- /** Words of the strip right under the hero — short, so they stay readable there. */
- marqueeFirst: string[]
- /** Words of every other marquee strip, in order. */
- marqueeDefault: string[]
- /** Words of the closed-beta marquee strip, in order. */
- marqueeClosedBeta: string[]
/** Passed straight through to ExchangeRateWidget's `labels`. */
exchange: {
youSend: string
@@ -61,76 +53,25 @@ export function landingStrings(i18n: Translations): LandingStrings {
seeMarkupOn: i18n.landingSeeMarkupOn,
dropLinkHeading: i18n.landingDropLinkHeading,
dropLinkBody: i18n.landingDropLinkBody,
- tryTheDoor: i18n.landingTryTheDoor,
- cardBeat: {
- // The catalogs keep every value trimmed (i18n/__tests__/messages.test.ts)
- // and JSX drops the newline between two expressions, so the spaces
- // around the inline /setup link are added here. The counter chip
- // carries its own margin, so the body halves need none.
- bridgeBefore: `${i18n.landingCardBeatBridgeBefore} `,
- bridgeLinkLabel: i18n.landingCardBeatBridgeLink,
- bridgeAfter: ` ${i18n.landingCardBeatBridgeAfter}`,
- kicker: i18n.landingCardBeatKicker,
- heading: i18n.landingCardBeatHeading,
- tagline: i18n.landingCardBeatTagline,
- bodyBefore: i18n.landingCardBeatBodyBefore,
- counterLabel: i18n.landingCardBeatCounterLabel,
- bodyAfter: i18n.landingCardBeatBodyAfter,
- custody: i18n.landingCardBeatCustody,
- trust: i18n.landingCardBeatTrust,
- waitlistLink: i18n.landingCardBeatWaitlistLink,
- statMerchants: i18n.landingCardBeatStatMerchants,
- statBalance: i18n.landingCardBeatStatBalance,
- statCard: i18n.landingCardBeatStatCard,
- statMonthlyFees: i18n.landingCardBeatStatMonthlyFees,
- },
- manifesto: {
- heading: i18n.landingManifestoHeading,
- subline: i18n.landingManifestoSubline,
- },
+ currencyPlaceholder: i18n.landingCurrencyPlaceholder,
+ cardHeading: i18n.landingCardHeading,
+ cardBody: i18n.landingCardBody,
+ cardBullet1: i18n.landingCardBullet1,
+ cardBullet2: i18n.landingCardBullet2,
+ cardBullet3: i18n.landingCardBullet3,
+ cardDisclaimer: i18n.landingCardDisclaimer,
+ cardCta: i18n.landingCardCta,
+ wallOfLove: i18n.landingWallOfLove,
+ wallOfLoveBody: i18n.landingWallOfLoveBody,
problem: {
heading: i18n.landingProblemHeading,
- prose: i18n.landingProblemProse,
- pointerPassport: i18n.landingProblemPointerPassport,
- pointerRate: i18n.landingProblemPointerRate,
- pointerMoneyOut: i18n.landingProblemPointerMoneyOut,
- },
- worksToday: {
- heading: i18n.landingWorksTodayHeading,
- subline: i18n.landingWorksTodaySubline,
- payLocalTitle: i18n.landingWorksTodayPayLocalTitle,
- payLocalBody: i18n.landingPayLocalBody,
- payLocalNote: i18n.landingPayLocalSettles,
- payLocalMoneyOut: i18n.landingWorksTodayMoneyOut,
- payLocalChipEurPix: i18n.landingWorksTodayChipEurPix,
- payLocalChipUsdMercadoPago: i18n.landingWorksTodayChipUsdMercadoPago,
- dropLinkTitle: i18n.landingWorksTodayDropLinkTitle,
- rateTitle: i18n.landingWorksTodayRateTitle,
- securityTitle: i18n.landingWorksTodaySecurityTitle,
- securityBody: i18n.landingWorksTodaySecurityBody,
- },
- notForYou: {
- heading: i18n.landingNotForYouHeading,
- body: i18n.landingNotForYouBody,
- signUpLink: i18n.landingNotForYouSignUpLink,
+ crossBorderTitle: i18n.landingProblemCrossBorderTitle,
+ crossBorderBody: i18n.landingProblemCrossBorderBody,
+ sendHomeTitle: i18n.landingProblemSendHomeTitle,
+ sendHomeBody: i18n.landingProblemSendHomeBody,
+ paidAbroadTitle: i18n.landingProblemPaidAbroadTitle,
+ paidAbroadBody: i18n.landingProblemPaidAbroadBody,
},
- marqueeFirst: [
- i18n.landingMarqueeInstant,
- i18n.landingMarquee247,
- i18n.landingMarqueeUsd,
- i18n.landingMarqueeEur,
- i18n.landingMarqueeGlobal,
- ],
- marqueeDefault: [
- i18n.landingMarqueeInstant,
- i18n.landingMarquee247,
- i18n.landingMarqueeUsd,
- i18n.landingMarqueeEur,
- i18n.landingMarqueeStablecoins,
- i18n.landingMarqueeGlobal,
- i18n.landingMarqueeSelfCustodial,
- ],
- marqueeClosedBeta: [i18n.landingMarqueeClosedBeta, i18n.landingMarqueeShhhh, i18n.landingMarqueeWordTravels],
exchange: {
youSend: i18n.exchangeYouSend,
recipientGets: i18n.exchangeRecipientGets,
diff --git a/src/components/LandingPage/noFees.tsx b/src/components/LandingPage/noFees.tsx
index 054cfaae44..b8366ceb5e 100644
--- a/src/components/LandingPage/noFees.tsx
+++ b/src/components/LandingPage/noFees.tsx
@@ -171,15 +171,13 @@ export function NoFees({
labels={strings.exchange}
/>
- {/* The route adds the "peanut-vs-" prefix to every compare slug and
- sets dynamicParams = false, so an unprefixed slug 404s. */}
diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts
index dab1e1e0ec..144ed322c2 100644
--- a/src/config/underMaintenance.config.ts
+++ b/src/config/underMaintenance.config.ts
@@ -31,7 +31,7 @@
* - /card page redirects to /home
* - /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 disableCardBeat for the new one
+ * - 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,8 +51,8 @@
* - 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. disableCardBeat: hides the card beat on the landing page
- * - removes the card section, the closed-beta marquee strip under it, and the "try the door" button in the sticky mobile bar
+ * 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
*
@@ -72,7 +72,7 @@ interface MaintenanceConfig {
disableXchainSend: boolean
disableCardPioneers: boolean
disableCardLaunchCTA: boolean
- disableCardBeat: boolean
+ disableLandingCardFold: boolean
pixBrazilOnrampMaintenance: boolean
/** Manteca fiat currencies still down (e.g. ['BRL']); currencies not listed stay live. Empty = all enabled. */
disabledMantecaCurrencies: MantecaCurrency[]
@@ -89,7 +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.
- disableCardBeat: false, // set to true to hide the landing-page card beat (section + closed-beta strip + sticky-bar door button)
+ 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/constants/tweets.consts.ts b/src/constants/tweets.consts.ts
new file mode 100644
index 0000000000..7fd8d58398
--- /dev/null
+++ b/src/constants/tweets.consts.ts
@@ -0,0 +1,92 @@
+/**
+ * @joinpeanut Wall of Love
+ *
+ * Curated third-party mentions from X (Twitter) during Devconnect Buenos Aires.
+ *
+ * @dataset
+ * - Date range: Oct 1 – Nov 26, 2025
+ * - Count: ~140 unique posts
+ * - Filters: Excludes team posts, neutral mentions, bots
+ *
+ * @scoring impact_score (0.55–0.99)
+ * - 60% content quality (enthusiasm, specificity, authenticity)
+ * - 30% author influence (followers, verified status, known builder/VC/EF)
+ * - 10% engagement signals
+ * - Caps: Internal team posts ≤0.55, low-effort posts ≤0.62
+ *
+ * @usage
+ * Sorted descending by impact_score for carousel display.
+ * Update quarterly by re-running collection with same criteria.
+ *
+ * @recreation Use Grok (has native X search access):
+ *
+ * "Search X for positive third-party mentions of @joinpeanut from [DATE_START] to [DATE_END].
+ * Exclude team posts (@uwwgo, @0xkkonrad, @andxqueen), neutral mentions, and bots.
+ * For each post return: url, author, handle, content, verified, followers_count, avatar, timestamp.
+ * Add impact_score (0.60–0.98): 60% enthusiasm + 30% influence + 10% engagement.
+ * Cap bots at 0.65, boost EF/Arbitrum/known devs. Sort by impact_score desc.
+ * Return as JSON array."
+ *
+ * If truncated, ask for batches ("top 50", then "remaining entries").
+ *
+ * @lastUpdated 2025-11-26
+ */
+
+export interface TweetMedia {
+ type: 'photo' | 'video'
+ url: string
+}
+
+export interface Tweet {
+ url: string
+ author: string
+ handle: string
+ content: string
+ timestamp?: string
+ verified?: boolean
+ followers_count?: number
+ avatar?: string
+ impact_score?: number
+ is_reply?: boolean
+ reply_to_url?: string | null
+ media?: TweetMedia[]
+ visible?: boolean
+}
+
+interface TweetsData {
+ total_positive_mentions: number
+ date_range: string
+ scoring: string
+ note: string
+ tweets: Tweet[]
+}
+
+// Tweets manifest is authored in mono at content/_system/generated/testimonials.json
+// and ships to peanut-ui through the peanut-content mirror. Same shape as before.
+import tweetsJson from '@/content/generated/testimonials.json'
+
+const MAX_TWEETS = 100
+const MAX_PER_AUTHOR = 3
+
+const tweetsData = tweetsJson as TweetsData
+
+/** All tweets from dataset, sorted by impact_score desc */
+export const ALL_TWEETS: Tweet[] = tweetsData.tweets
+
+/** Filtered tweets for carousel: top 100, max 3 per author */
+export const TWEETS: Tweet[] = (() => {
+ const authorCount = new Map()
+ const filtered: Tweet[] = []
+
+ for (const tweet of ALL_TWEETS.filter((t) => t.visible !== false)) {
+ const count = authorCount.get(tweet.handle) || 0
+ if (count >= MAX_PER_AUTHOR) continue
+
+ authorCount.set(tweet.handle, count + 1)
+ filtered.push(tweet)
+
+ if (filtered.length >= MAX_TWEETS) break
+ }
+
+ return filtered
+})()
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 8c5a1442df..a48ead70b7 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -107,53 +107,23 @@
"landingDropLinkBody": "Chat or IRL. Works on WhatsApp, SMS, QR codes. No bank details, they don't even need to sign up or install Peanut.",
"landingSendNow": "SEND NOW",
"landingSignUpNow": "SIGN UP NOW",
- "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
- "landingCardBeatBridgeLink": "The app is live today",
- "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
- "landingCardBeatKicker": "Shhhhh · Closed beta",
- "landingCardBeatHeading": "The card is out.",
- "landingCardBeatTagline": "For you. Maybe.",
- "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
- "landingCardBeatCounterLabel": "only {count}",
- "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
- "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
- "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
- "landingCardBeatWaitlistLink": "or join the card waitlist →",
- "landingCardBeatStatMerchants": "Visa-accepting merchants",
- "landingCardBeatStatBalance": "balance",
- "landingCardBeatStatCard": "card",
- "landingCardBeatStatMonthlyFees": "monthly fees",
- "landingTryTheDoor": "TRY THE DOOR",
- "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
- "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingCurrencyPlaceholder": "Currency or country",
+ "landingCardHeading": "PAY EVERYWHERE.",
+ "landingCardBody": "Get the Peanut Card and pay anywhere in the world.",
+ "landingCardBullet1": "No monthly fees. No annual fees.",
+ "landingCardBullet2": "Earn forever for every invite",
+ "landingCardBullet3": "Non-custodial - your funds, your control",
+ "landingCardDisclaimer": "Standard fees and limits apply per the cardholder terms.",
+ "landingCardCta": "GET MY CARD",
+ "landingWallOfLove": "WALL OF LOVE",
+ "landingWallOfLoveBody": "See what people are saying about Peanut on",
"landingProblemHeading": "You already know the problem.",
- "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
- "landingProblemPointerPassport": "How the passport check works",
- "landingProblemPointerRate": "What the rate looks like",
- "landingProblemPointerMoneyOut": "How money gets out",
- "landingWorksTodayHeading": "What works today.",
- "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
- "landingWorksTodayPayLocalTitle": "Pay like a local",
- "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
- "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
- "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
- "landingWorksTodayDropLinkTitle": "As easy as a text",
- "landingWorksTodayRateTitle": "See the rate first",
- "landingWorksTodaySecurityTitle": "Total security",
- "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
- "landingNotForYouHeading": "Probably not for you.",
- "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
- "landingNotForYouSignUpLink": "or sign up →",
- "landingMarqueeClosedBeta": "Closed beta",
- "landingMarqueeShhhh": "Shhhh",
- "landingMarqueeWordTravels": "Word travels",
- "landingMarqueeInstant": "Instant",
- "landingMarquee247": "24/7",
- "landingMarqueeUsd": "USD",
- "landingMarqueeEur": "EUR",
- "landingMarqueeStablecoins": "USDT/USDC",
- "landingMarqueeGlobal": "Global",
- "landingMarqueeSelfCustodial": "Self-custodial",
+ "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 a3bec81ebd..1f6c703b66 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -107,53 +107,23 @@
"landingDropLinkBody": "Por chat o en persona. Funciona en WhatsApp, SMS y códigos QR. Sin datos bancarios, y no necesitan registrarse ni instalar Peanut.",
"landingSendNow": "ENVIAR AHORA",
"landingSignUpNow": "CREAR CUENTA YA",
- "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
- "landingCardBeatBridgeLink": "The app is live today",
- "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
- "landingCardBeatKicker": "Shhhhh · Closed beta",
- "landingCardBeatHeading": "The card is out.",
- "landingCardBeatTagline": "For you. Maybe.",
- "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
- "landingCardBeatCounterLabel": "only {count}",
- "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
- "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
- "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
- "landingCardBeatWaitlistLink": "or join the card waitlist →",
- "landingCardBeatStatMerchants": "Visa-accepting merchants",
- "landingCardBeatStatBalance": "balance",
- "landingCardBeatStatCard": "card",
- "landingCardBeatStatMonthlyFees": "monthly fees",
- "landingTryTheDoor": "TRY THE DOOR",
- "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
- "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingCurrencyPlaceholder": "Moneda o país",
+ "landingCardHeading": "PAGA EN TODAS PARTES.",
+ "landingCardBody": "Pide la Peanut Card y paga en cualquier parte del mundo.",
+ "landingCardBullet1": "Sin cuota mensual. Sin cuota anual.",
+ "landingCardBullet2": "Gana para siempre por cada invitación",
+ "landingCardBullet3": "No custodial: tus fondos, tu control",
+ "landingCardDisclaimer": "Se aplican las comisiones y los límites estándar según los términos del titular de la tarjeta.",
+ "landingCardCta": "QUIERO MI TARJETA",
+ "landingWallOfLove": "MURO DE AMOR",
+ "landingWallOfLoveBody": "Mira lo que dicen de Peanut en",
"landingProblemHeading": "You already know the problem.",
- "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
- "landingProblemPointerPassport": "How the passport check works",
- "landingProblemPointerRate": "What the rate looks like",
- "landingProblemPointerMoneyOut": "How money gets out",
- "landingWorksTodayHeading": "What works today.",
- "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
- "landingWorksTodayPayLocalTitle": "Pay like a local",
- "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
- "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
- "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
- "landingWorksTodayDropLinkTitle": "As easy as a text",
- "landingWorksTodayRateTitle": "See the rate first",
- "landingWorksTodaySecurityTitle": "Total security",
- "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
- "landingNotForYouHeading": "Probably not for you.",
- "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
- "landingNotForYouSignUpLink": "or sign up →",
- "landingMarqueeClosedBeta": "Closed beta",
- "landingMarqueeShhhh": "Shhhh",
- "landingMarqueeWordTravels": "Word travels",
- "landingMarqueeInstant": "Instant",
- "landingMarquee247": "24/7",
- "landingMarqueeUsd": "USD",
- "landingMarqueeEur": "EUR",
- "landingMarqueeStablecoins": "USDT/USDC",
- "landingMarqueeGlobal": "Global",
- "landingMarqueeSelfCustodial": "Self-custodial",
+ "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 d01f42bd73..fada53b9f8 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -107,53 +107,23 @@
"landingDropLinkBody": "Por chat o en persona. Funciona en WhatsApp, SMS y códigos QR. Sin datos bancarios, y no necesitan registrarse ni instalar Peanut.",
"landingSendNow": "ENVIÁ AHORA",
"landingSignUpNow": "CREÁ TU CUENTA YA",
- "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
- "landingCardBeatBridgeLink": "The app is live today",
- "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
- "landingCardBeatKicker": "Shhhhh · Closed beta",
- "landingCardBeatHeading": "The card is out.",
- "landingCardBeatTagline": "For you. Maybe.",
- "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
- "landingCardBeatCounterLabel": "only {count}",
- "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
- "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
- "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
- "landingCardBeatWaitlistLink": "or join the card waitlist →",
- "landingCardBeatStatMerchants": "Visa-accepting merchants",
- "landingCardBeatStatBalance": "balance",
- "landingCardBeatStatCard": "card",
- "landingCardBeatStatMonthlyFees": "monthly fees",
- "landingTryTheDoor": "TRY THE DOOR",
- "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
- "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingCurrencyPlaceholder": "Moneda o país",
+ "landingCardHeading": "PAGÁ EN TODAS PARTES.",
+ "landingCardBody": "Pedí la Peanut Card y pagá en cualquier parte del mundo.",
+ "landingCardBullet1": "Sin cuota mensual. Sin cuota anual.",
+ "landingCardBullet2": "Ganá para siempre por cada invitación",
+ "landingCardBullet3": "No custodial: tus fondos, tu control",
+ "landingCardDisclaimer": "Se aplican las comisiones y los límites estándar según los términos del titular de la tarjeta.",
+ "landingCardCta": "QUIERO MI TARJETA",
+ "landingWallOfLove": "MURO DE AMOR",
+ "landingWallOfLoveBody": "Mirá lo que dicen de Peanut en",
"landingProblemHeading": "You already know the problem.",
- "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
- "landingProblemPointerPassport": "How the passport check works",
- "landingProblemPointerRate": "What the rate looks like",
- "landingProblemPointerMoneyOut": "How money gets out",
- "landingWorksTodayHeading": "What works today.",
- "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
- "landingWorksTodayPayLocalTitle": "Pay like a local",
- "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
- "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
- "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
- "landingWorksTodayDropLinkTitle": "As easy as a text",
- "landingWorksTodayRateTitle": "See the rate first",
- "landingWorksTodaySecurityTitle": "Total security",
- "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
- "landingNotForYouHeading": "Probably not for you.",
- "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
- "landingNotForYouSignUpLink": "or sign up →",
- "landingMarqueeClosedBeta": "Closed beta",
- "landingMarqueeShhhh": "Shhhh",
- "landingMarqueeWordTravels": "Word travels",
- "landingMarqueeInstant": "Instant",
- "landingMarquee247": "24/7",
- "landingMarqueeUsd": "USD",
- "landingMarqueeEur": "EUR",
- "landingMarqueeStablecoins": "USDT/USDC",
- "landingMarqueeGlobal": "Global",
- "landingMarqueeSelfCustodial": "Self-custodial",
+ "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 4bfff929c9..41e2fff19f 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -107,53 +107,23 @@
"landingDropLinkBody": "Por chat ou pessoalmente. Funciona no WhatsApp, SMS e códigos QR. Sem dados bancários, e a pessoa nem precisa se cadastrar ou instalar o Peanut.",
"landingSendNow": "ENVIAR AGORA",
"landingSignUpNow": "CRIAR CONTA AGORA",
- "landingCardBeatBridgeBefore": "Peanut is the easiest way to send digital dollars to anyone, anywhere.",
- "landingCardBeatBridgeLink": "The app is live today",
- "landingCardBeatBridgeAfter": "— the card is the part we're still letting people into.",
- "landingCardBeatKicker": "Shhhhh · Closed beta",
- "landingCardBeatHeading": "The card is out.",
- "landingCardBeatTagline": "For you. Maybe.",
- "landingCardBeatBodyBefore": "We're letting beta users in slowly —",
- "landingCardBeatCounterLabel": "only {count}",
- "landingCardBeatBodyAfter": "a week. If you've got the right badge, you skip the line entirely.",
- "landingCardBeatCustody": "Your balance is yours. Non-custodial.",
- "landingCardBeatTrust": "Passkey-approved · 24/7 real humans",
- "landingCardBeatWaitlistLink": "or join the card waitlist →",
- "landingCardBeatStatMerchants": "Visa-accepting merchants",
- "landingCardBeatStatBalance": "balance",
- "landingCardBeatStatCard": "card",
- "landingCardBeatStatMonthlyFees": "monthly fees",
- "landingTryTheDoor": "TRY THE DOOR",
- "landingManifestoHeading": "If your life spans more than one country, peanut is for you.",
- "landingManifestoSubline": "We built peanut for people who don't fit.",
+ "landingCurrencyPlaceholder": "Moeda ou país",
+ "landingCardHeading": "PAGUE EM QUALQUER LUGAR.",
+ "landingCardBody": "Peça o Peanut Card e pague em qualquer lugar do mundo.",
+ "landingCardBullet1": "Sem taxa mensal. Sem anuidade.",
+ "landingCardBullet2": "Ganhe para sempre por cada convite",
+ "landingCardBullet3": "Não custodial: seus fundos, seu controle",
+ "landingCardDisclaimer": "Aplicam-se as taxas e os limites padrão conforme os termos do titular do cartão.",
+ "landingCardCta": "QUERO MEU CARTÃO",
+ "landingWallOfLove": "MURAL DE AMOR",
+ "landingWallOfLoveBody": "Veja o que as pessoas dizem do Peanut no",
"landingProblemHeading": "You already know the problem.",
- "landingProblemProse": "Digital nomads who get bounced by every neobank's verification the moment they cross a border. Expats who pay 4% on every transaction home. Cross-border users who earn in one currency and spend in another.",
- "landingProblemPointerPassport": "How the passport check works",
- "landingProblemPointerRate": "What the rate looks like",
- "landingProblemPointerMoneyOut": "How money gets out",
- "landingWorksTodayHeading": "What works today.",
- "landingWorksTodaySubline": "0% transfer fees — the rate is shown before you send.",
- "landingWorksTodayPayLocalTitle": "Pay like a local",
- "landingWorksTodayMoneyOut": "Money out too: withdraw to PIX or your bank.",
- "landingWorksTodayChipEurPix": "EUR · EU → PIX · BR",
- "landingWorksTodayChipUsdMercadoPago": "USD → MercadoPago · AR",
- "landingWorksTodayDropLinkTitle": "As easy as a text",
- "landingWorksTodayRateTitle": "See the rate first",
- "landingWorksTodaySecurityTitle": "Total security",
- "landingWorksTodaySecurityBody": "Every transaction is approved via Passkeys using Face ID, Touch ID or passcode.",
- "landingNotForYouHeading": "Probably not for you.",
- "landingNotForYouBody": "If your money lives in one country and one currency, your bank card already works. Keep it.",
- "landingNotForYouSignUpLink": "or sign up →",
- "landingMarqueeClosedBeta": "Closed beta",
- "landingMarqueeShhhh": "Shhhh",
- "landingMarqueeWordTravels": "Word travels",
- "landingMarqueeInstant": "Instant",
- "landingMarquee247": "24/7",
- "landingMarqueeUsd": "USD",
- "landingMarqueeEur": "EUR",
- "landingMarqueeStablecoins": "USDT/USDC",
- "landingMarqueeGlobal": "Global",
- "landingMarqueeSelfCustodial": "Self-custodial",
+ "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 160972c94c..a197547533 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -134,67 +134,29 @@ export interface Translations {
// Landing page — CTAs and inputs
landingSendNow: string
landingSignUpNow: string
-
- // Landing page — card beat (closed-beta card, /shhhhh door)
- // The bridge line and the body wrap inline elements (a /setup link, the
- // scarcity counter), so each is split around the element it wraps.
- landingCardBeatBridgeBefore: string
- landingCardBeatBridgeLink: string
- landingCardBeatBridgeAfter: string
- landingCardBeatKicker: string
- landingCardBeatHeading: string
- landingCardBeatTagline: string
- landingCardBeatBodyBefore: string
- landingCardBeatCounterLabel: string // "only {count}"
- landingCardBeatBodyAfter: string
- landingCardBeatCustody: string
- landingCardBeatTrust: string
- landingCardBeatWaitlistLink: string
- landingCardBeatStatMerchants: string
- landingCardBeatStatBalance: string
- landingCardBeatStatCard: string
- landingCardBeatStatMonthlyFees: string
- landingTryTheDoor: string // shared by the card beat, the ending beat and the sticky bar
-
- // Landing page — manifesto beat
- landingManifestoHeading: string
- landingManifestoSubline: string
-
- // Landing page — problem beat (the pointer arrow is drawn by the component)
+ landingCurrencyPlaceholder: string
+
+ // Landing page — Peanut Card section
+ landingCardHeading: string
+ landingCardBody: string
+ landingCardBullet1: string
+ landingCardBullet2: string
+ landingCardBullet3: string
+ landingCardDisclaimer: string
+ landingCardCta: string
+
+ // Landing page — testimonials
+ landingWallOfLove: string
+ landingWallOfLoveBody: string
+
+ // Landing page — problem fold (three named cards, icons drawn by the component)
landingProblemHeading: string
- landingProblemProse: string
- landingProblemPointerPassport: string
- landingProblemPointerRate: string
- landingProblemPointerMoneyOut: string
-
- // Landing page — what-works-today beat
- landingWorksTodayHeading: string
- landingWorksTodaySubline: string
- landingWorksTodayPayLocalTitle: string
- landingWorksTodayMoneyOut: string
- landingWorksTodayChipEurPix: string
- landingWorksTodayChipUsdMercadoPago: string
- landingWorksTodayDropLinkTitle: string
- landingWorksTodayRateTitle: string
- landingWorksTodaySecurityTitle: string
- landingWorksTodaySecurityBody: string
-
- // Landing page — ending beat
- landingNotForYouHeading: string
- landingNotForYouBody: string
- landingNotForYouSignUpLink: string
-
- // Landing page — marquee strips
- landingMarqueeClosedBeta: string
- landingMarqueeShhhh: string
- landingMarqueeWordTravels: string
- landingMarqueeInstant: string
- landingMarquee247: string
- landingMarqueeUsd: string
- landingMarqueeEur: string
- landingMarqueeStablecoins: string
- landingMarqueeGlobal: string
- landingMarqueeSelfCustodial: 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
diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts
index c8ea1f99bd..95c77a8539 100644
--- a/src/lib/landingContent.ts
+++ b/src/lib/landingContent.ts
@@ -16,6 +16,7 @@ interface LandingFrontmatter {
hero?: {
primary_cta?: { label?: string; href?: string; subtext?: string }
}
+ marquee?: string[]
faqs?: {
heading?: string
questions?: Array<{ id: string; question: string; answer: string }>
@@ -32,6 +33,7 @@ export interface LandingContent {
questions: Array<{ id: string; question: string; answer: string }>
marquee: { visible: boolean; message: string }
}
+ marqueeMessages: string[]
}
// Fallback used if the singleton MD is missing (e.g. submodule hasn't synced).
@@ -40,6 +42,7 @@ export interface LandingContent {
const DEFAULTS: LandingContent = {
heroConfig: { primaryCta: { label: 'SIGN UP', href: '/setup' } },
faqData: { heading: 'FAQ.', questions: [], marquee: { visible: false, message: 'Peanut' } },
+ marqueeMessages: [],
}
// Code-defined FAQ item advertising supported networks/tokens/bank rails.
@@ -94,5 +97,6 @@ function readLandingContent(locale: Locale): LandingContent {
? fm.faqs.marquee
: DEFAULTS.faqData.marquee,
},
+ marqueeMessages: (fm.marquee ?? DEFAULTS.marqueeMessages).filter((m): m is string => typeof m === 'string'),
}
}
From 26fb7ddc4ece22dae4eb76e7dce5229cb818a2be Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 13:57:32 +0000
Subject: [PATCH 04/17] fix(landing): make the door fold's card tilt actually
apply
A transform is ignored on a non-replaced inline element, and next/link
renders an inline anchor, so the card sat straight instead of at -12deg.
---
src/components/LandingPage/ShhhhhFold.tsx | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx
index 64f039de04..cabdc34328 100644
--- a/src/components/LandingPage/ShhhhhFold.tsx
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -14,9 +14,10 @@ const ScaledPixelatedCardFace = dynamic(
{ ssr: false }
)
-// On a black ground the card's black drop shadow and the button's black shadow
-// both disappear, so both carry the brand pink here instead. Deliberate
-// deviation from the all-black-shadow rule — it only applies on this fold.
+// 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'
/**
@@ -70,7 +71,7 @@ export function ShhhhhFold() {
{/* 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. */}
-
+
From 54c1c97f97f989a9fd04f35a36728453bbda665b Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 15:07:49 +0000
Subject: [PATCH 05/17] feat(landing): put brand marks on the problem cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The three placeholder line icons were generic stock shapes that belonged to
no other Peanut surface. Each card now carries art the site already uses, so
the fold reads as ours.
"Living across borders" takes the marquee's thumbs-up turned over — the
verdict every neobank hands these people. "Sending money home" is a Knerd
percent, outline rather than filled: at this size the filled cut closes its
counters and reads as a blob. "Paid from abroad" is the pinch hand cut out of
the no-hidden-fees art into its own asset, so the card pulls 11KB of paths
instead of the whole 34KB banner.
One 44px-tall slot holds all three, because the glyph and the drawings need
different type sizes to carry the same optical weight.
---
.../illustrations/no-hidden-fees-hand.svg | 48 +++++++++++++++++
src/components/LandingPage/ProblemFold.tsx | 54 ++++++-------------
2 files changed, 64 insertions(+), 38 deletions(-)
create mode 100644 src/assets/illustrations/no-hidden-fees-hand.svg
diff --git a/src/assets/illustrations/no-hidden-fees-hand.svg b/src/assets/illustrations/no-hidden-fees-hand.svg
new file mode 100644
index 0000000000..4b378ff03a
--- /dev/null
+++ b/src/assets/illustrations/no-hidden-fees-hand.svg
@@ -0,0 +1,48 @@
+
diff --git a/src/components/LandingPage/ProblemFold.tsx b/src/components/LandingPage/ProblemFold.tsx
index 826e9118c2..0d1d58059a 100644
--- a/src/components/LandingPage/ProblemFold.tsx
+++ b/src/components/LandingPage/ProblemFold.tsx
@@ -1,7 +1,15 @@
+import Image from 'next/image'
+import HandThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg'
+import NoHiddenFeesHand from '@/assets/illustrations/no-hidden-fees-hand.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]']
@@ -18,53 +26,25 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) {
id: 'cross-border',
title: problem.crossBorderTitle,
body: problem.crossBorderBody,
- icon: (
- <>
-
-
-
- >
- ),
+ // the marquee's thumbs-up, turned over: the verification verdict
+ icon: ,
},
{
id: 'send-home',
title: problem.sendHomeTitle,
body: problem.sendHomeBody,
icon: (
- <>
-
-
-
- >
+
+ %
+
),
},
{
id: 'paid-abroad',
title: problem.paidAbroadTitle,
body: problem.paidAbroadBody,
- icon: (
- <>
-
-
- >
- ),
+ // the pinch hand from the no-hidden-fees art
+ icon: ,
},
]
@@ -85,9 +65,7 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) {
key={card.id}
className={`shadow-4 rounded-sm border-2 border-n-1 bg-white px-5 py-6 md:px-7 md:py-7 ${tilts[i]}`}
>
-
+
{card.icon}
{card.title}
From 9e1d308af13aa75450146e1f6b203c7aa793b59b Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 15:08:05 +0000
Subject: [PATCH 06/17] feat(landing): send the homepage's own words somewhere
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The landing page names cities and repeats product claims that all have a real
article behind them, and none of it was reachable. This links the ones with a
genuine destination and leaves the rest alone — "Instant" has no article, so
it stays plain text.
MarqueeComp now takes {label, href} items alongside plain strings, so a strip
can link some words and not others. Every existing caller passes strings and
is untouched. The strip under the door fold links all five words to /shhhhh,
since the whole strip is the door. Marquee thumbs lose their "Marquee Image"
alt: now that the strips carry links, a screen reader would read that filler
between every one.
The city line stays one translated string per locale. A helper splits it on
the city names — aliases cover "New York", "Nueva York" and "Nova York" — and
wraps the matches. A locale whose wording drops a city loses that link and
keeps its sentence, which is why the helper matches rather than interpolates.
---
.../Global/MarqueeWrapper/index.tsx | 25 ++++++--
.../Global/MarqueeWrapper/marquee.types.ts | 10 +++
.../LandingPage/LandingPageClient.tsx | 23 ++++++-
.../__tests__/landingLinks.utils.test.ts | 61 +++++++++++++++++++
.../LandingPage/landingLinks.utils.ts | 49 +++++++++++++++
src/components/LandingPage/marquee.tsx | 4 +-
src/components/LandingPage/yourMoney.tsx | 22 ++++++-
7 files changed, 185 insertions(+), 9 deletions(-)
create mode 100644 src/components/Global/MarqueeWrapper/marquee.types.ts
create mode 100644 src/components/LandingPage/__tests__/landingLinks.utils.test.ts
create mode 100644 src/components/LandingPage/landingLinks.utils.ts
diff --git a/src/components/Global/MarqueeWrapper/index.tsx b/src/components/Global/MarqueeWrapper/index.tsx
index d2d3eb7207..5d64848662 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
{
+ 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]
)
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/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/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/yourMoney.tsx b/src/components/LandingPage/yourMoney.tsx
index 6dca98519d..db3abf4e15 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 }) {
From 2f60cbf395e675c310597ec083893ec894192744 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 15:43:44 +0000
Subject: [PATCH 07/17] feat(landing): swap in Konrad's card art, cut the door
fold's body
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Konrad drew a peace hand and a coin stack for the problem cards, so the
placeholder marks go: the turned-over thumbs-up and the pinch hand cropped
out of the no-hidden-fees banner last round. That crop had exactly one
consumer, so the asset goes with it. Card 2 keeps the Knerd percent, and all
three still share one 44px slot so the card tops line up.
The door fold loses its body paragraph. It was the /shhhhh pitch repeated a
click early, scarcity count and all — the fold only has to make the offer.
Wordmark, tagline, door, card. The shared ScarcityCounter stays; /shhhhh is
still its caller.
---
src/assets/illustrations/hands-peace.png | Bin 0 -> 6959 bytes
.../illustrations/no-hidden-fees-hand.svg | 48 ------------------
src/assets/illustrations/stack-coin.png | Bin 0 -> 6484 bytes
src/components/LandingPage/ProblemFold.tsx | 10 ++--
src/components/LandingPage/ShhhhhFold.tsx | 13 ++---
5 files changed, 7 insertions(+), 64 deletions(-)
create mode 100644 src/assets/illustrations/hands-peace.png
delete mode 100644 src/assets/illustrations/no-hidden-fees-hand.svg
create mode 100644 src/assets/illustrations/stack-coin.png
diff --git a/src/assets/illustrations/hands-peace.png b/src/assets/illustrations/hands-peace.png
new file mode 100644
index 0000000000000000000000000000000000000000..58e62fd147a74edc532f8c44c5006baae31f8e46
GIT binary patch
literal 6959
zcmV+~8_?v5P)BxfL1_x1%Or{aRq=fJED|1!p&1On5w>259WQIHf0i*xqMSyU0q%M
z0R3Xrw8u?*vTYA{nrFFfpPM#s+U@q|z(8^+#8K03ns(Q;m9p`@lcv4Ez(4{h)GLIE
z^iW$g?HmIG5lAW2LAV1^d5Jtiy-&Cf0|Om#O`9RVQ1^Y@a|{&c0c8ZI;_$*+5oTMvgne>FRbJynh0BX;MUY+E6WAqpRAqq^8<=vI$$|)_H|Zg4
z;ZQcJa)X4{)=HGXHSGf;-~5LUAHq4e{^|Mn@gqKe{)~t)YA}WkXz`N@rX$Oy{ehn5
zXi!bGR8gMr(P*@~zP?^97K_y(__xVqvU>95NsRBjgAFTtjxrXttzDsqOc6<_v)ODV
zh3{#saOn4sQI;#%5NPj3dx5|ULa}Lty3f_sRmgFaw6a&QC(>Su)*;Xpc=6)J>hA7t
z6^lU8j**`$*cIu1t04^73S)Qb3qWzDAV9P;=u#p*P=&Qy3L1`S9U`_J-u>_8cNdRd`J~4%m&X(ADDa7;%XdP4R7p%5YGgEPd8o
zD6Q-S>6t-!{jeug$zYYQJwHE>CE776VhY7cG8)hpTa68Gkm5KONXr!z*KYqhhD23v
zZf;_P0jqHh9F!+HolaLdVVBsbhr?PEl+Q^hf?+sl_PSGfoXeO*Jq56FXq;zl+;
z-@%Cf*nCY)19Ft%DmZ_$+iJ)*C?W$CYUx3$M`>_emlvfLEd5vU
z+I{5{+84%9(9G?d2?0KS{1^%zb$L;0!K%cXGaO?OagRiy(jBk@2Yw1%mlv56tny_0
z!UYszF-z8!_<9gRT$dN67OaDiXV1h2rca1{S7OP!dQ$b0sRb)`W=?o2)=LMk)>a||xyTVE=!`Y7{`ljM$jD3K>rXN{D5YQ>gjkCXoSmIP
zf#xLmq&f((=)0!Ilwf;eEi{4^y92ydj1HU{xzY6=Cn!k);wg&Q;=A3GDOu@B^wJ$rtk$Scu+~sM
zK+BRrFK@QvpKaX3qv9Gw+zEu~=5&13rj?OgR(nGN+Js@HkGxcUMh{whpsP7Em-m6){}vkJMwq2sjOwKo6;rt)b)j?
zTmqu(Qfi}@69z;nSTioc5<-+GoQy;k_SoOQqXz$#_fFx`%_T2RE!a7PSQD&LDDKGL
zyUvDvr3RPa)2Gfo`z}k#iw6sPghZ<#oaDuPXlk&q^Z6VKL_T1Si35=W*`-(-cv)}>
zYKah}_FqX3!HP#wfBp3rtdJl9rngIn`XX5ISy*!m6tv|NI0&i1!YT(`ywRdJ_g?>?tjBD;QC6gY%TGV^#1*OSRr!h$hN=T
z%cDo)C|Gus6FzeY$t75^`YgExO2N1Tzwe!erSGcDfi>w8kU50Bd-o1j$XLa&N^<$H
zB&SOt&fKzMWg`!~9P)gBHk4sVl@F0iuqyL!#gA3G1V7_MP+C}37n*a4jQBCM;ua`f
z0(ao|;<;Yy7FO94nsy0@SbcnQErhp}seOlp*(={RK&;|f29i^-ODK|I-oAZ{(kMsi
zPjCV#-8S`9X6{w9Vzd
zxdl6i!gsOY7Np!iwGGRHr7=+?yett=*|J0-4YB$xL<^L{sbe9Q5~cq-M*t-RtBMws
z_GOi!rC!HkmG@+h07?jU4uxA#I;lHDt=Q^N3N~mqs8nq&v0E^mPKy@VP{v{%dWur4
zO9WPCFC|#9TcFGciiXm{2K%s93*;$#yg33XCD=I>Zo#WpuTYW_p)IR|&6z*2OUZJZ
zZy-b&Mv2DY7HkN}qzf+VQ&OW60Xeg?_9qvV6izSXo-w$SY{8BVcwb%2Q`yQ;sPVluC$E)+lg0|G$I~
zOBS|NbiiXlul9g?b#4`fCi6wklrC@g>1=U6g
z0+uYz5eX}TokMXWON6*0L<+;-XKLJH(~*WBr?|6l}n9n5ZdsruLeJ
zax|Gci|Z5xo3k8I8`cCnhq3^2PAjYQWBr?|6s*!6xItvB36`kLWlk%r+y;JxN(so{
zuu&W>1GA>Bq-{2vt#Z)}yjF^1^aC%^`;`HXXZ*RlyR~j6g0c
zdny*TbUNWa|AR943??7yaeIHigK{~Auf>(RrfNWFOFZjPlG5pfrQl7vXxgM{3n&Iu
zl6G-%vC07lLyF_$b(l%s!vS6?&d<*^=Xi|@p-5jsIpPm#yuo*OcdOJ~US4XBZ=$&-
zrD%F8%{h)x5e}OtjNSUGR2z@StJE-zrZ_fUmznf^C0HyL+Us8h4x7>a%0kOwW%>Ea
zc&zwgF2Q6n(VW{135gLUVAuZ|8{hOCM#zzR$gC~p;H!#(mxXk~eL@BM0vn-T!XP
EALP%Y;ukU(kbUy2Ph-)5zoWIf6xL^&6UQI!A%k0P-u;!
zAHE}Dz(DA6uAMg-@%i}9x|OXp_5HBC4`cEIkxGXw2U!uq0iOZ0umjDI90)_x^;OQ2
z&FDwrJ_O`3(xVIm4ukgS=*Zb1!b9KR-*;&u4e0HBupZ~{sPztUx=`2+0O>sk7j_-r>GS6(VoAM-GAt1Imyny2@a;p!5A5
zQ1xgK8129o5JnwR`m8wQzE@d-fr%xfX3(uio=gN(?>**Y;*~cICGT5QZ~-Q$E~Zf6
zz3?yyC|0-A8Z{kRl9&jGlFxv4!vdLW2ORH*wG9Y@L-TP+-dxIs4%I_zU~tVn4cVS&2;s(^y7hpo7|;pf!&HIlMR8GkUb$W4&_NhZ_U-a
z?lv|yZhm)pc^SQ8#~XI&;gx&buLZ}dJ)#xZF;^7=s)k8rVxjy;i_vG)I|`_Jz!`Z;@nn&kV`9P!MRo#OTy|$N_gW>;*Pd$oR1HQH
zmz)`q$5S!^l^0xqN!la7*5Iiq0TokCen_z(1XN+X`Q(`=q&P=xi?T(-;1j@qx}bP{
u;hakJ6C`+{@R)#O9uVKm(ZVpHn(}uK_t|&QwQ5BG0000,
+ icon: ,
},
{
id: 'send-home',
@@ -43,8 +42,7 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) {
id: 'paid-abroad',
title: problem.paidAbroadTitle,
body: problem.paidAbroadBody,
- // the pinch hand from the no-hidden-fees art
- icon: ,
+ icon: ,
},
]
diff --git a/src/components/LandingPage/ShhhhhFold.tsx b/src/components/LandingPage/ShhhhhFold.tsx
index cabdc34328..932b912288 100644
--- a/src/components/LandingPage/ShhhhhFold.tsx
+++ b/src/components/LandingPage/ShhhhhFold.tsx
@@ -4,7 +4,6 @@ import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/0_Bruddle/Button'
-import { ScarcityCounter } from './ScarcityCounter'
// 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
@@ -21,9 +20,9 @@ const ScaledPixelatedCardFace = dynamic(
const PINK = '#FF90E8'
/**
- * The homepage door to /shhhhh. Distils §1 of the /shhhhh page — same catalog,
- * same counter — and every click here goes to /shhhhh. The fold makes the
- * offer; the page does the work (sign-in, badge check, waitlist).
+ * 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')
@@ -43,12 +42,6 @@ export function ShhhhhFold() {
{t('tagline')}
{/* no shadowSize: that prop only paints black. The pink
From c73c6d41a609d9b52733f2e490d0186bf59e004b Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 15:44:05 +0000
Subject: [PATCH 08/17] feat(landing): link the partner logos, calm the link
styling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The works-with strip named nine partners and answered nothing about any of
them. Each logo now opens the page that honestly explains what it does with
Peanut: Wise and Revolut their compare pages, Mercado Pago the QR explainer,
PIX the Brazil page, and the SEPA/ACH banks the bank-deposit article. Brubank
and Stripe stay unlinked — no article covers their rail, and a logo that
lands somewhere vague is worse than one that does nothing.
Mercado Pago and PIX are the only two marks in the set that aren't a heavy
wordmark, and both were disappearing into the pink tile, so those two get a
white card behind them.
Round-2 links also stop underlining at rest. Eight underlined words in a
scrolling strip read as clutter; the underline now waits for the cursor.
---
.../Global/MarqueeWrapper/index.tsx | 2 +-
src/components/LandingPage/RegulatedRails.tsx | 56 +++++++++++++------
src/components/LandingPage/yourMoney.tsx | 2 +-
3 files changed, 42 insertions(+), 18 deletions(-)
diff --git a/src/components/Global/MarqueeWrapper/index.tsx b/src/components/Global/MarqueeWrapper/index.tsx
index 5d64848662..dcc2d64ccf 100644
--- a/src/components/Global/MarqueeWrapper/index.tsx
+++ b/src/components/Global/MarqueeWrapper/index.tsx
@@ -41,7 +41,7 @@ const wordClass = 'text-lg font-bold uppercase md:text-xl'
function MarqueeWord({ item }: { item: MarqueeItem }) {
if (typeof item === 'string') return
{item}
return (
-
+
{item.label}
)
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index 168d0a21d4..e6f12643be 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,29 @@ 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' },
]
+const tileClass = 'btn btn-purple btn-shadow-primary-4 mx-7 mb-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 +70,7 @@ export function RegulatedRails({ locale = DEFAULT_LOCALE }: { locale?: Locale })
{bodyParts.map((part, index) =>
part.href ? (
-
+
{part.text}
) : (
From 3b2ae4e9784257c229e70b722db629eec40109c2 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 15:44:05 +0000
Subject: [PATCH 09/17] feat(landing): continue each FAQ into its article,
right-align learn more
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Six of the seven landing FAQs have a help article that answers the question
properly, and none of them said so. The FAQ panel takes an optional
learnMoreHref per question and the landing page supplies it from a map keyed
on the content system's question ids — the content file stays free of code
concerns, and a question with no honest article simply has no entry. "Why
Peanut?" has none; "My question is not here" already links the help centre.
Both other FAQsPanel callers pass nothing and are unchanged.
Konrad asked for the learn-more links right-aligned, remembering they used to
be. Git says otherwise: neither has ever carried an alignment class. The
securityBuiltIn one only looks centred on a phone because it inherits
text-center from the feature card while the paragraph beside it opts out with
text-left. Right-aligning all three is the change he actually wants; there was
nothing to revert.
The 24/7 card also drops "real humans" — we shouldn't promise a person picks
up. Spanish and Portuguese first sentences follow; second sentences untouched.
---
src/components/Global/FAQs/index.tsx | 17 +++++++++-
.../LandingPage/LandingPageClient.tsx | 34 ++++++++++++++-----
src/components/LandingPage/faq.tsx | 4 +--
src/components/LandingPage/landingStrings.ts | 2 ++
.../LandingPage/securityBuiltIn.tsx | 16 +++++----
src/i18n/en.json | 2 +-
src/i18n/es-419.json | 2 +-
src/i18n/es-ar.json | 2 +-
src/i18n/pt-br.json | 2 +-
9 files changed, 60 insertions(+), 21 deletions(-)
diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx
index a217325541..842b235687 100644
--- a/src/components/Global/FAQs/index.tsx
+++ b/src/components/Global/FAQs/index.tsx
@@ -4,6 +4,8 @@ import type { ReactNode } from 'react'
export type FAQsProps = {
heading: string
+ /** Translated label for the per-question "learn more" link. */
+ learnMoreLabel?: string
questions: Array<{
id: string
question: string
@@ -13,9 +15,15 @@ export type FAQsProps = {
redirectUrl?: string
redirectText?: string
calModal?: boolean
+ /** Article that answers this question in full. Renders a "learn more" link under the answer. */
+ learnMoreHref?: string
}>
}
+// 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 (
))}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index b32b15d93f..09cfff140c 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -98,13 +98,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)
@@ -323,7 +336,12 @@ export function LandingPageClient({
-
+
{marquee.visible && (
{feature.learnMoreHref && (
-
- {i18n.landingLearnMore} →
-
+ // max-w matches the paragraph above, so the link
+ // lands on that right edge instead of the wider cell
+
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a48ead70b7..93779d0d56 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -80,7 +80,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.",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 1f6c703b66..24d0c579db 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -80,7 +80,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.",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index fada53b9f8..c114228ab4 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -80,7 +80,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.",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index 41e2fff19f..ac65b5aa36 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -80,7 +80,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.",
From ad7a9e46b92a9b49121e8ccf9052997871f08237 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 16:12:15 +0000
Subject: [PATCH 10/17] refactor(landing): make the problem-card marks vector
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both cards were carrying raster art at 44px. The peace hand already existed as
a vector — the same hand-peace.svg the footer and the security fold use, so the
homepage now draws from the same file instead of a near-duplicate PNG. Konrad
supplied the coin stack as an SVG too.
Ratios differ from the PNGs (37x57 and 197x173), but the shared 44px slot sizes
on height, so the row still lines up.
---
src/assets/illustrations/hands-peace.png | Bin 6959 -> 0 bytes
src/assets/illustrations/stack-coin.png | Bin 6484 -> 0 bytes
src/assets/illustrations/stack-coin.svg | 8 ++++++++
src/components/LandingPage/ProblemFold.tsx | 6 +++---
4 files changed, 11 insertions(+), 3 deletions(-)
delete mode 100644 src/assets/illustrations/hands-peace.png
delete mode 100644 src/assets/illustrations/stack-coin.png
create mode 100644 src/assets/illustrations/stack-coin.svg
diff --git a/src/assets/illustrations/hands-peace.png b/src/assets/illustrations/hands-peace.png
deleted file mode 100644
index 58e62fd147a74edc532f8c44c5006baae31f8e46..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6959
zcmV+~8_?v5P)BxfL1_x1%Or{aRq=fJED|1!p&1On5w>259WQIHf0i*xqMSyU0q%M
z0R3Xrw8u?*vTYA{nrFFfpPM#s+U@q|z(8^+#8K03ns(Q;m9p`@lcv4Ez(4{h)GLIE
z^iW$g?HmIG5lAW2LAV1^d5Jtiy-&Cf0|Om#O`9RVQ1^Y@a|{&c0c8ZI;_$*+5oTMvgne>FRbJynh0BX;MUY+E6WAqpRAqq^8<=vI$$|)_H|Zg4
z;ZQcJa)X4{)=HGXHSGf;-~5LUAHq4e{^|Mn@gqKe{)~t)YA}WkXz`N@rX$Oy{ehn5
zXi!bGR8gMr(P*@~zP?^97K_y(__xVqvU>95NsRBjgAFTtjxrXttzDsqOc6<_v)ODV
zh3{#saOn4sQI;#%5NPj3dx5|ULa}Lty3f_sRmgFaw6a&QC(>Su)*;Xpc=6)J>hA7t
z6^lU8j**`$*cIu1t04^73S)Qb3qWzDAV9P;=u#p*P=&Qy3L1`S9U`_J-u>_8cNdRd`J~4%m&X(ADDa7;%XdP4R7p%5YGgEPd8o
zD6Q-S>6t-!{jeug$zYYQJwHE>CE776VhY7cG8)hpTa68Gkm5KONXr!z*KYqhhD23v
zZf;_P0jqHh9F!+HolaLdVVBsbhr?PEl+Q^hf?+sl_PSGfoXeO*Jq56FXq;zl+;
z-@%Cf*nCY)19Ft%DmZ_$+iJ)*C?W$CYUx3$M`>_emlvfLEd5vU
z+I{5{+84%9(9G?d2?0KS{1^%zb$L;0!K%cXGaO?OagRiy(jBk@2Yw1%mlv56tny_0
z!UYszF-z8!_<9gRT$dN67OaDiXV1h2rca1{S7OP!dQ$b0sRb)`W=?o2)=LMk)>a||xyTVE=!`Y7{`ljM$jD3K>rXN{D5YQ>gjkCXoSmIP
zf#xLmq&f((=)0!Ilwf;eEi{4^y92ydj1HU{xzY6=Cn!k);wg&Q;=A3GDOu@B^wJ$rtk$Scu+~sM
zK+BRrFK@QvpKaX3qv9Gw+zEu~=5&13rj?OgR(nGN+Js@HkGxcUMh{whpsP7Em-m6){}vkJMwq2sjOwKo6;rt)b)j?
zTmqu(Qfi}@69z;nSTioc5<-+GoQy;k_SoOQqXz$#_fFx`%_T2RE!a7PSQD&LDDKGL
zyUvDvr3RPa)2Gfo`z}k#iw6sPghZ<#oaDuPXlk&q^Z6VKL_T1Si35=W*`-(-cv)}>
zYKah}_FqX3!HP#wfBp3rtdJl9rngIn`XX5ISy*!m6tv|NI0&i1!YT(`ywRdJ_g?>?tjBD;QC6gY%TGV^#1*OSRr!h$hN=T
z%cDo)C|Gus6FzeY$t75^`YgExO2N1Tzwe!erSGcDfi>w8kU50Bd-o1j$XLa&N^<$H
zB&SOt&fKzMWg`!~9P)gBHk4sVl@F0iuqyL!#gA3G1V7_MP+C}37n*a4jQBCM;ua`f
z0(ao|;<;Yy7FO94nsy0@SbcnQErhp}seOlp*(={RK&;|f29i^-ODK|I-oAZ{(kMsi
zPjCV#-8S`9X6{w9Vzd
zxdl6i!gsOY7Np!iwGGRHr7=+?yett=*|J0-4YB$xL<^L{sbe9Q5~cq-M*t-RtBMws
z_GOi!rC!HkmG@+h07?jU4uxA#I;lHDt=Q^N3N~mqs8nq&v0E^mPKy@VP{v{%dWur4
zO9WPCFC|#9TcFGciiXm{2K%s93*;$#yg33XCD=I>Zo#WpuTYW_p)IR|&6z*2OUZJZ
zZy-b&Mv2DY7HkN}qzf+VQ&OW60Xeg?_9qvV6izSXo-w$SY{8BVcwb%2Q`yQ;sPVluC$E)+lg0|G$I~
zOBS|NbiiXlul9g?b#4`fCi6wklrC@g>1=U6g
z0+uYz5eX}TokMXWON6*0L<+;-XKLJH(~*WBr?|6l}n9n5ZdsruLeJ
zax|Gci|Z5xo3k8I8`cCnhq3^2PAjYQWBr?|6s*!6xItvB36`kLWlk%r+y;JxN(so{
zuu&W>1GA>Bq-{2vt#Z)}yjF^1^aC%^`;`HXXZ*RlyR~j6g0c
zdny*TbUNWa|AR943??7yaeIHigK{~Auf>(RrfNWFOFZjPlG5pfrQl7vXxgM{3n&Iu
zl6G-%vC07lLyF_$b(l%s!vS6?&d<*^=Xi|@p-5jsIpPm#yuo*OcdOJ~US4XBZ=$&-
zrD%F8%{h)x5e}OtjNSUGR2z@StJE-zrZ_fUmznf^C0HyL+Us8h4x7>a%0kOwW%>Ea
zc&zwgF2Q6n(VW{135gLUVAuZ|8{hOCM#zzR$gC~p;H!#(mxXk~eL@BM0vn-T!XP
EALP%Y;ukU(kbUy2Ph-)5zoWIf6xL^&6UQI!A%k0P-u;!
zAHE}Dz(DA6uAMg-@%i}9x|OXp_5HBC4`cEIkxGXw2U!uq0iOZ0umjDI90)_x^;OQ2
z&FDwrJ_O`3(xVIm4ukgS=*Zb1!b9KR-*;&u4e0HBupZ~{sPztUx=`2+0O>sk7j_-r>GS6(VoAM-GAt1Imyny2@a;p!5A5
zQ1xgK8129o5JnwR`m8wQzE@d-fr%xfX3(uio=gN(?>**Y;*~cICGT5QZ~-Q$E~Zf6
zz3?yyC|0-A8Z{kRl9&jGlFxv4!vdLW2ORH*wG9Y@L-TP+-dxIs4%I_zU~tVn4cVS&2;s(^y7hpo7|;pf!&HIlMR8GkUb$W4&_NhZ_U-a
z?lv|yZhm)pc^SQ8#~XI&;gx&buLZ}dJ)#xZF;^7=s)k8rVxjy;i_vG)I|`_Jz!`Z;@nn&kV`9P!MRo#OTy|$N_gW>;*Pd$oR1HQH
zmz)`q$5S!^l^0xqN!la7*5Iiq0TokCen_z(1XN+X`Q(`=q&P=xi?T(-;1j@qx}bP{
u;hakJ6C`+{@R)#O9uVKm(ZVpHn(}uK_t|&QwQ5BG0000
+
+
+
+
+
+
+
diff --git a/src/components/LandingPage/ProblemFold.tsx b/src/components/LandingPage/ProblemFold.tsx
index 000b0463f7..a08839cb36 100644
--- a/src/components/LandingPage/ProblemFold.tsx
+++ b/src/components/LandingPage/ProblemFold.tsx
@@ -1,6 +1,6 @@
import Image from 'next/image'
-import HandsPeace from '@/assets/illustrations/hands-peace.png'
-import StackCoin from '@/assets/illustrations/stack-coin.png'
+import HandPeace from '@/assets/illustrations/hand-peace.svg'
+import StackCoin from '@/assets/illustrations/stack-coin.svg'
import type { LandingStrings } from './landingStrings'
const bgColor = '#F9F4F0'
@@ -26,7 +26,7 @@ export function ProblemFold({ strings }: { strings: LandingStrings }) {
id: 'cross-border',
title: problem.crossBorderTitle,
body: problem.crossBorderBody,
- icon: ,
+ icon: ,
},
{
id: 'send-home',
From e7a97d5f2aa8ae95074aba45b09d52378d37124b Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Thu, 13 Aug 2026 16:12:15 +0000
Subject: [PATCH 11/17] fix(landing): stop the hover lift clipping a logo
tile's top border
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
react-fast-marquee sets overflow-x:hidden on its container. A visible Y axis
against a hidden X axis computes to auto, so the strip clips vertically too —
and the tiles carried mb-2 with no top margin, sitting flush against that edge.
Hovering lifted a tile 2px into it and sheared off its top border.
my-2 gives the tile 8px above to move into. Measured under a real hover: the
tile top sat 2px past the clip edge before, and 6px clear of it after.
---
src/components/LandingPage/RegulatedRails.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index e6f12643be..003c48eb99 100644
--- a/src/components/LandingPage/RegulatedRails.tsx
+++ b/src/components/LandingPage/RegulatedRails.tsx
@@ -38,7 +38,10 @@ const logos = [
{ logo: WISE_ICON, alt: 'Wise', path: 'compare/peanut-vs-wise' },
]
-const tileClass = 'btn btn-purple btn-shadow-primary-4 mx-7 mb-2 flex h-26 w-48 items-center gap-2'
+// 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 = [
From a9fb1b1fc7e0d4d74203f2f01bc55ecbafadb6ff Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 06:21:10 +0000
Subject: [PATCH 12/17] fix(landing): give the country lockups the partner-tile
treatment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Pix wordmark in pix-brz.svg is mint #32BCAD. Against the homepage's
#90A8ED that is 1.01:1 — the same luminance as the ground — so it reads as
a rendering fault rather than a logo. It failed on cream too (2.15:1); the
blue only made it obvious. A lighter card does not rescue it either: the
mint is 2.35:1 on white and 1.17:1 on pink. Only the black mark clears 4.5:1.
So each lockup now uses what #regulated-rails already ships one fold up: the
pink partner tile, 1px black border and 4px hard shadow, holding the black
mark on the white inner card that fold gives PIX and MercadoPago (both are
line art, not heavy wordmarks, and go weak straight on the pink).
The flags stay, but as their own assets drawn to one rule — the old SVGs
baked flag and logo into a single file, so the logo could not move into a
tile without splitting them. mepa-arg.svg also carried no outline at all,
which is why Argentina dissolved into the background at 1.37:1; both flags
now have the same 3px black edge and hold their silhouette on either ground.
Also fixes the asset itself: pix-logo.svg declared viewBox "0 0 88 38" while
its ink spans x = -15.7..87.7, so the left petal of the diamond was sheared
off — visible on the rails marquee today, not just here.
Both links keep their hrefs and now look like links: the tile lifts on hover
and an underlined "Learn more ->" sits under each, reusing landingLearnMore,
so no locale file changes. On mobile the pair sits above the phone, so the
sticky CTA covers the screenshot instead of a link. Mobile and desktop share
one block via md:contents, which drops the duplicated markup from four link
elements to two.
---
src/assets/icons/pix-logo.svg | 2 +-
src/assets/illustrations/flag-ar.svg | 19 ++++++
src/assets/illustrations/flag-br.svg | 17 ++++++
src/assets/logos/index.ts | 2 -
src/assets/logos/mepa-arg.svg | 35 -----------
src/assets/logos/pix-brz.svg | 42 --------------
src/components/LandingPage/Manteca.tsx | 61 ++++++++++----------
src/components/LandingPage/PartnerLockup.tsx | 52 +++++++++++++++++
8 files changed, 121 insertions(+), 109 deletions(-)
create mode 100644 src/assets/illustrations/flag-ar.svg
create mode 100644 src/assets/illustrations/flag-br.svg
delete mode 100644 src/assets/logos/mepa-arg.svg
delete mode 100644 src/assets/logos/pix-brz.svg
create mode 100644 src/components/LandingPage/PartnerLockup.tsx
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 @@
-
- {/* 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..c49314576a
--- /dev/null
+++ b/src/components/LandingPage/PartnerLockup.tsx
@@ -0,0 +1,52 @@
+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 tile is the partner tile from `#regulated-rails` — pink, 1px black border,
+ * 4px hard shadow — and the mark keeps the white inner card that fold gives PIX
+ * and MercadoPago: both are line art rather than heavy wordmarks, so they go weak
+ * straight on the pink.
+ */
+export function PartnerLockup({
+ flag,
+ flagAlt,
+ logo,
+ logoAlt,
+ href,
+ ariaLabel,
+ learnMore,
+ className,
+}: {
+ flag: StaticImageData
+ flagAlt: string
+ logo: StaticImageData
+ logoAlt: string
+ href: string
+ ariaLabel: string
+ learnMore: string
+ className?: string
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {learnMore} →
+
+
+ )
+}
From 74089860c2c73b35af04615d190869132ee3ae13 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 07:00:39 +0000
Subject: [PATCH 13/17] fix(landing): drop the settles footnote, rename footer
Jobs to Career
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Konrad hotfix ruling: the 'Settles in digital dollars at the real
exchange rate.' line goes (Manteca renders on / and /quests — removed
from both), dead landingPayLocalSettles key removed from all catalogs.
Footer link EN label Jobs → Career; es/pt keep their existing labels
for the localization gate to revisit.
---
src/components/LandingPage/Manteca.tsx | 4 ----
src/i18n/en.json | 3 +--
src/i18n/es-419.json | 1 -
src/i18n/es-ar.json | 1 -
src/i18n/pt-br.json | 1 -
src/i18n/types.ts | 1 -
6 files changed, 1 insertion(+), 10 deletions(-)
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index cc4cc7576a..4e47fa46f3 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -97,10 +97,6 @@ const Manteca = ({
-
-
- {i18n.landingPayLocalSettles}
-
)
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 93779d0d56..ae029106a5 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.",
@@ -91,7 +90,7 @@
"footerTerms": "Terms",
"footerPrivacy": "Privacy",
"footerSecurity": "Security",
- "footerJobs": "Jobs",
+ "footerJobs": "Career",
"footerSiteDirectory": "Site directory",
"footerCompare": "Compare",
"footerLearnMoreSection": "Learn More",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 24d0c579db..b4606b697a 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.",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index c114228ab4..fe0bac6f9d 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.",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index ac65b5aa36..1f806ba1b4 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. SEM DOCUMENTO LOCAL.",
"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 sem CPF",
"landingSecurityHeading": "SEGURANÇA. CONTROLE. SUPORTE.",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index a197547533..a2d96fdf4b 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
From cb10001cfcccc7a38155805e0e8aca0c259a41a3 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 08:42:25 +0000
Subject: [PATCH 14/17] =?UTF-8?q?fix(landing):=20qr-pay=20lockups=20?=
=?UTF-8?q?=E2=80=94=20original=20flags,=20marks=20straight=20on=20the=20g?=
=?UTF-8?q?round?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements mono projects/lp-refresh/handoff-qrpay-lockups.md (Konrad's
revised ruling): the pink tile and white inner card go, the original
wavy flag artwork returns (cropped flag-only via root viewBox — ink
untouched), and the black marks sit directly on the section bg (9:1 on
the homepage blue, 19:1 on the /quests cream). Mark scale L per the
brief's recommendation — the PIX Banco Central strapline is illegible
at S. Fixed-height slot keeps the marks on a shared baseline; the
underlined Learn more stays as the sole click affordance. Kept from
#2692: the pix-logo viewBox fix and the Manteca mobile/desktop
collapse.
---
src/assets/illustrations/flag-ar.svg | 52 +++++++++++-------
src/assets/illustrations/flag-br.svg | 57 ++++++++++++++------
src/components/LandingPage/Manteca.tsx | 2 +
src/components/LandingPage/PartnerLockup.tsx | 32 ++++++-----
4 files changed, 95 insertions(+), 48 deletions(-)
diff --git a/src/assets/illustrations/flag-ar.svg b/src/assets/illustrations/flag-ar.svg
index 2733b39896..94a308cba6 100644
--- a/src/assets/illustrations/flag-ar.svg
+++ b/src/assets/illustrations/flag-ar.svg
@@ -1,19 +1,35 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/illustrations/flag-br.svg b/src/assets/illustrations/flag-br.svg
index 89b3cf9396..e87f8036cb 100644
--- a/src/assets/illustrations/flag-br.svg
+++ b/src/assets/illustrations/flag-br.svg
@@ -1,17 +1,42 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index 4e47fa46f3..911a0302c0 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -79,6 +79,7 @@ const Manteca = ({
flagAlt="Argentina"
logo={MERCADO_PAGO_ICON}
logoAlt="Mercado Pago"
+ logoClassName="w-[108px] md:w-[150px]"
href={`/${locale}/pay-with/mercadopago`}
ariaLabel={i18n.landingMercadoPagoAria}
learnMore={i18n.landingLearnMore}
@@ -89,6 +90,7 @@ const Manteca = ({
flagAlt="Brazil"
logo={PIX_ICON}
logoAlt="PIX"
+ logoClassName="w-[95px] md:w-[132px]"
href={`/${locale}/pay-with/pix`}
ariaLabel={i18n.landingPixAria}
learnMore={i18n.landingLearnMore}
diff --git a/src/components/LandingPage/PartnerLockup.tsx b/src/components/LandingPage/PartnerLockup.tsx
index c49314576a..8db499d77b 100644
--- a/src/components/LandingPage/PartnerLockup.tsx
+++ b/src/components/LandingPage/PartnerLockup.tsx
@@ -6,16 +6,18 @@ import { twMerge } from 'tailwind-merge'
* A country flag over its payment rail's mark, linking to the page that explains
* the rail.
*
- * The tile is the partner tile from `#regulated-rails` — pink, 1px black border,
- * 4px hard shadow — and the mark keeps the white inner card that fold gives PIX
- * and MercadoPago: both are line art rather than heavy wordmarks, so they go weak
- * straight on the pink.
+ * 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,
@@ -25,23 +27,25 @@ export function PartnerLockup({
flagAlt: string
logo: StaticImageData
logoAlt: string
+ logoClassName: string
href: string
ariaLabel: string
learnMore: string
className?: string
}) {
return (
-
-
+
+
-
-
+
+
From 993777dfa89528c0b5bad12a13182e1aaf77b676 Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Fri, 14 Aug 2026 10:39:17 +0000
Subject: [PATCH 15/17] 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 e332bc0bd0..b38709699a 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -2868,6 +2868,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 9b0bf0fa40..8133d983d5 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -2868,6 +2868,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 96cf207f8c..bc7aa07c9b 100644
--- a/src/i18n/app/messages/es-AR.json
+++ b/src/i18n/app/messages/es-AR.json
@@ -1210,6 +1210,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 6e529e6072..303dc2485e 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -2868,6 +2868,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 9e0e69ddab618633e419b3c94e5ea886c188411e Mon Sep 17 00:00:00 2001
From: 0xkkonrad
Date: Tue, 18 Aug 2026 15:36:05 +0000
Subject: [PATCH 16/17] 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 17/17] 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))]"
/>
)
}