From df27ff355c06034bb1306afe78f199d472e975f7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 14:50:55 -0700 Subject: [PATCH 1/4] feat: advertise supported networks, tokens and bank rails in landing FAQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users comparing cards can't find which chains/stables we support without signing up (X thread 2026-07-02 β€” reviewers literally said they look for this in the FAQ and bounce when it's missing). Renders the answer from the same rhino.consts the add-money Choose Network drawer uses, so the public claim can never drift from what the app actually accepts; plain-text answer feeds the FAQPage JSON-LD for SEO. --- src/components/Global/FAQs/index.tsx | 6 +- .../LandingPage/LandingPageClient.tsx | 10 ++- .../LandingPage/SupportedRailsFaqAnswer.tsx | 63 +++++++++++++++++++ src/constants/faq.consts.ts | 27 ++++++++ src/lib/landingContent.ts | 43 ++++++++++--- 5 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 src/components/LandingPage/SupportedRailsFaqAnswer.tsx create mode 100644 src/constants/faq.consts.ts diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx index a7af0ca91..a21732554 100644 --- a/src/components/Global/FAQs/index.tsx +++ b/src/components/Global/FAQs/index.tsx @@ -1,11 +1,15 @@ 'use client' +import type { ReactNode } from 'react' + export type FAQsProps = { heading: string questions: Array<{ id: string question: string answer: string + /** Rich JSX answer body β€” rendered instead of `answer`, which still feeds SEO schemas */ + answerContent?: ReactNode redirectUrl?: string redirectText?: string calModal?: boolean @@ -54,7 +58,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
-

{linkifyText(faq.answer)}

+ {faq.answerContent ??

{linkifyText(faq.answer)}

} {faq.calModal && ( - + + q.id === SUPPORTED_RAILS_FAQ_ID ? { ...q, answerContent: } : q + )} + marquee={faqData.marquee} + /> {footerSlot} diff --git a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx new file mode 100644 index 000000000..7713c1382 --- /dev/null +++ b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx @@ -0,0 +1,63 @@ +'use client' + +import ChainChip from '@/components/AddMoney/components/ChainChip' +import { CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, getSupportedTokens, TOKEN_LOGOS } from '@/constants/rhino.consts' +import { chainDisplayName } from '@/constants/faq.consts' + +const FIAT_RAILS = [ + { flag: 'πŸ‡ΊπŸ‡Έ', name: 'ACH & Wire', detail: 'USD Β· United States' }, + { flag: 'πŸ‡ͺπŸ‡Ί', name: 'SEPA', detail: 'EUR Β· 36 countries' }, + { flag: 'πŸ‡¬πŸ‡§', name: 'Faster Payments', detail: 'GBP Β· United Kingdom' }, + { flag: 'πŸ‡²πŸ‡½', name: 'SPEI', detail: 'MXN Β· Mexico' }, + { flag: 'πŸ‡¦πŸ‡·', name: 'Mercado Pago', detail: 'ARS Β· Argentina' }, + { flag: 'πŸ‡§πŸ‡·', name: 'Pix', detail: 'BRL Β· Brazil' }, +] as const + +/** + * Rich answer body for the "which networks, tokens and banks?" landing FAQ item. + * Renders from the same rhino.consts constants as the add-money Choose Network + * drawer, so the FAQ always advertises exactly what the app supports. + */ +export function SupportedRailsFaqAnswer() { + return ( +
+
+

+ Crypto β€” one deposit address for all {SUPPORTED_EVM_CHAINS.length} EVM networks, plus Solana and + Tron: +

+
+ {SUPPORTED_EVM_CHAINS.map((chain) => ( + + ))} + + +
+
+
+

Tokens:

+
+ {getSupportedTokens('EVM').map((token) => ( + + ))} +
+

+ USDC & USDT on every network Β· ETH on EVM networks Β· Tron is USDT-only +

+
+
+

Banks & local payment apps:

+
    + {FIAT_RAILS.map((rail) => ( +
  • + {rail.flag} + {rail.name} + {rail.detail} +
  • + ))} +
+
+

Deposits are free β€” Peanut covers the gas.

+
+ ) +} diff --git a/src/constants/faq.consts.ts b/src/constants/faq.consts.ts new file mode 100644 index 000000000..487a304ec --- /dev/null +++ b/src/constants/faq.consts.ts @@ -0,0 +1,27 @@ +import { SUPPORTED_EVM_CHAINS } from '@/constants/rhino.consts' + +/** + * The "which networks, tokens and banks?" landing FAQ item. Question + plain-text + * answer live here (server-safe, feeds the FAQPage JSON-LD via getLandingContent); + * the rich chip UI is rendered client-side by SupportedRailsFaqAnswer, matched by id. + * Chain names derive from rhino.consts so this can never drift from what the + * add-money flow actually supports. + */ +export const SUPPORTED_RAILS_FAQ_ID = 'supported-rails' + +// Display labels where plain title-case reads wrong. +const CHAIN_DISPLAY_OVERRIDES: Record = { + BNB: 'BNB Chain', +} + +export const chainDisplayName = (chain: string): string => + CHAIN_DISPLAY_OVERRIDES[chain] ?? chain.charAt(0) + chain.slice(1).toLowerCase() + +const EVM_CHAIN_LIST = SUPPORTED_EVM_CHAINS.map(chainDisplayName).join(', ') + +export const SUPPORTED_RAILS_FAQ_QUESTION = 'Which networks, tokens and banks does Peanut support?' + +export const SUPPORTED_RAILS_FAQ_ANSWER = + `Crypto: deposit and withdraw USDC and USDT on ${SUPPORTED_EVM_CHAINS.length} EVM networks with a single address (${EVM_CHAIN_LIST}), plus Solana (USDC, USDT) and Tron (USDT only). ETH is also supported on EVM networks. ` + + 'Banks: US bank transfers (ACH and wire, USD), SEPA (EUR, 36 countries), UK Faster Payments (GBP) and Mexico SPEI (MXN). ' + + 'Local payment apps: Mercado Pago in Argentina and Pix in Brazil. Deposits are free β€” Peanut covers the gas.' diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts index 58984f8cb..1302755cc 100644 --- a/src/lib/landingContent.ts +++ b/src/lib/landingContent.ts @@ -5,6 +5,11 @@ // component or route handler, never inside a 'use client' file. import { readSingletonContentLocalized } from '@/lib/content' +import { + SUPPORTED_RAILS_FAQ_ANSWER, + SUPPORTED_RAILS_FAQ_ID, + SUPPORTED_RAILS_FAQ_QUESTION, +} from '@/constants/faq.consts' import type { Locale } from '@/i18n/types' interface LandingFrontmatter { @@ -40,9 +45,31 @@ const DEFAULTS: LandingContent = { marqueeMessages: [], } +// Code-defined FAQ item advertising supported networks/tokens/bank rails. +// Lives in code (not the content MD) because its facts derive from +// rhino.consts β€” the same constants the add-money flow renders β€” so the +// public answer can't drift from what the app actually supports. +// LandingPageClient swaps in the rich chip UI by this id. +const SUPPORTED_RAILS_QUESTION = { + id: SUPPORTED_RAILS_FAQ_ID, + question: SUPPORTED_RAILS_FAQ_QUESTION, + answer: SUPPORTED_RAILS_FAQ_ANSWER, +} + +// Insert before the last content question, which is the "My question is not +// here β†’ help center" catch-all by convention. +function withSupportedRails(questions: LandingContent['faqData']['questions']) { + if (questions.length === 0) return [SUPPORTED_RAILS_QUESTION] + return [...questions.slice(0, -1), SUPPORTED_RAILS_QUESTION, ...questions.slice(-1)] +} + export function getLandingContent(locale: Locale = 'en'): LandingContent { const content = readSingletonContentLocalized('landing', locale) - if (!content) return DEFAULTS + if (!content) + return { + ...DEFAULTS, + faqData: { ...DEFAULTS.faqData, questions: withSupportedRails(DEFAULTS.faqData.questions) }, + } const fm = content.frontmatter return { @@ -57,12 +84,14 @@ export function getLandingContent(locale: Locale = 'en'): LandingContent { heading: fm.faqs?.heading ?? DEFAULTS.faqData.heading, // Authored frontmatter β€” drop malformed entries so a null/partial // item can't crash the .map() in the landing page. - questions: (fm.faqs?.questions ?? []).filter( - (q): q is { id: string; question: string; answer: string } => - q != null && - typeof q.id === 'string' && - typeof q.question === 'string' && - typeof q.answer === 'string' + questions: withSupportedRails( + (fm.faqs?.questions ?? []).filter( + (q): q is { id: string; question: string; answer: string } => + q != null && + typeof q.id === 'string' && + typeof q.question === 'string' && + typeof q.answer === 'string' + ) ), marquee: fm.faqs?.marquee && typeof fm.faqs.marquee.message === 'string' From 02da547397a9e76f99e447d2e509db1975c5672b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 15:00:10 -0700 Subject: [PATCH 2/4] refactor: single-source the FAQ rail facts per code review Fiat rails now live once in FIAT_RAILS (chips + JSON-LD prose both derive from it), Solana/Tron chips derive from OTHER_SUPPORTED_CHAINS, token chips reuse getSupportedTokens' logoUrl, chainDisplayName moves to a utils file per export rules, withSupportedRails wraps in one place, and the FAQ array is memoized (LandingPageClient re-renders per scroll frame). --- .../LandingPage/LandingPageClient.tsx | 21 +++++++---- .../LandingPage/SupportedRailsFaqAnswer.tsx | 27 +++++--------- src/constants/faq.consts.ts | 37 +++++++++++-------- src/lib/landingContent.ts | 25 ++++++------- src/utils/chain-display.utils.ts | 8 ++++ 5 files changed, 64 insertions(+), 54 deletions(-) create mode 100644 src/utils/chain-display.utils.ts diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 45b73708c..bb05397b3 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -1,7 +1,7 @@ 'use client' import { useFooterVisibility } from '@/context/footerVisibility' -import { Suspense, useEffect, useState, useRef, useCallback, type ReactNode } from 'react' +import { Suspense, useEffect, useMemo, useState, useRef, useCallback, type ReactNode } from 'react' import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage' import { SupportedRailsFaqAnswer } from '@/components/LandingPage/SupportedRailsFaqAnswer' import { SUPPORTED_RAILS_FAQ_ID } from '@/constants/faq.consts' @@ -53,6 +53,17 @@ export function LandingPageClient({ footerSlot, }: LandingPageClientProps) { const { isFooterVisible } = useFooterVisibility() + + // 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 [buttonVisible, setButtonVisible] = useState(true) const [isScrollFrozen, setIsScrollFrozen] = useState(false) const [buttonScale, setButtonScale] = useState(1) @@ -217,13 +228,7 @@ export function LandingPageClient({ - - q.id === SUPPORTED_RAILS_FAQ_ID ? { ...q, answerContent: } : q - )} - marquee={faqData.marquee} - /> + {footerSlot} diff --git a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx index 7713c1382..d2c78a017 100644 --- a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx +++ b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx @@ -1,22 +1,15 @@ 'use client' import ChainChip from '@/components/AddMoney/components/ChainChip' -import { CHAIN_LOGOS, SUPPORTED_EVM_CHAINS, getSupportedTokens, TOKEN_LOGOS } from '@/constants/rhino.consts' -import { chainDisplayName } from '@/constants/faq.consts' - -const FIAT_RAILS = [ - { flag: 'πŸ‡ΊπŸ‡Έ', name: 'ACH & Wire', detail: 'USD Β· United States' }, - { flag: 'πŸ‡ͺπŸ‡Ί', name: 'SEPA', detail: 'EUR Β· 36 countries' }, - { flag: 'πŸ‡¬πŸ‡§', name: 'Faster Payments', detail: 'GBP Β· United Kingdom' }, - { flag: 'πŸ‡²πŸ‡½', name: 'SPEI', detail: 'MXN Β· Mexico' }, - { flag: 'πŸ‡¦πŸ‡·', name: 'Mercado Pago', detail: 'ARS Β· Argentina' }, - { flag: 'πŸ‡§πŸ‡·', name: 'Pix', detail: 'BRL Β· Brazil' }, -] as const +import { CHAIN_LOGOS, OTHER_SUPPORTED_CHAINS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts' +import { FIAT_RAILS } from '@/constants/faq.consts' +import { chainDisplayName } from '@/utils/chain-display.utils' /** * Rich answer body for the "which networks, tokens and banks?" landing FAQ item. * Renders from the same rhino.consts constants as the add-money Choose Network - * drawer, so the FAQ always advertises exactly what the app supports. + * drawer (and FIAT_RAILS shared with the plain-text SEO answer), so the FAQ + * always advertises exactly what the app supports. */ export function SupportedRailsFaqAnswer() { return ( @@ -27,18 +20,16 @@ export function SupportedRailsFaqAnswer() { Tron:

- {SUPPORTED_EVM_CHAINS.map((chain) => ( + {[...SUPPORTED_EVM_CHAINS, ...OTHER_SUPPORTED_CHAINS].map((chain) => ( ))} - -

Tokens:

{getSupportedTokens('EVM').map((token) => ( - + ))}

@@ -52,7 +43,9 @@ export function SupportedRailsFaqAnswer() {

  • {rail.flag} {rail.name} - {rail.detail} + + {rail.currency} Β· {rail.region} +
  • ))} diff --git a/src/constants/faq.consts.ts b/src/constants/faq.consts.ts index 487a304ec..327cd1c93 100644 --- a/src/constants/faq.consts.ts +++ b/src/constants/faq.consts.ts @@ -1,27 +1,32 @@ -import { SUPPORTED_EVM_CHAINS } from '@/constants/rhino.consts' +import { OTHER_SUPPORTED_CHAINS, SUPPORTED_EVM_CHAINS } from '@/constants/rhino.consts' +import { chainDisplayName } from '@/utils/chain-display.utils' /** - * The "which networks, tokens and banks?" landing FAQ item. Question + plain-text - * answer live here (server-safe, feeds the FAQPage JSON-LD via getLandingContent); - * the rich chip UI is rendered client-side by SupportedRailsFaqAnswer, matched by id. - * Chain names derive from rhino.consts so this can never drift from what the - * add-money flow actually supports. + * The "which networks, tokens and banks?" landing FAQ item. Question, fiat-rail + * facts and plain-text answer live here (server-safe, feeds the FAQPage JSON-LD + * via getLandingContent); the rich chip UI is rendered client-side by + * SupportedRailsFaqAnswer, matched by id. Chain names derive from rhino.consts + * and the visible rails render from FIAT_RAILS, so the public answer and the + * on-page UI share one source and can't drift from what the app supports. */ export const SUPPORTED_RAILS_FAQ_ID = 'supported-rails' -// Display labels where plain title-case reads wrong. -const CHAIN_DISPLAY_OVERRIDES: Record = { - BNB: 'BNB Chain', -} - -export const chainDisplayName = (chain: string): string => - CHAIN_DISPLAY_OVERRIDES[chain] ?? chain.charAt(0) + chain.slice(1).toLowerCase() +export const FIAT_RAILS = [ + { flag: 'πŸ‡ΊπŸ‡Έ', name: 'ACH & Wire', currency: 'USD', region: 'United States' }, + { flag: 'πŸ‡ͺπŸ‡Ί', name: 'SEPA', currency: 'EUR', region: '36 countries' }, + { flag: 'πŸ‡¬πŸ‡§', name: 'Faster Payments', currency: 'GBP', region: 'United Kingdom' }, + { flag: 'πŸ‡²πŸ‡½', name: 'SPEI', currency: 'MXN', region: 'Mexico' }, + { flag: 'πŸ‡¦πŸ‡·', name: 'Mercado Pago', currency: 'ARS', region: 'Argentina' }, + { flag: 'πŸ‡§πŸ‡·', name: 'Pix', currency: 'BRL', region: 'Brazil' }, +] as const const EVM_CHAIN_LIST = SUPPORTED_EVM_CHAINS.map(chainDisplayName).join(', ') +const OTHER_CHAIN_LIST = OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(' and ') +const FIAT_RAIL_LIST = FIAT_RAILS.map((rail) => `${rail.name} (${rail.currency}, ${rail.region})`).join(', ') export const SUPPORTED_RAILS_FAQ_QUESTION = 'Which networks, tokens and banks does Peanut support?' export const SUPPORTED_RAILS_FAQ_ANSWER = - `Crypto: deposit and withdraw USDC and USDT on ${SUPPORTED_EVM_CHAINS.length} EVM networks with a single address (${EVM_CHAIN_LIST}), plus Solana (USDC, USDT) and Tron (USDT only). ETH is also supported on EVM networks. ` + - 'Banks: US bank transfers (ACH and wire, USD), SEPA (EUR, 36 countries), UK Faster Payments (GBP) and Mexico SPEI (MXN). ' + - 'Local payment apps: Mercado Pago in Argentina and Pix in Brazil. Deposits are free β€” Peanut covers the gas.' + `Crypto: deposit and withdraw USDC and USDT on ${SUPPORTED_EVM_CHAINS.length} EVM networks with a single address (${EVM_CHAIN_LIST}), plus ${OTHER_CHAIN_LIST}. ETH is also supported on EVM networks; Tron is USDT-only. ` + + `Banks & local payment apps: ${FIAT_RAIL_LIST}. ` + + 'Deposits are free β€” Peanut covers the gas.' diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts index 1302755cc..df23d8659 100644 --- a/src/lib/landingContent.ts +++ b/src/lib/landingContent.ts @@ -64,12 +64,13 @@ function withSupportedRails(questions: LandingContent['faqData']['questions']) { } export function getLandingContent(locale: Locale = 'en'): LandingContent { + const base = readLandingContent(locale) + return { ...base, faqData: { ...base.faqData, questions: withSupportedRails(base.faqData.questions) } } +} + +function readLandingContent(locale: Locale): LandingContent { const content = readSingletonContentLocalized('landing', locale) - if (!content) - return { - ...DEFAULTS, - faqData: { ...DEFAULTS.faqData, questions: withSupportedRails(DEFAULTS.faqData.questions) }, - } + if (!content) return DEFAULTS const fm = content.frontmatter return { @@ -84,14 +85,12 @@ export function getLandingContent(locale: Locale = 'en'): LandingContent { heading: fm.faqs?.heading ?? DEFAULTS.faqData.heading, // Authored frontmatter β€” drop malformed entries so a null/partial // item can't crash the .map() in the landing page. - questions: withSupportedRails( - (fm.faqs?.questions ?? []).filter( - (q): q is { id: string; question: string; answer: string } => - q != null && - typeof q.id === 'string' && - typeof q.question === 'string' && - typeof q.answer === 'string' - ) + questions: (fm.faqs?.questions ?? []).filter( + (q): q is { id: string; question: string; answer: string } => + q != null && + typeof q.id === 'string' && + typeof q.question === 'string' && + typeof q.answer === 'string' ), marquee: fm.faqs?.marquee && typeof fm.faqs.marquee.message === 'string' diff --git a/src/utils/chain-display.utils.ts b/src/utils/chain-display.utils.ts new file mode 100644 index 000000000..fe9bce425 --- /dev/null +++ b/src/utils/chain-display.utils.ts @@ -0,0 +1,8 @@ +// Display labels where plain title-case reads wrong. +const CHAIN_DISPLAY_OVERRIDES: Record = { + BNB: 'BNB Chain', +} + +/** rhino.consts chain key (e.g. 'ARBITRUM', 'BNB') β†’ human display name */ +export const chainDisplayName = (chain: string): string => + CHAIN_DISPLAY_OVERRIDES[chain] ?? chain.charAt(0) + chain.slice(1).toLowerCase() From fb1ac5d906b6fdd72db16df486de119d0e6d0b6d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 15:12:51 -0700 Subject: [PATCH 3/4] feat: position supported-rails FAQ right after 'What is Peanut?' (Hugo review) --- src/lib/landingContent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts index df23d8659..95c77a853 100644 --- a/src/lib/landingContent.ts +++ b/src/lib/landingContent.ts @@ -56,11 +56,11 @@ const SUPPORTED_RAILS_QUESTION = { answer: SUPPORTED_RAILS_FAQ_ANSWER, } -// Insert before the last content question, which is the "My question is not -// here β†’ help center" catch-all by convention. +// Insert right after the "What is Peanut?" question (falls back to the end). function withSupportedRails(questions: LandingContent['faqData']['questions']) { - if (questions.length === 0) return [SUPPORTED_RAILS_QUESTION] - return [...questions.slice(0, -1), SUPPORTED_RAILS_QUESTION, ...questions.slice(-1)] + const idx = questions.findIndex((q) => /what is peanut\??/i.test(q.question)) + const at = idx === -1 ? questions.length : idx + 1 + return [...questions.slice(0, at), SUPPORTED_RAILS_QUESTION, ...questions.slice(at)] } export function getLandingContent(locale: Locale = 'en'): LandingContent { From 8d4d799789955cd0242b884964f5547629f3ebd5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 15:18:16 -0700 Subject: [PATCH 4/4] fix: derive non-EVM chain names in FAQ copy from OTHER_SUPPORTED_CHAINS (CodeRabbit) --- src/components/LandingPage/SupportedRailsFaqAnswer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx index d2c78a017..2777fbcef 100644 --- a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx +++ b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx @@ -16,8 +16,8 @@ export function SupportedRailsFaqAnswer() {

    - Crypto β€” one deposit address for all {SUPPORTED_EVM_CHAINS.length} EVM networks, plus Solana and - Tron: + Crypto β€” one deposit address for all {SUPPORTED_EVM_CHAINS.length} EVM networks, plus{' '} + {OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(' and ')}:

    {[...SUPPORTED_EVM_CHAINS, ...OTHER_SUPPORTED_CHAINS].map((chain) => (