From cd332f4ac9b6a92bf7198314f7eff9d174bfb874 Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:17:34 +0000 Subject: [PATCH 01/77] copy: clarify reference code is one-time use --- .../AddMoney/components/OnrampConfirmationModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AddMoney/components/OnrampConfirmationModal.tsx b/src/components/AddMoney/components/OnrampConfirmationModal.tsx index d14b4f724..00c94d0ce 100644 --- a/src/components/AddMoney/components/OnrampConfirmationModal.tsx +++ b/src/components/AddMoney/components/OnrampConfirmationModal.tsx @@ -50,7 +50,7 @@ export const OnrampConfirmationModal = ({ {' '} (the exact amount shown) , - 'Copy the reference code exactly', + 'Copy the one-time reference code exactly', 'Paste it in the description/reference field', ]} /> @@ -60,7 +60,7 @@ export const OnrampConfirmationModal = ({ icon="alert" iconClassName="text-error-5" title="If the amount or reference don't match:" - description="Your deposit will fail and it will take 2 to 10 days to return to your bank and might incur fees." + description="Your deposit will fail and it will take 2 to 10 days to return to your bank and might incur fees. The reference code is single use." /> } From 243e17300577240f4e7ed07bb0df74a52ae11ebc Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:26:09 +0000 Subject: [PATCH 02/77] =?UTF-8?q?copy:=20update=20landing=20page=20?= =?UTF-8?q?=E2=80=94=20YOUR=20MONEY.=20YOUR=20RULES.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LandingPage/RegulatedRails.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx index e1e391d68..7a60ce712 100644 --- a/src/components/LandingPage/RegulatedRails.tsx +++ b/src/components/LandingPage/RegulatedRails.tsx @@ -52,34 +52,22 @@ export function RegulatedRails() {

- REGULATED RAILS, SELF-CUSTODY CONTROL + YOUR MONEY. YOUR RULES.

- Peanut is a self-custodial wallet that seamlessly connects to banks and payment networks (examples - below) via multiple third party partners who operate under international licenses and standards to - keep every transaction secure, private, and under your control. + Connect your wallet to your bank and local payment networks like PIX and MercadoPago through + licensed partners — so you can pay like a local without giving up control of your funds.

- Our partners hold{' '} - MSB - {' '} - licenses and are compliant under{' '} - - GDPR and CCPA/CPRA + Learn more -   frameworks
From fcd0aa6d6953cefd785870757bd5c429a7be7974 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 13:57:25 +0000 Subject: [PATCH 03/77] fix: full-graph new users render green + debounce topNodes slider - Change new user node color from blue to green to match legend - Debounce topNodes slider (500ms) to prevent refetch on every tick - Pass includeNewDays to backend so new users always appear regardless of topNodes filter --- src/components/Global/InvitesGraph/index.tsx | 20 ++++++++++++++++++-- src/services/points.ts | 5 ++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx index d07a20400..bb41909ed 100644 --- a/src/components/Global/InvitesGraph/index.tsx +++ b/src/components/Global/InvitesGraph/index.tsx @@ -841,6 +841,9 @@ export default function InvitesGraph(props: InvitesGraphProps) { // Fetch graph data on mount and when topNodes changes (only in full mode) // Note: topNodes filtering only applies to full mode (payment mode has fixed 5000 limit in backend) + // topNodes is debounced so the slider doesn't trigger a refetch on every tick + const topNodesDebounceRef = useRef | null>(null) + const isInitialFetchRef = useRef(true) useEffect(() => { if (isMinimal) return @@ -852,9 +855,11 @@ export default function InvitesGraph(props: InvitesGraphProps) { const apiMode = mode === 'payment' ? 'payment' : 'full' // Pass topNodes for both modes - payment mode now supports it via Performance button // Pass password for payment mode authentication + // Pass includeNewDays so backend always includes recent signups regardless of topNodes const result = await pointsApi.getInvitesGraph(props.apiKey, { mode: apiMode, topNodes: topNodes > 0 ? topNodes : undefined, + includeNewDays: activityFilter.activityDays, password: mode === 'payment' ? props.password : undefined, }) @@ -866,7 +871,18 @@ export default function InvitesGraph(props: InvitesGraphProps) { setLoading(false) } - fetchData() + // First fetch is immediate, subsequent topNodes changes are debounced (500ms) + if (isInitialFetchRef.current) { + isInitialFetchRef.current = false + fetchData() + } else { + if (topNodesDebounceRef.current) clearTimeout(topNodesDebounceRef.current) + topNodesDebounceRef.current = setTimeout(fetchData, 500) + } + + return () => { + if (topNodesDebounceRef.current) clearTimeout(topNodesDebounceRef.current) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMinimal, !isMinimal && props.apiKey, mode, topNodes]) @@ -1118,7 +1134,7 @@ export default function InvitesGraph(props: InvitesGraphProps) { } else { // Activity filter enabled - three states if (activityStatus === 'new') { - fillColor = 'rgba(144, 168, 237, 0.85)' // secondary-3 #90A8ED for new signups + fillColor = 'rgba(74, 222, 128, 0.85)' // green-400 for new signups } else if (activityStatus === 'active') { fillColor = 'rgba(255, 144, 232, 0.85)' // primary-1 for active } else { diff --git a/src/services/points.ts b/src/services/points.ts index c0f1a99eb..6fb25164f 100644 --- a/src/services/points.ts +++ b/src/services/points.ts @@ -303,7 +303,7 @@ export const pointsApi = { getInvitesGraph: async ( apiKey: string, - options?: { mode?: 'full' | 'payment'; topNodes?: number; password?: string } + options?: { mode?: 'full' | 'payment'; topNodes?: number; includeNewDays?: number; password?: string } ): Promise => { const isPaymentMode = options?.mode === 'payment' const params = new URLSearchParams() @@ -313,6 +313,9 @@ export const pointsApi = { if (options?.topNodes && options.topNodes > 0) { params.set('topNodes', options.topNodes.toString()) } + if (options?.includeNewDays && options.includeNewDays > 0) { + params.set('includeNewDays', options.includeNewDays.toString()) + } if (options?.password) { params.set('password', options.password) } From 241e2ca0762b90436f482343a4f992d84ade20f4 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 14:04:42 +0000 Subject: [PATCH 04/77] fix: redirect docs.peanut.me to /en/help docs.peanut.me was redirecting to peanut.me (homepage) instead of /en/help. Add host-based redirect to route docs subdomain traffic to the help center. --- redirects.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/redirects.json b/redirects.json index e771b321d..7d110d33c 100644 --- a/redirects.json +++ b/redirects.json @@ -19,6 +19,12 @@ "destination": "/en/help", "permanent": true }, + { + "source": "/:path*", + "has": [{ "type": "host", "value": "docs.peanut.me" }], + "destination": "https://peanut.me/en/help", + "permanent": true + }, { "source": "/packet", "destination": "https://github.com/peanutprotocol/peanut-ui/tree/archive/legacy-peanut-to", From 1d04ee1927ee2d20b5e90356eccbf4f31b040c02 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 10 Mar 2026 14:14:32 +0000 Subject: [PATCH 05/77] fix: remove broken SEO footer links, kill React fallbacks, update content submodule - SEOFooter: filter hardcoded link lists against actual COUNTRIES_SEO/ COMPETITORS/EXCHANGES data so broken links never render - deposit/compare/receive-money-from pages: remove React fallback rendering paths (all content now has MDX), require MDX or 404 - Delete dead code: ComparisonTable.tsx, ReceiveMoneyContent.tsx - Update content submodule to include 32 new content files (8 country hubs, 8 send-to pages, 15 exchange deposit guides, 1 Chile entity) --- .../(marketing)/compare/[slug]/page.tsx | 137 +++------------ .../(marketing)/deposit/[exchange]/page.tsx | 165 +++--------------- .../receive-money-from/[country]/page.tsx | 34 ++-- src/components/LandingPage/SEOFooter.tsx | 26 ++- src/components/Marketing/ComparisonTable.tsx | 32 ---- .../Marketing/pages/ReceiveMoneyContent.tsx | 143 --------------- src/content | 2 +- 7 files changed, 88 insertions(+), 451 deletions(-) delete mode 100644 src/components/Marketing/ComparisonTable.tsx delete mode 100644 src/components/Marketing/pages/ReceiveMoneyContent.tsx diff --git a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx index 019be7cb6..c2dab8b29 100644 --- a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx @@ -2,16 +2,8 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' import { generateMetadata as metadataHelper } from '@/app/metadata' import { COMPETITORS } from '@/data/seo' -import { MarketingHero } from '@/components/Marketing/MarketingHero' -import { MarketingShell } from '@/components/Marketing/MarketingShell' -import { Section } from '@/components/Marketing/Section' -import { ComparisonTable } from '@/components/Marketing/ComparisonTable' -import { FAQSection } from '@/components/Marketing/FAQSection' -import { JsonLd } from '@/components/Marketing/JsonLd' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' -import type { Locale } from '@/i18n/types' -import { getTranslations, t, localizedPath } from '@/i18n' -import { RelatedPages } from '@/components/Marketing/RelatedPages' +import { getTranslations } from '@/i18n' import { ContentPage } from '@/components/Marketing/ContentPage' import { readPageContentLocalized, type ContentFrontmatter } from '@/lib/content' import { renderContent } from '@/lib/mdx' @@ -41,31 +33,15 @@ export async function generateMetadata({ params }: PageProps): Promise const competitor = COMPETITORS[slug] if (!competitor) return {} - // Try MDX content frontmatter first const mdxContent = readPageContentLocalized('compare', slug, locale) - if (mdxContent && mdxContent.frontmatter.published !== false) { - return { - ...metadataHelper({ - title: mdxContent.frontmatter.title, - description: mdxContent.frontmatter.description, - canonical: `/${locale}/compare/peanut-vs-${slug}`, - dynamicOg: true, - }), - alternates: { - canonical: `/${locale}/compare/peanut-vs-${slug}`, - languages: getAlternates('compare', `peanut-vs-${slug}`), - }, - } - } - - // Fallback: i18n-based metadata - const year = new Date().getFullYear() + if (!mdxContent || mdxContent.frontmatter.published === false) return {} return { ...metadataHelper({ - title: `Peanut vs ${competitor.name} ${year} | Peanut`, - description: `Peanut vs ${competitor.name}: ${competitor.tagline}`, + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, canonical: `/${locale}/compare/peanut-vs-${slug}`, + dynamicOg: true, }), alternates: { canonical: `/${locale}/compare/peanut-vs-${slug}`, @@ -83,90 +59,31 @@ export default async function ComparisonPageLocalized({ params }: PageProps) { const competitor = COMPETITORS[slug] if (!competitor) notFound() - // Try MDX content first const mdxSource = readPageContentLocalized('compare', slug, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const url = `/${locale}/compare/peanut-vs-${slug}` - return ( - - {content} - - ) - } + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() - // Fallback: old React-driven page - const i18n = getTranslations(locale as Locale) - const year = new Date().getFullYear() - - const breadcrumbSchema = { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: [ - { '@type': 'ListItem', position: 1, name: i18n.home, item: 'https://peanut.me' }, - { - '@type': 'ListItem', - position: 2, - name: `Peanut vs ${competitor.name}`, - item: `https://peanut.me/${locale}/compare/peanut-vs-${slug}`, - }, - ], - } + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/compare/peanut-vs-${slug}` return ( - <> - - - - - -
- -
- -
-

{competitor.verdict}

-
- - - - {/* Related comparisons */} - s !== slug) - .slice(0, 5) - .map(([s, c]) => ({ - title: `Peanut vs ${c.name} [${year}]`, - href: localizedPath('compare', locale, `peanut-vs-${s}`), - }))} - /> - - {/* Last updated */} -

- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })} -

-
- + + {content} + ) } diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx index 5c775ba7b..8db8e6259 100644 --- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx +++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx @@ -2,17 +2,8 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' import { generateMetadata as metadataHelper } from '@/app/metadata' import { EXCHANGES } from '@/data/seo' -import { MarketingHero } from '@/components/Marketing/MarketingHero' -import { MarketingShell } from '@/components/Marketing/MarketingShell' -import { Section } from '@/components/Marketing/Section' -import { Steps } from '@/components/Marketing/Steps' -import { FAQSection } from '@/components/Marketing/FAQSection' -import { JsonLd } from '@/components/Marketing/JsonLd' -import { Card } from '@/components/0_Bruddle/Card' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' -import type { Locale } from '@/i18n/types' -import { getTranslations, t, localizedPath } from '@/i18n' -import { RelatedPages } from '@/components/Marketing/RelatedPages' +import { getTranslations } from '@/i18n' import { ContentPage } from '@/components/Marketing/ContentPage' import { readPageContentLocalized, type ContentFrontmatter } from '@/lib/content' import { renderContent } from '@/lib/mdx' @@ -44,31 +35,15 @@ export async function generateMetadata({ params }: PageProps): Promise const ex = EXCHANGES[exchange] if (!ex) return {} - // Try MDX content frontmatter first const mdxContent = readPageContentLocalized('deposit', exchange, locale) - if (mdxContent && mdxContent.frontmatter.published !== false) { - return { - ...metadataHelper({ - title: mdxContent.frontmatter.title, - description: mdxContent.frontmatter.description, - canonical: `/${locale}/deposit/from-${exchange}`, - dynamicOg: true, - }), - alternates: { - canonical: `/${locale}/deposit/from-${exchange}`, - languages: getAlternates('deposit', `from-${exchange}`), - }, - } - } - - // Fallback: i18n-based metadata - const i18n = getTranslations(locale as Locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} return { ...metadataHelper({ - title: `${t(i18n.depositFrom, { exchange: ex.name })} | Peanut`, - description: `${t(i18n.depositFrom, { exchange: ex.name })}. ${i18n.recommendedNetwork}: ${ex.recommendedNetwork}.`, + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, canonical: `/${locale}/deposit/from-${exchange}`, + dynamicOg: true, }), alternates: { canonical: `/${locale}/deposit/from-${exchange}`, @@ -86,117 +61,31 @@ export default async function DepositPageLocalized({ params }: PageProps) { const ex = EXCHANGES[exchange] if (!ex) notFound() - // Try MDX content first const mdxSource = readPageContentLocalized('deposit', exchange, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const url = `/${locale}/deposit/from-${exchange}` - return ( - - {content} - - ) - } - - // Fallback: old React-driven page - const i18n = getTranslations(locale as Locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() - const steps = ex.steps.map((step, i) => ({ - title: `${i + 1}`, - description: step, - })) - - const howToSchema = { - '@context': 'https://schema.org', - '@type': 'HowTo', - name: t(i18n.depositFrom, { exchange: ex.name }), - inLanguage: locale, - step: steps.map((step, i) => ({ - '@type': 'HowToStep', - position: i + 1, - name: step.title, - text: step.description, - })), - } + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/deposit/from-${exchange}` return ( - <> - - - - - -
-
- {[ - { label: i18n.recommendedNetwork, value: ex.recommendedNetwork }, - { label: i18n.withdrawalFee, value: ex.withdrawalFee }, - { label: i18n.processingTime, value: ex.processingTime }, - ].map((item) => ( - - {item.label} - {item.value} - - ))} -
-
- -
- -
- - {ex.troubleshooting.length > 0 && ( -
-
- {ex.troubleshooting.map((item, i) => ( - -

{item.issue}

-

{item.fix}

-
- ))} -
-
- )} - - - - {/* Related deposit guides */} - slug !== exchange) - .slice(0, 5) - .map(([slug, e]) => ({ - title: t(i18n.depositFrom, { exchange: e.name }), - href: localizedPath('deposit', locale, `from-${slug}`), - }))} - /> - - {/* Last updated */} -

- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })} -

-
- + + {content} + ) } diff --git a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx index e650c1652..797c36741 100644 --- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx @@ -5,7 +5,6 @@ import { CORRIDORS, getCountryName } from '@/data/seo' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' import type { Locale } from '@/i18n/types' import { getTranslations, t } from '@/i18n' -import { ReceiveMoneyContent } from '@/components/Marketing/pages/ReceiveMoneyContent' import { ContentPage } from '@/components/Marketing/ContentPage' import { readPageContentLocalized } from '@/lib/content' import { renderContent } from '@/lib/mdx' @@ -51,24 +50,21 @@ export default async function ReceiveMoneyPage({ params }: PageProps) { if (!isValidLocale(locale)) notFound() if (!getReceiveSources().includes(country)) notFound() - // Try MDX content first (future-proofing — no content files exist yet) const mdxSource = readPageContentLocalized('receive-from', country, locale) - if (mdxSource && mdxSource.frontmatter.published !== false) { - const { content } = await renderContent(mdxSource.body) - const i18n = getTranslations(locale) - const countryName = getCountryName(country, locale) - return ( - - {content} - - ) - } + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const countryName = getCountryName(country, locale) - // Fallback: old React-driven page - return + return ( + + {content} + + ) } diff --git a/src/components/LandingPage/SEOFooter.tsx b/src/components/LandingPage/SEOFooter.tsx index e8f9df90e..b7ed23906 100644 --- a/src/components/LandingPage/SEOFooter.tsx +++ b/src/components/LandingPage/SEOFooter.tsx @@ -1,17 +1,22 @@ import Link from 'next/link' +import { + COUNTRIES_SEO, + COMPETITORS as COMPETITORS_SEO, + EXCHANGES as EXCHANGES_SEO, +} from '@/data/seo' // Curated "seed list" for Google crawl discovery. Renders below the main footer // on non-marketing pages (homepage, /exchange, /lp, etc.). Marketing pages don't // need this — they already have RelatedPages + CountryGrid linking to sibling content. // -// This list is intentionally static and small. New countries/exchanges/competitors -// are discovered by Google via in-page links on the pages listed here. Only update -// this when a new content *category* is added or top markets shift significantly. +// The lists below define PRIORITY ORDER only. Each is filtered at render time +// against the actual SEO data (@/data/seo) so we never link to a country, +// competitor, or exchange that doesn't have published content. // -// Data is inlined (not imported from @/data/seo) to avoid pulling in fs-dependent -// modules that can't be bundled for the client. +// This is a server component, so importing the fs-dependent @/data/seo modules +// is safe. Only update the priority lists when top markets shift significantly. -const TOP_COUNTRIES: Array<{ slug: string; name: string }> = [ +const TOP_COUNTRIES_PRIORITY: Array<{ slug: string; name: string }> = [ { slug: 'argentina', name: 'Argentina' }, { slug: 'brazil', name: 'Brazil' }, { slug: 'mexico', name: 'Mexico' }, @@ -22,7 +27,7 @@ const TOP_COUNTRIES: Array<{ slug: string; name: string }> = [ { slug: 'chile', name: 'Chile' }, ] -const COMPETITORS: Array<{ slug: string; name: string }> = [ +const COMPETITORS_PRIORITY: Array<{ slug: string; name: string }> = [ { slug: 'wise', name: 'Wise' }, { slug: 'western-union', name: 'Western Union' }, { slug: 'paypal', name: 'PayPal' }, @@ -30,13 +35,18 @@ const COMPETITORS: Array<{ slug: string; name: string }> = [ { slug: 'binance-p2p', name: 'Binance P2P' }, ] -const EXCHANGES: Array<{ slug: string; name: string }> = [ +const EXCHANGES_PRIORITY: Array<{ slug: string; name: string }> = [ { slug: 'binance', name: 'Binance' }, { slug: 'coinbase', name: 'Coinbase' }, { slug: 'bybit', name: 'Bybit' }, { slug: 'kraken', name: 'Kraken' }, ] +// Filter to only entries with published content +const TOP_COUNTRIES = TOP_COUNTRIES_PRIORITY.filter(({ slug }) => slug in COUNTRIES_SEO) +const COMPETITORS = COMPETITORS_PRIORITY.filter(({ slug }) => slug in COMPETITORS_SEO) +const EXCHANGES = EXCHANGES_PRIORITY.filter(({ slug }) => slug in EXCHANGES_SEO) + export function SEOFooter() { return (