From 25f310db5931ce417e475e3ee5fe1856352ff5e4 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 16 Feb 2026 16:37:40 +0000
Subject: [PATCH 01/61] style: bring marketing/SEO pages in line with Peanut
design system
Marketing pages looked like a different product from /lp and /quests.
Upgrades 9 shared components so all ~350 programmatic pages inherit
the brand treatment: CloudsCss hero backgrounds, marquee strips,
solid shadow-4 cards, bolder typography, and higher-contrast text.
---
src/app/[locale]/(marketing)/layout.tsx | 36 +++++++++++++
src/components/Marketing/BlogCard.tsx | 28 ++++++++++
src/components/Marketing/ComparisonTable.tsx | 32 +++++++++++
src/components/Marketing/DestinationGrid.tsx | 56 ++++++++++++++++++++
src/components/Marketing/FAQSection.tsx | 40 ++++++++++++++
src/components/Marketing/MarketingHero.tsx | 49 +++++++++++++++++
src/components/Marketing/RelatedPages.tsx | 25 +++++++++
src/components/Marketing/Section.tsx | 14 +++++
src/components/Marketing/Steps.tsx | 25 +++++++++
9 files changed, 305 insertions(+)
create mode 100644 src/app/[locale]/(marketing)/layout.tsx
create mode 100644 src/components/Marketing/BlogCard.tsx
create mode 100644 src/components/Marketing/ComparisonTable.tsx
create mode 100644 src/components/Marketing/DestinationGrid.tsx
create mode 100644 src/components/Marketing/FAQSection.tsx
create mode 100644 src/components/Marketing/MarketingHero.tsx
create mode 100644 src/components/Marketing/RelatedPages.tsx
create mode 100644 src/components/Marketing/Section.tsx
create mode 100644 src/components/Marketing/Steps.tsx
diff --git a/src/app/[locale]/(marketing)/layout.tsx b/src/app/[locale]/(marketing)/layout.tsx
new file mode 100644
index 000000000..cb3c8efc3
--- /dev/null
+++ b/src/app/[locale]/(marketing)/layout.tsx
@@ -0,0 +1,36 @@
+import { notFound } from 'next/navigation'
+import { isValidLocale, SUPPORTED_LOCALES } from '@/i18n/config'
+import { MarketingNav } from '@/components/Marketing/MarketingNav'
+import Footer from '@/components/LandingPage/Footer'
+import { MarqueeComp } from '@/components/Global/MarqueeWrapper'
+import { HandThumbsUp } from '@/assets'
+
+interface LayoutProps {
+ children: React.ReactNode
+ params: Promise<{ locale: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.map((locale) => ({ locale }))
+}
+
+export default async function LocalizedMarketingLayout({ children, params }: LayoutProps) {
+ const { locale } = await params
+
+ if (!isValidLocale(locale)) {
+ notFound()
+ }
+
+ return (
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/src/components/Marketing/BlogCard.tsx b/src/components/Marketing/BlogCard.tsx
new file mode 100644
index 000000000..f7025add2
--- /dev/null
+++ b/src/components/Marketing/BlogCard.tsx
@@ -0,0 +1,28 @@
+import Link from 'next/link'
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface BlogCardProps {
+ slug: string
+ title: string
+ excerpt: string
+ date: string
+ category?: string
+ hrefPrefix?: string
+}
+
+export function BlogCard({ slug, title, excerpt, date, category, hrefPrefix = '/blog' }: BlogCardProps) {
+ return (
+
+
+ {category && (
+
+ {category}
+
+ )}
+ {title}
+ {excerpt}
+ {date}
+
+
+ )
+}
diff --git a/src/components/Marketing/ComparisonTable.tsx b/src/components/Marketing/ComparisonTable.tsx
new file mode 100644
index 000000000..abe0bca8f
--- /dev/null
+++ b/src/components/Marketing/ComparisonTable.tsx
@@ -0,0 +1,32 @@
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface ComparisonTableProps {
+ peanutName?: string
+ competitorName: string
+ rows: Array<{ feature: string; peanut: string; competitor: string }>
+}
+
+export function ComparisonTable({ peanutName = 'Peanut', competitorName, rows }: ComparisonTableProps) {
+ return (
+
+
+
+
+ Feature
+ {peanutName}
+ {competitorName}
+
+
+
+ {rows.map((row, i) => (
+
+ {row.feature}
+ {row.peanut}
+ {row.competitor}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
new file mode 100644
index 000000000..f512c4155
--- /dev/null
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -0,0 +1,56 @@
+import Link from 'next/link'
+import { Card } from '@/components/0_Bruddle/Card'
+import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
+import countryCurrencyMappings, { getFlagUrl } from '@/constants/countryCurrencyMapping'
+import { localizedPath } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+
+interface DestinationGridProps {
+ /** If provided, only show these country slugs */
+ countries?: string[]
+ title?: string
+ locale?: Locale
+}
+
+export function DestinationGrid({ countries, title = 'Send money to', locale = 'en' }: DestinationGridProps) {
+ const slugs = countries ?? Object.keys(COUNTRIES_SEO)
+
+ return (
+
+ {title && {title} }
+
+ {slugs.map((slug) => {
+ const seo = COUNTRIES_SEO[slug]
+ if (!seo) return null
+
+ const mapping = countryCurrencyMappings.find(
+ (m) => m.path === slug || m.country.toLowerCase().replace(/ /g, '-') === slug
+ )
+
+ const countryName = getCountryName(slug, locale)
+ const flagCode = mapping?.flagCode
+
+ return (
+
+
+ {flagCode && (
+
+ )}
+
+ {countryName}
+ →
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/Marketing/FAQSection.tsx b/src/components/Marketing/FAQSection.tsx
new file mode 100644
index 000000000..a9a0cb15c
--- /dev/null
+++ b/src/components/Marketing/FAQSection.tsx
@@ -0,0 +1,40 @@
+import { JsonLd } from './JsonLd'
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface FAQSectionProps {
+ faqs: Array<{ q: string; a: string }>
+ title?: string
+}
+
+export function FAQSection({ faqs, title = 'Frequently Asked Questions' }: FAQSectionProps) {
+ const faqSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: faqs.map((faq) => ({
+ '@type': 'Question',
+ name: faq.q,
+ acceptedAnswer: {
+ '@type': 'Answer',
+ text: faq.a,
+ },
+ })),
+ }
+
+ return (
+
+ {title}
+
+ {faqs.map((faq, i) => (
+
+
+ {faq.q}
+ +
+
+ {faq.a}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/Marketing/MarketingHero.tsx b/src/components/Marketing/MarketingHero.tsx
new file mode 100644
index 000000000..13b436de3
--- /dev/null
+++ b/src/components/Marketing/MarketingHero.tsx
@@ -0,0 +1,49 @@
+import Title from '@/components/0_Bruddle/Title'
+import Link from 'next/link'
+import { CloudsCss } from '@/components/LandingPage/CloudsCss'
+import { MarqueeComp } from '@/components/Global/MarqueeWrapper'
+import { HandThumbsUp } from '@/assets'
+
+const marketingClouds = [
+ { top: '15%', width: 160, speed: '45s', direction: 'ltr' as const },
+ { top: '55%', width: 180, speed: '50s', direction: 'rtl' as const },
+ { top: '85%', width: 150, speed: '48s', direction: 'ltr' as const, delay: '8s' },
+]
+
+interface MarketingHeroProps {
+ title: string
+ subtitle: string
+ ctaText?: string
+ ctaHref?: string
+}
+
+export function MarketingHero({ title, subtitle, ctaText = 'Get Started', ctaHref = '/home' }: MarketingHeroProps) {
+ return (
+ <>
+
+
+
+
+
+
+
{subtitle}
+ {ctaText && (
+
+
+ {ctaText}
+
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/src/components/Marketing/RelatedPages.tsx b/src/components/Marketing/RelatedPages.tsx
new file mode 100644
index 000000000..aaf9fdd55
--- /dev/null
+++ b/src/components/Marketing/RelatedPages.tsx
@@ -0,0 +1,25 @@
+import Link from 'next/link'
+
+interface RelatedPagesProps {
+ pages: Array<{ title: string; href: string }>
+ title?: string
+}
+
+export function RelatedPages({ pages, title = 'Related Pages' }: RelatedPagesProps) {
+ if (pages.length === 0) return null
+
+ return (
+
+ {title}
+
+ {pages.map((page) => (
+
+
+ {page.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Marketing/Section.tsx b/src/components/Marketing/Section.tsx
new file mode 100644
index 000000000..94bbda722
--- /dev/null
+++ b/src/components/Marketing/Section.tsx
@@ -0,0 +1,14 @@
+interface SectionProps {
+ title: string
+ children: React.ReactNode
+ id?: string
+}
+
+export function Section({ title, children, id }: SectionProps) {
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/Steps.tsx b/src/components/Marketing/Steps.tsx
new file mode 100644
index 000000000..badcb6cf9
--- /dev/null
+++ b/src/components/Marketing/Steps.tsx
@@ -0,0 +1,25 @@
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface StepsProps {
+ steps: Array<{ title: string; description: string }>
+}
+
+export function Steps({ steps }: StepsProps) {
+ return (
+
+ {steps.map((step, i) => (
+
+
+
+ {i + 1}
+
+
+
{step.title}
+
{step.description}
+
+
+
+ ))}
+
+ )
+}
From 3fbc44906deed1adf9e83fa4c9d1e01c82f06693 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 16 Feb 2026 16:51:28 +0000
Subject: [PATCH 02/61] feat: programmatic SEO pages, i18n, DS showcase, LP
refactor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add localized marketing pages (350+ routes across en/es/pt):
- Country hub, corridor, from-to, compare, convert, deposit,
pay-with, receive-money, blog, and team pages
- i18n framework with locale-aware content and hreflang tags
- SEO data layer (corridors, exchanges, competitors, countries)
- JSON-LD structured data (BreadcrumbList, HowTo, FAQPage, etc.)
- Blog engine with markdown/frontmatter support
Design system and LP improvements:
- DS showcase at /dev/ds with primitives, tokens, and patterns
- CloudsCss pure-CSS cloud animations (no JS, SSR-safe)
- LP refactor: LandingPageShell, LandingPageClient, AnimateOnView
- Button/Card/Input primitive refinements
Code quality fixes from review:
- Fix XSS in JsonLd component (escape in JSON output)
- Extract findMappingBySlug() helper (DRY, was duplicated 10x)
- Fix DS primitive count (10→9)
---
docs/TODO-SEO.md | 51 +++
package.json | 3 +
pnpm-lock.yaml | 372 ++++++++++++++++++
public/llms-full.txt | 77 ++++
public/llms.txt | 27 ++
src/app/(mobile-ui)/dev/components/page.tsx | 13 +-
.../dev/ds/_components/CatalogCard.tsx | 55 +++
.../dev/ds/_components/CodeBlock.tsx | 42 ++
.../dev/ds/_components/DesignNote.tsx | 24 ++
.../(mobile-ui)/dev/ds/_components/DoDont.tsx | 38 ++
.../dev/ds/_components/DocHeader.tsx | 21 +
.../dev/ds/_components/DocPage.tsx | 33 ++
.../dev/ds/_components/DocSection.tsx | 75 ++++
.../dev/ds/_components/DocSidebar.tsx | 95 +++++
.../dev/ds/_components/Playground.tsx | 111 ++++++
.../dev/ds/_components/PropsTable.tsx | 49 +++
.../dev/ds/_components/SectionDivider.tsx | 3 +
.../dev/ds/_components/StatusTag.tsx | 21 +
.../dev/ds/_components/TierNav.tsx | 32 ++
.../dev/ds/_components/WhenToUse.tsx | 37 ++
.../dev/ds/_components/nav-config.ts | 51 +++
.../dev/ds/_hooks/useHighlightedCode.ts | 39 ++
.../dev/ds/foundations/borders/page.tsx | 89 +++++
.../dev/ds/foundations/colors/page.tsx | 118 ++++++
.../dev/ds/foundations/icons/page.tsx | 83 ++++
.../(mobile-ui)/dev/ds/foundations/page.tsx | 61 +++
.../dev/ds/foundations/shadows/page.tsx | 73 ++++
.../dev/ds/foundations/spacing/page.tsx | 79 ++++
.../dev/ds/foundations/typography/page.tsx | 96 +++++
src/app/(mobile-ui)/dev/ds/layout.tsx | 37 ++
src/app/(mobile-ui)/dev/ds/page.tsx | 103 +++++
.../dev/ds/patterns/amount-input/page.tsx | 133 +++++++
.../dev/ds/patterns/cards-global/page.tsx | 182 +++++++++
.../dev/ds/patterns/copy-share/page.tsx | 204 ++++++++++
.../dev/ds/patterns/drawer/page.tsx | 127 ++++++
.../dev/ds/patterns/feedback/page.tsx | 224 +++++++++++
.../dev/ds/patterns/layouts/page.tsx | 268 +++++++++++++
.../dev/ds/patterns/loading/page.tsx | 124 ++++++
.../dev/ds/patterns/modal/page.tsx | 224 +++++++++++
.../dev/ds/patterns/navigation/page.tsx | 126 ++++++
src/app/(mobile-ui)/dev/ds/patterns/page.tsx | 90 +++++
.../dev/ds/primitives/base-input/page.tsx | 95 +++++
.../dev/ds/primitives/base-select/page.tsx | 80 ++++
.../dev/ds/primitives/button/page.tsx | 307 +++++++++++++++
.../dev/ds/primitives/card/page.tsx | 97 +++++
.../dev/ds/primitives/checkbox/page.tsx | 61 +++
.../dev/ds/primitives/divider/page.tsx | 54 +++
.../dev/ds/primitives/page-container/page.tsx | 47 +++
.../(mobile-ui)/dev/ds/primitives/page.tsx | 89 +++++
.../dev/ds/primitives/title/page.tsx | 54 +++
.../dev/ds/primitives/toast/page.tsx | 56 +++
src/app/(mobile-ui)/dev/page.tsx | 26 +-
.../[locale]/(marketing)/[country]/page.tsx | 48 +++
.../[locale]/(marketing)/blog/[slug]/page.tsx | 84 ++++
.../(marketing)/blog/category/[cat]/page.tsx | 99 +++++
src/app/[locale]/(marketing)/blog/page.tsx | 97 +++++
.../(marketing)/compare/[slug]/page.tsx | 119 ++++++
.../(marketing)/convert/[pair]/page.tsx | 139 +++++++
.../(marketing)/deposit/[exchange]/page.tsx | 148 +++++++
.../(marketing)/pay-with/[method]/page.tsx | 50 +++
.../receive-money-from/[country]/page.tsx | 51 +++
.../send-money-from/[from]/to/[to]/page.tsx | 54 +++
.../send-money-to/[country]/page.tsx | 54 +++
.../(marketing)/send-money-to/page.tsx | 71 ++++
src/app/[locale]/(marketing)/team/page.tsx | 112 ++++++
src/app/layout.tsx | 58 +++
src/app/lp/card/CardLandingPage.tsx | 34 +-
src/app/lp/card/page.tsx | 9 +-
src/app/lp/layout.tsx | 10 +
src/app/lp/page.tsx | 31 +-
src/app/metadata.ts | 4 +
src/app/page.tsx | 250 ++----------
src/app/robots.ts | 70 +++-
src/app/sitemap.ts | 116 +++++-
src/components/0_Bruddle/BaseInput.tsx | 3 +
src/components/0_Bruddle/BaseSelect.tsx | 1 +
src/components/0_Bruddle/Button.tsx | 127 +-----
src/components/0_Bruddle/Card.tsx | 3 +-
src/components/0_Bruddle/Checkbox.tsx | 3 +
src/components/0_Bruddle/CloudsBackground.tsx | 3 +
src/components/0_Bruddle/Divider.tsx | 3 +
src/components/0_Bruddle/PageContainer.tsx | 3 +
src/components/0_Bruddle/Title.tsx | 3 +
src/components/Global/AnimateOnView.tsx | 59 +++
.../Global/FooterVisibilityObserver.tsx | 31 ++
src/components/LandingPage/CloudsCss.tsx | 41 ++
.../LandingPage/LandingPageClient.tsx | 190 +++++++++
.../LandingPage/LandingPageShell.tsx | 10 +
src/components/LandingPage/Manteca.tsx | 97 ++---
src/components/LandingPage/RegulatedRails.tsx | 86 +---
.../LandingPage/SendInSecondsCTA.tsx | 27 ++
src/components/LandingPage/hero.tsx | 79 ++--
src/components/LandingPage/imageAssets.tsx | 124 ------
src/components/LandingPage/landingPageData.ts | 54 +++
src/components/LandingPage/sendInSeconds.tsx | 193 ++-------
src/components/Marketing/DestinationGrid.tsx | 6 +-
src/components/Marketing/JsonLd.tsx | 12 +
src/components/Marketing/MarketingNav.tsx | 19 +
src/components/Marketing/MarketingShell.tsx | 8 +
src/components/Marketing/index.ts | 10 +
.../Marketing/pages/CorridorPageContent.tsx | 173 ++++++++
.../Marketing/pages/FromToCorridorContent.tsx | 262 ++++++++++++
.../Marketing/pages/HubPageContent.tsx | 275 +++++++++++++
.../Marketing/pages/PayWithContent.tsx | 108 +++++
.../Marketing/pages/ReceiveMoneyContent.tsx | 145 +++++++
src/constants/countryCurrencyMapping.ts | 7 +
src/constants/routes.ts | 36 ++
src/content | 1 +
src/data/seo/comparisons.ts | 56 +++
src/data/seo/convert.ts | 27 ++
src/data/seo/corridors.ts | 96 +++++
src/data/seo/exchanges.ts | 62 +++
src/data/seo/index.ts | 13 +
src/data/seo/payment-methods.ts | 57 +++
src/data/team.ts | 50 +++
src/hooks/useLongPress.ts | 117 ++++++
src/i18n/config.ts | 60 +++
src/i18n/en.json | 63 +++
src/i18n/es.json | 63 +++
src/i18n/index.ts | 34 ++
src/i18n/pt.json | 63 +++
src/i18n/types.ts | 97 +++++
src/lib/blog.ts | 95 +++++
src/lib/content.ts | 69 ++++
src/lib/seo/schemas.tsx | 55 +++
src/styles/globals.css | 69 ++++
126 files changed, 8868 insertions(+), 864 deletions(-)
create mode 100644 docs/TODO-SEO.md
create mode 100644 public/llms-full.txt
create mode 100644 public/llms.txt
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DesignNote.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DocHeader.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DocPage.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DocSection.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/DocSidebar.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/Playground.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/SectionDivider.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/StatusTag.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/TierNav.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/WhenToUse.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/_components/nav-config.ts
create mode 100644 src/app/(mobile-ui)/dev/ds/_hooks/useHighlightedCode.ts
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/layout.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/patterns/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/button/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
create mode 100644 src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
create mode 100644 src/app/[locale]/(marketing)/[country]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/blog/[slug]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/blog/page.tsx
create mode 100644 src/app/[locale]/(marketing)/compare/[slug]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/convert/[pair]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
create mode 100644 src/app/[locale]/(marketing)/send-money-to/page.tsx
create mode 100644 src/app/[locale]/(marketing)/team/page.tsx
create mode 100644 src/app/lp/layout.tsx
create mode 100644 src/components/Global/AnimateOnView.tsx
create mode 100644 src/components/Global/FooterVisibilityObserver.tsx
create mode 100644 src/components/LandingPage/CloudsCss.tsx
create mode 100644 src/components/LandingPage/LandingPageClient.tsx
create mode 100644 src/components/LandingPage/LandingPageShell.tsx
create mode 100644 src/components/LandingPage/SendInSecondsCTA.tsx
delete mode 100644 src/components/LandingPage/imageAssets.tsx
create mode 100644 src/components/LandingPage/landingPageData.ts
create mode 100644 src/components/Marketing/JsonLd.tsx
create mode 100644 src/components/Marketing/MarketingNav.tsx
create mode 100644 src/components/Marketing/MarketingShell.tsx
create mode 100644 src/components/Marketing/index.ts
create mode 100644 src/components/Marketing/pages/CorridorPageContent.tsx
create mode 100644 src/components/Marketing/pages/FromToCorridorContent.tsx
create mode 100644 src/components/Marketing/pages/HubPageContent.tsx
create mode 100644 src/components/Marketing/pages/PayWithContent.tsx
create mode 100644 src/components/Marketing/pages/ReceiveMoneyContent.tsx
create mode 120000 src/content
create mode 100644 src/data/seo/comparisons.ts
create mode 100644 src/data/seo/convert.ts
create mode 100644 src/data/seo/corridors.ts
create mode 100644 src/data/seo/exchanges.ts
create mode 100644 src/data/seo/index.ts
create mode 100644 src/data/seo/payment-methods.ts
create mode 100644 src/data/team.ts
create mode 100644 src/hooks/useLongPress.ts
create mode 100644 src/i18n/config.ts
create mode 100644 src/i18n/en.json
create mode 100644 src/i18n/es.json
create mode 100644 src/i18n/index.ts
create mode 100644 src/i18n/pt.json
create mode 100644 src/i18n/types.ts
create mode 100644 src/lib/blog.ts
create mode 100644 src/lib/content.ts
create mode 100644 src/lib/seo/schemas.tsx
diff --git a/docs/TODO-SEO.md b/docs/TODO-SEO.md
new file mode 100644
index 000000000..e7114ec89
--- /dev/null
+++ b/docs/TODO-SEO.md
@@ -0,0 +1,51 @@
+# SEO TODOs
+
+## Content Tasks (Marketer)
+
+### Convert Pages — Editorial Content
+Each `/convert/{pair}` page needs 300+ words of unique editorial content to avoid thin content flags.
+Include: currency background, exchange rate trends, tips for getting best rates, common use cases.
+Reference: Wise convert pages for structure and depth.
+
+### Blog — Seed Posts
+Write 10-15 seed posts targeting hub topics:
+- One per major country (Argentina, Brazil, Mexico)
+- Cross-cutting guides ("cheapest way to send money internationally", "crypto vs wire transfer")
+- Use `peanut-content/reference/agent-workflow.md` as the generation playbook.
+
+### Payment Method Pages
+Expand `/pay-with/{method}` content for each payment method.
+Current placeholder content is thin. Each needs 500+ words.
+
+### Team Page
+Fill in real team member data in `src/data/team.ts`:
+- Real names, roles, bios
+- Headshots (400x400px WebP in /public/team/)
+- Social links (LinkedIn especially — builds E-E-A-T)
+
+## Engineering Tasks
+
+### Scroll-Depth CTAs
+TODO: Add mid-content CTA cards on long editorial pages (blog posts, corridor pages).
+Trigger: Insert CTA after 50% scroll or after the 3rd section.
+Design: Use Bruddle Card with `variant="purple"` Button.
+Purpose: Increase conversion from organic traffic on content-heavy pages.
+
+### Sitemap Submission on Deploy
+Add to Vercel build hook or post-deploy script:
+```bash
+curl "https://www.google.com/ping?sitemap=https://peanut.me/sitemap.xml"
+curl "https://www.bing.com/ping?sitemap=https://peanut.me/sitemap.xml"
+```
+Or use Google Search Console API for programmatic submission.
+
+### Help Center (Crisp)
+DNS only — add CNAME record:
+```
+help.peanut.me → [crisp-kb-domain]
+```
+Configure in Crisp Dashboard > Settings > Knowledge Base > Custom Domain.
+
+### Content Generation CI
+Set up a script/CI job that runs Konrad's agent workflow to generate/update content files.
+See `peanut-content/reference/agent-workflow.md`.
diff --git a/package.json b/package.json
index 4bfe240f7..5f0d7e502 100644
--- a/package.json
+++ b/package.json
@@ -60,10 +60,12 @@
"embla-carousel-react": "^8.6.0",
"ethers": "5.7.2",
"framer-motion": "^11.11.17",
+ "gray-matter": "^4.0.3",
"i18n-iso-countries": "^7.13.0",
"iban-to-bic": "^1.4.0",
"js-cookie": "^3.0.5",
"jsqr": "^1.4.0",
+ "marked": "^17.0.2",
"next": "16.0.10",
"nuqs": "^2.8.6",
"pix-utils": "^2.8.2",
@@ -79,6 +81,7 @@
"react-redux": "^9.2.0",
"react-tooltip": "^5.28.0",
"redux": "^5.0.1",
+ "shiki": "^3.22.0",
"siwe": "^2.3.2",
"tailwind-merge": "^1.14.0",
"tailwind-scrollbar": "^3.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0df89f257..49bad01fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -118,6 +118,9 @@ importers:
framer-motion:
specifier: ^11.11.17
version: 11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ gray-matter:
+ specifier: ^4.0.3
+ version: 4.0.3
i18n-iso-countries:
specifier: ^7.13.0
version: 7.14.0
@@ -130,6 +133,9 @@ importers:
jsqr:
specifier: ^1.4.0
version: 1.4.0
+ marked:
+ specifier: ^17.0.2
+ version: 17.0.2
next:
specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -175,6 +181,9 @@ importers:
redux:
specifier: ^5.0.1
version: 5.0.1
+ shiki:
+ specifier: ^3.22.0
+ version: 3.22.0
siwe:
specifier: ^2.3.2
version: 2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))
@@ -2627,6 +2636,27 @@ packages:
typescript:
optional: true
+ '@shikijs/core@3.22.0':
+ resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==}
+
+ '@shikijs/engine-javascript@3.22.0':
+ resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==}
+
+ '@shikijs/engine-oniguruma@3.22.0':
+ resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==}
+
+ '@shikijs/langs@3.22.0':
+ resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==}
+
+ '@shikijs/themes@3.22.0':
+ resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==}
+
+ '@shikijs/types@3.22.0':
+ resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
+
+ '@shikijs/vscode-textmate@10.0.2':
+ resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
'@simplewebauthn/browser@8.3.7':
resolution: {integrity: sha512-ZtRf+pUEgOCvjrYsbMsJfiHOdKcrSZt2zrAnIIpfmA06r0FxBovFYq0rJ171soZbe13KmWzAoLKjSxVW7KxCdQ==}
@@ -2810,6 +2840,9 @@ packages:
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
'@types/istanbul-lib-coverage@2.0.6':
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
@@ -2834,6 +2867,9 @@ packages:
'@types/lodash@4.17.23':
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -2889,6 +2925,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -2904,6 +2943,9 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
'@vercel/analytics@1.6.1':
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
peerDependencies:
@@ -3513,6 +3555,9 @@ packages:
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
@@ -3525,6 +3570,12 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -3592,6 +3643,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
commander@12.0.0:
resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==}
engines: {node: '>=18'}
@@ -3851,6 +3905,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
devtools-protocol@0.0.1495869:
resolution: {integrity: sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==}
@@ -4093,6 +4150,10 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
extension-port-stream@3.0.0:
resolution: {integrity: sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==}
engines: {node: '>=12.0.0'}
@@ -4324,6 +4385,10 @@ packages:
resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ gray-matter@4.0.3:
+ resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
+ engines: {node: '>=6.0'}
+
gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
@@ -4358,6 +4423,12 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-to-html@9.0.5:
+ resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
@@ -4371,6 +4442,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ html-void-elements@3.0.0:
+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -4481,6 +4555,10 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -4789,6 +4867,10 @@ packages:
keyvaluestorage-interface@1.0.0:
resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==}
+ kind-of@6.0.3:
+ resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+ engines: {node: '>=0.10.0'}
+
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -4897,10 +4979,18 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
+ marked@17.0.2:
+ resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -4911,6 +5001,21 @@ packages:
micro-ftch@0.3.1:
resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==}
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -5124,6 +5229,12 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ oniguruma-parser@0.12.1:
+ resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
+
+ oniguruma-to-es@4.3.4:
+ resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
+
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
@@ -5461,6 +5572,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
proxy-agent@6.5.0:
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
engines: {node: '>= 14'}
@@ -5676,6 +5790,15 @@ packages:
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+ regex-recursion@6.0.2:
+ resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+ regex-utilities@2.3.0:
+ resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+ regex@6.1.0:
+ resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -5768,6 +5891,10 @@ packages:
scrypt-js@3.0.1:
resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==}
+ section-matter@1.0.0:
+ resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
+ engines: {node: '>=4'}
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -5812,6 +5939,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
+ shiki@3.22.0:
+ resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==}
+
shimmer@1.2.1:
resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==}
@@ -5913,6 +6043,9 @@ packages:
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
@@ -5975,6 +6108,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -5983,6 +6119,10 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
+ strip-bom-string@1.0.0:
+ resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
+ engines: {node: '>=0.10.0'}
+
strip-bom@4.0.0:
resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
engines: {node: '>=8'}
@@ -6144,6 +6284,9 @@ packages:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -6233,6 +6376,21 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
@@ -6399,6 +6557,12 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
viem@2.45.0:
resolution: {integrity: sha512-iVA9qrAgRdtpWa80lCZ6Jri6XzmLOwwA1wagX2HnKejKeliFLpON0KOdyfqvcy+gUpBVP59LBxP2aKiL3aj8fg==}
peerDependencies:
@@ -6672,6 +6836,9 @@ packages:
use-sync-external-store:
optional: true
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -9932,6 +10099,39 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
+ '@shikijs/core@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+ hast-util-to-html: 9.0.5
+
+ '@shikijs/engine-javascript@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.4
+
+ '@shikijs/engine-oniguruma@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+
+ '@shikijs/langs@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+
+ '@shikijs/themes@3.22.0':
+ dependencies:
+ '@shikijs/types': 3.22.0
+
+ '@shikijs/types@3.22.0':
+ dependencies:
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ '@shikijs/vscode-textmate@10.0.2': {}
+
'@simplewebauthn/browser@8.3.7':
dependencies:
'@simplewebauthn/typescript-types': 8.3.4
@@ -10143,6 +10343,10 @@ snapshots:
dependencies:
'@types/node': 20.4.2
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/istanbul-lib-coverage@2.0.6': {}
'@types/istanbul-lib-report@3.0.3':
@@ -10170,6 +10374,10 @@ snapshots:
'@types/lodash@4.17.23': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
'@types/ms@2.1.0': {}
'@types/mysql@2.15.26':
@@ -10221,6 +10429,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
+ '@types/unist@3.0.3': {}
+
'@types/use-sync-external-store@0.0.6': {}
'@types/validator@13.15.10': {}
@@ -10236,6 +10446,8 @@ snapshots:
'@types/node': 20.4.2
optional: true
+ '@ungap/structured-clone@1.3.0': {}
+
'@vercel/analytics@1.6.1(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
optionalDependencies:
next: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -11474,6 +11686,8 @@ snapshots:
canvas-confetti@1.9.4: {}
+ ccount@2.0.1: {}
+
chalk@3.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -11486,6 +11700,10 @@ snapshots:
char-regex@1.0.2: {}
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -11552,6 +11770,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ comma-separated-tokens@2.0.3: {}
+
commander@12.0.0: {}
commander@2.20.3: {}
@@ -11785,6 +12005,10 @@ snapshots:
detect-node-es@1.1.0: {}
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
devtools-protocol@0.0.1495869: {}
diacritics@1.3.0: {}
@@ -12117,6 +12341,10 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
extension-port-stream@3.0.0:
dependencies:
readable-stream: 3.6.2
@@ -12367,6 +12595,13 @@ snapshots:
graphql@16.12.0: {}
+ gray-matter@4.0.3:
+ dependencies:
+ js-yaml: 3.14.2
+ kind-of: 6.0.3
+ section-matter: 1.0.0
+ strip-bom-string: 1.0.0
+
gzip-size@6.0.0:
dependencies:
duplexer: 0.1.2
@@ -12413,6 +12648,24 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-to-html@9.0.5:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ comma-separated-tokens: 2.0.3
+ hast-util-whitespace: 3.0.0
+ html-void-elements: 3.0.0
+ mdast-util-to-hast: 13.2.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ stringify-entities: 4.0.4
+ zwitch: 2.0.4
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
@@ -12429,6 +12682,8 @@ snapshots:
html-escaper@2.0.2: {}
+ html-void-elements@3.0.0: {}
+
http-proxy-agent@5.0.0:
dependencies:
'@tootallnate/once': 2.0.0
@@ -12537,6 +12792,8 @@ snapshots:
dependencies:
hasown: 2.0.2
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -13049,6 +13306,8 @@ snapshots:
keyvaluestorage-interface@1.0.0: {}
+ kind-of@6.0.3: {}
+
kleur@3.0.3: {}
knip@5.82.1(@types/node@20.4.2)(typescript@5.9.3):
@@ -13154,14 +13413,45 @@ snapshots:
dependencies:
tmpl: 1.0.5
+ marked@17.0.2: {}
+
math-intrinsics@1.1.0: {}
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
merge-stream@2.0.0: {}
merge2@1.4.1: {}
micro-ftch@0.3.1: {}
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -13323,6 +13613,14 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ oniguruma-parser@0.12.1: {}
+
+ oniguruma-to-es@4.3.4:
+ dependencies:
+ oniguruma-parser: 0.12.1
+ regex: 6.1.0
+ regex-recursion: 6.0.2
+
opener@1.5.2: {}
ox@0.11.3(typescript@5.9.3)(zod@3.22.4):
@@ -13639,6 +13937,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-information@7.1.0: {}
+
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
@@ -13867,6 +14167,16 @@ snapshots:
redux@5.0.1: {}
+ regex-recursion@6.0.2:
+ dependencies:
+ regex-utilities: 2.3.0
+
+ regex-utilities@2.3.0: {}
+
+ regex@6.1.0:
+ dependencies:
+ regex-utilities: 2.3.0
+
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -13948,6 +14258,11 @@ snapshots:
scrypt-js@3.0.1: {}
+ section-matter@1.0.0:
+ dependencies:
+ extend-shallow: 2.0.1
+ kind-of: 6.0.3
+
semver@6.3.1: {}
semver@7.7.3: {}
@@ -14018,6 +14333,17 @@ snapshots:
shebang-regex@3.0.0: {}
+ shiki@3.22.0:
+ dependencies:
+ '@shikijs/core': 3.22.0
+ '@shikijs/engine-javascript': 3.22.0
+ '@shikijs/engine-oniguruma': 3.22.0
+ '@shikijs/langs': 3.22.0
+ '@shikijs/themes': 3.22.0
+ '@shikijs/types': 3.22.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
shimmer@1.2.1: {}
side-channel-list@1.0.0:
@@ -14141,6 +14467,8 @@ snapshots:
dependencies:
whatwg-url: 7.1.0
+ space-separated-tokens@2.0.2: {}
+
split-on-first@1.1.0: {}
split2@4.2.0: {}
@@ -14212,6 +14540,11 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -14220,6 +14553,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-bom-string@1.0.0: {}
+
strip-bom@4.0.0: {}
strip-final-newline@2.0.0: {}
@@ -14403,6 +14738,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ trim-lines@3.0.1: {}
+
ts-interface-checker@0.1.13: {}
ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.9.3):
@@ -14471,6 +14808,29 @@ snapshots:
uncrypto@0.1.3: {}
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
universalify@0.2.0: {}
unplugin@1.0.1:
@@ -14586,6 +14946,16 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4):
dependencies:
'@noble/curves': 1.9.1
@@ -14899,3 +15269,5 @@ snapshots:
immer: 11.1.3
react: 19.2.4
use-sync-external-store: 1.4.0(react@19.2.4)
+
+ zwitch@2.0.4: {}
diff --git a/public/llms-full.txt b/public/llms-full.txt
new file mode 100644
index 000000000..906b896de
--- /dev/null
+++ b/public/llms-full.txt
@@ -0,0 +1,77 @@
+# Peanut — Full Product Description
+
+> Instant global peer-to-peer payments in digital dollars.
+
+## Overview
+
+Peanut is a peer-to-peer payments app that lets users send and receive money globally using digital dollars (USDC stablecoins). It provides a consumer-grade UX on top of blockchain infrastructure — users never need to understand crypto, manage wallets, or handle gas fees.
+
+## Key Features
+
+### Instant P2P Transfers
+Send digital dollars to any Peanut user instantly. No waiting for bank processing, no wire fees.
+
+### Payment Links
+Generate a shareable link containing funds. The recipient clicks the link to claim the money — no account needed. Links work across messaging apps, email, and social media.
+
+### Bank Cash-Out
+Connect a local bank account and convert digital dollars to local currency. Supported rails:
+- **Argentina**: Bank transfer, MercadoPago
+- **Brazil**: PIX, bank transfer
+- **Mexico**: SPEI, bank transfer
+- **Colombia**: Bank transfer
+- **Peru**: Bank transfer
+- **Bolivia**: Bank transfer (via Meru)
+
+### Crypto Deposit
+Fund your account by depositing crypto from any exchange (Coinbase, Binance, Kraken, Bybit, OKX, etc.) or external wallet.
+
+### Card Payments
+Physical and virtual debit cards for spending digital dollars at any merchant that accepts card payments.
+
+### QR Payments
+Generate and scan QR codes for in-person payments.
+
+## Security Model
+
+- **Self-custodied smart accounts**: User funds sit in ERC-4337 smart accounts, not on Peanut servers
+- **Biometric passkeys**: Account access is secured by the device's Secure Enclave (face/fingerprint). The private key never leaves the device
+- **No server-side keys**: Peanut cannot access, freeze, or move user funds — even under regulatory pressure
+- **Independent recovery**: If Peanut goes offline, users can recover access via any ERC-4337-compatible wallet
+
+## KYC / Compliance
+
+- Core features (send, receive, payment links) work without KYC
+- Bank connections trigger a one-time identity check via Persona (SOC2 Type 2, GDPR, ISO 27001)
+- Peanut only receives a pass/fail result — no documents stored on Peanut servers
+
+## Fee Structure
+
+- Peer-to-peer transfers: minimal fees
+- Bank cash-out: small conversion spread
+- No monthly subscription or account fees
+- Merchant payments planned with fees lower than Visa/Mastercard
+
+## Target Markets
+
+Primary focus on Latin America:
+- Argentina, Brazil, Mexico (largest markets)
+- Colombia, Peru, Bolivia, Chile, Ecuador
+
+Use cases: remittances, freelancer payments, cross-border transfers, savings in stable currency, merchant payments.
+
+## Technical Stack
+
+- Next.js web application (progressive web app)
+- ERC-4337 smart accounts on Base (Ethereum L2)
+- Biometric passkeys via WebAuthn / Secure Enclave
+- Licensed banking partners for fiat on/off ramps
+
+## Company
+
+- Founded by Konrad Kononenko and Hugo Montenegro
+- Based in Europe, serving Latin America
+- Website: https://peanut.me
+- Twitter: https://twitter.com/PeanutProtocol
+- GitHub: https://github.com/peanutprotocol
+- LinkedIn: https://www.linkedin.com/company/peanut-trade/
diff --git a/public/llms.txt b/public/llms.txt
new file mode 100644
index 000000000..eb344e2cc
--- /dev/null
+++ b/public/llms.txt
@@ -0,0 +1,27 @@
+# Peanut
+
+> Instant global peer-to-peer payments in digital dollars.
+
+Peanut is the easiest way to send digital dollars to anyone, anywhere. No banks, no borders — just fast, cheap money transfers.
+
+## What Peanut Does
+
+- **Send & receive money instantly** — peer-to-peer transfers powered by digital dollars (USDC)
+- **Cash out to local banks** — connect bank accounts in Argentina, Brazil, Mexico, and more
+- **No KYC required for core features** — send and receive without identity verification
+- **Self-custodied accounts** — your funds sit in your own smart account, secured by biometric passkeys
+- **Payment links** — share a link to send money to anyone, even without an account
+
+## Supported Corridors
+
+- Argentina (bank transfer, MercadoPago)
+- Brazil (PIX, bank transfer)
+- Mexico (SPEI, bank transfer)
+- Colombia, Peru, Bolivia, and more
+
+## Links
+
+- Website: https://peanut.me
+- Careers: https://peanut.me/careers
+- Support: https://peanut.me/support
+- Full description: https://peanut.me/llms-full.txt
diff --git a/src/app/(mobile-ui)/dev/components/page.tsx b/src/app/(mobile-ui)/dev/components/page.tsx
index 930ab1957..b87cb0970 100644
--- a/src/app/(mobile-ui)/dev/components/page.tsx
+++ b/src/app/(mobile-ui)/dev/components/page.tsx
@@ -275,7 +275,7 @@ export default function ComponentsPage() {
rows={[
[
'variant',
- 'purple | stroke | primary-soft | transparent | dark | transparent-dark | transparent-light | green | yellow',
+ 'purple | stroke | primary-soft | transparent | dark | transparent-dark | transparent-light',
'purple',
],
['size', 'small | medium | large', '(none = h-13)'],
@@ -328,21 +328,10 @@ export default function ComponentsPage() {
Label`} />
- {(['green', 'yellow'] as const).map((variant) => (
-
-
- {variant}
- 0 production usages
-
-
-
{variant}
-
- ))}
- xl and xl-fixed exist in code but have 0 usages anywhere
default
diff --git a/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx b/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
new file mode 100644
index 000000000..7a586e668
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import Link from 'next/link'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
+import { Card } from '@/components/0_Bruddle/Card'
+import { StatusTag } from './StatusTag'
+
+interface CatalogCardProps {
+ title: string
+ description: string
+ href: string
+ icon?: IconName
+ status?: 'production' | 'limited' | 'unused' | 'needs-refactor'
+ quality?: 1 | 2 | 3 | 4 | 5
+ usages?: number
+}
+
+export function CatalogCard({ title, description, href, icon, status, quality, usages }: CatalogCardProps) {
+ return (
+
+
+
+ {icon && (
+
+
+
+ )}
+
+
{title}
+
{description}
+
+ {status && }
+ {quality && (
+
+ {'★'.repeat(quality)}
+ {'☆'.repeat(5 - quality)}
+
+ )}
+ {usages !== undefined && (
+
+ {usages} usage{usages !== 1 ? 's' : ''}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+export function CatalogGrid({ children }: { children: React.ReactNode }) {
+ return
{children}
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx b/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
new file mode 100644
index 000000000..6573bac84
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { useState } from 'react'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { useHighlightedCode } from '../_hooks/useHighlightedCode'
+
+interface CodeBlockProps {
+ code: string
+ label?: string
+ language?: string
+}
+
+export function CodeBlock({ code, label, language = 'tsx' }: CodeBlockProps) {
+ const html = useHighlightedCode(code, language)
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+
+
+ {label && (
+ {label}
+ )}
+
+ {copied ? : }
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DesignNote.tsx b/src/app/(mobile-ui)/dev/ds/_components/DesignNote.tsx
new file mode 100644
index 000000000..e82a33622
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DesignNote.tsx
@@ -0,0 +1,24 @@
+import { Icon } from '@/components/Global/Icons/Icon'
+
+const styles = {
+ warning: {
+ container: 'border-yellow-1/40 bg-yellow-1/20',
+ icon: 'text-n-1',
+ iconName: 'alert' as const,
+ },
+ info: {
+ container: 'border-primary-3 bg-primary-3/20',
+ icon: 'text-n-1',
+ iconName: 'info' as const,
+ },
+}
+
+export function DesignNote({ type, children }: { type: 'warning' | 'info'; children: React.ReactNode }) {
+ const s = styles[type]
+ return (
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx b/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
new file mode 100644
index 000000000..a6f3565d4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
@@ -0,0 +1,38 @@
+import { Icon } from '@/components/Global/Icons/Icon'
+
+interface DoDontProps {
+ doExample: React.ReactNode
+ doLabel?: string
+ dontExample: React.ReactNode
+ dontLabel?: string
+}
+
+export function DoDont({
+ doExample,
+ doLabel = 'Do',
+ dontExample,
+ dontLabel = "Don't",
+}: DoDontProps) {
+ return (
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DocHeader.tsx b/src/app/(mobile-ui)/dev/ds/_components/DocHeader.tsx
new file mode 100644
index 000000000..55cf98bc0
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DocHeader.tsx
@@ -0,0 +1,21 @@
+import { StatusTag } from './StatusTag'
+
+interface DocHeaderProps {
+ title: string
+ description: string
+ status?: 'production' | 'limited' | 'unused' | 'needs-refactor'
+ usages?: string
+}
+
+export function DocHeader({ title, description, status, usages }: DocHeaderProps) {
+ return (
+
+
+
{title}
+ {status && }
+ {usages && {usages} }
+
+
{description}
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DocPage.tsx b/src/app/(mobile-ui)/dev/ds/_components/DocPage.tsx
new file mode 100644
index 000000000..3183b66fc
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DocPage.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+
+function DocPageRoot({ children }: { children: React.ReactNode }) {
+ // Extract Design/Code children for backward compat, or render directly
+ const extracted: React.ReactNode[] = []
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ extracted.push(child)
+ return
+ }
+ if (child.type === Design) {
+ // Unwrap Design children directly
+ extracted.push(child.props.children)
+ } else if (child.type === Code) {
+ // Skip Code — code now lives inside DocSection.Code
+ } else {
+ extracted.push(child)
+ }
+ })
+
+ return
{extracted}
+}
+
+function Design({ children }: { children: React.ReactNode }) {
+ return <>{children}>
+}
+
+function Code({ children }: { children: React.ReactNode }) {
+ return <>{children}>
+}
+
+export const DocPage = Object.assign(DocPageRoot, { Design, Code })
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DocSection.tsx b/src/app/(mobile-ui)/dev/ds/_components/DocSection.tsx
new file mode 100644
index 000000000..acaad3762
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DocSection.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import React, { useState } from 'react'
+
+interface DocSectionProps {
+ title: string
+ description?: string
+ children: React.ReactNode
+}
+
+function DocSectionRoot({ title, description, children }: DocSectionProps) {
+ const [codeVisible, setCodeVisible] = useState(false)
+
+ let contentNode: React.ReactNode = null
+ let codeNode: React.ReactNode = null
+ let hasCompoundChildren = false
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) return
+ if (child.type === Content) {
+ contentNode = child.props.children
+ hasCompoundChildren = true
+ }
+ if (child.type === Code) {
+ codeNode = child.props.children
+ hasCompoundChildren = true
+ }
+ })
+
+ // Backward compat: if no Content/Code wrappers, treat all children as content
+ if (!hasCompoundChildren) {
+ contentNode = children
+ }
+
+ const hasCode = codeNode !== null
+
+ return (
+
+ {/* Left: title + description + content */}
+
+
+
{title}
+ {hasCode && (
+ setCodeVisible(!codeVisible)}
+ className="flex items-center gap-1 rounded-sm border border-gray-3 px-1.5 py-0.5 text-[10px] font-bold text-grey-1 lg:hidden"
+ aria-label={codeVisible ? 'Hide code' : 'Show code'}
+ >
+ </>
+
+ )}
+
+ {description &&
{description}
}
+
{contentNode}
+
+
+ {/* Right: code */}
+ {hasCode && (
+
+ )}
+
+ )
+}
+
+function Content({ children }: { children: React.ReactNode }) {
+ return <>{children}>
+}
+
+function Code({ children }: { children: React.ReactNode }) {
+ return <>{children}>
+}
+
+export const DocSection = Object.assign(DocSectionRoot, { Content, Code })
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DocSidebar.tsx b/src/app/(mobile-ui)/dev/ds/_components/DocSidebar.tsx
new file mode 100644
index 000000000..937c63b61
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DocSidebar.tsx
@@ -0,0 +1,95 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { SIDEBAR_CONFIG } from './nav-config'
+
+export function DocSidebar() {
+ const pathname = usePathname()
+ const [isOpen, setIsOpen] = useState(false)
+
+ // Determine which tier we're in
+ const tier = pathname?.includes('/foundations')
+ ? 'foundations'
+ : pathname?.includes('/primitives')
+ ? 'primitives'
+ : pathname?.includes('/patterns')
+ ? 'patterns'
+ : pathname?.includes('/playground')
+ ? 'playground'
+ : null
+
+ const items = tier ? SIDEBAR_CONFIG[tier] : []
+
+ if (!tier || items.length === 0) return null
+
+ return (
+ <>
+ {/* Mobile hamburger */}
+
setIsOpen(!isOpen)}
+ className="flex items-center gap-1.5 rounded-sm border border-n-1/20 px-2.5 py-1.5 text-xs font-bold md:hidden"
+ >
+
+ Menu
+
+
+ {/* Mobile overlay */}
+ {isOpen && (
+
setIsOpen(false)}>
+
+
e.stopPropagation()}
+ >
+
+ {tier}
+ setIsOpen(false)}>
+
+
+
+ setIsOpen(false)} />
+
+
+ )}
+
+ {/* Desktop sidebar */}
+
+
+
+ >
+ )
+}
+
+function SidebarLinks({
+ items,
+ pathname,
+ onNavigate,
+}: {
+ items: typeof SIDEBAR_CONFIG.foundations
+ pathname: string | null
+ onNavigate?: () => void
+}) {
+ return (
+
+ {items.map((item) => {
+ const isActive = pathname === item.href
+ return (
+
+
+ {item.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx b/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx
new file mode 100644
index 000000000..1b4a15eb4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/Playground.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import { useState } from 'react'
+import { CodeBlock } from './CodeBlock'
+
+export type PlaygroundControl =
+ | { type: 'select'; prop: string; label: string; options: string[] }
+ | { type: 'boolean'; prop: string; label: string }
+ | { type: 'text'; prop: string; label: string; placeholder?: string }
+
+interface PlaygroundProps {
+ name: string
+ importPath: string
+ defaults: Record
+ controls: PlaygroundControl[]
+ render: (props: Record) => React.ReactNode
+ codeTemplate: (props: Record) => string
+}
+
+export function Playground({ name, importPath, defaults, controls, render, codeTemplate }: PlaygroundProps) {
+ const [props, setProps] = useState>(defaults)
+
+ const updateProp = (key: string, value: any) => {
+ setProps((prev) => ({ ...prev, [key]: value }))
+ }
+
+ return (
+
+ {/* Preview */}
+
+
Preview
+
{render(props)}
+
+
+ {/* Controls */}
+
+
Controls
+
+ {controls.map((control) => (
+ updateProp(control.prop, v)}
+ />
+ ))}
+
+
+
+ {/* Generated code */}
+
+
+
+ )
+}
+
+function ControlField({
+ control,
+ value,
+ onChange,
+}: {
+ control: PlaygroundControl
+ value: any
+ onChange: (v: any) => void
+}) {
+ switch (control.type) {
+ case 'select':
+ return (
+
+ {control.label}
+ onChange(e.target.value || undefined)}
+ className="w-full rounded-sm border border-n-1/30 bg-white px-2 py-1.5 text-xs font-bold"
+ >
+ (none)
+ {control.options.map((opt) => (
+
+ {opt}
+
+ ))}
+
+
+ )
+ case 'boolean':
+ return (
+
+ onChange(e.target.checked)}
+ className="size-4 rounded-sm border border-n-1"
+ />
+ {control.label}
+
+ )
+ case 'text':
+ return (
+
+ {control.label}
+ onChange(e.target.value || undefined)}
+ placeholder={control.placeholder}
+ className="w-full rounded-sm border border-n-1/30 bg-white px-2 py-1.5 text-xs"
+ />
+
+ )
+ }
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx b/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
new file mode 100644
index 000000000..e1df83e4f
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
@@ -0,0 +1,49 @@
+interface PropsTableRow {
+ name: string
+ type: string
+ default: string
+ required?: boolean
+ description?: string
+}
+
+export function PropsTable({ rows }: { rows: PropsTableRow[] }) {
+ return (
+
+
+
+
+
+ prop
+
+
+ type
+
+
+ default
+
+
+ description
+
+
+
+
+ {rows.map((row) => (
+
+
+ {row.name}
+ {row.required && * }
+
+ {row.type}
+ {row.default}
+ {row.description && (
+
+ {row.description}
+
+ )}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/SectionDivider.tsx b/src/app/(mobile-ui)/dev/ds/_components/SectionDivider.tsx
new file mode 100644
index 000000000..255fca580
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/SectionDivider.tsx
@@ -0,0 +1,3 @@
+export function SectionDivider() {
+ return
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/StatusTag.tsx b/src/app/(mobile-ui)/dev/ds/_components/StatusTag.tsx
new file mode 100644
index 000000000..1013f4282
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/StatusTag.tsx
@@ -0,0 +1,21 @@
+const styles = {
+ production: 'bg-green-1/30 text-n-1',
+ limited: 'bg-yellow-1/30 text-n-1',
+ unused: 'bg-n-1/10 text-grey-1',
+ 'needs-refactor': 'bg-error-1/30 text-n-1',
+}
+
+const labels = {
+ production: 'production',
+ limited: 'limited use',
+ unused: 'unused',
+ 'needs-refactor': 'needs refactor',
+}
+
+export function StatusTag({ status }: { status: 'production' | 'limited' | 'unused' | 'needs-refactor' }) {
+ return (
+
+ {labels[status]}
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/TierNav.tsx b/src/app/(mobile-ui)/dev/ds/_components/TierNav.tsx
new file mode 100644
index 000000000..1efe60467
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/TierNav.tsx
@@ -0,0 +1,32 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { TIERS } from './nav-config'
+
+export function TierNav() {
+ const pathname = usePathname()
+
+ return (
+
+ {TIERS.map((tier) => {
+ const isActive = pathname?.startsWith(tier.href)
+ return (
+
+
+ {tier.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/WhenToUse.tsx b/src/app/(mobile-ui)/dev/ds/_components/WhenToUse.tsx
new file mode 100644
index 000000000..cb8d605c1
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/WhenToUse.tsx
@@ -0,0 +1,37 @@
+import { Icon } from '@/components/Global/Icons/Icon'
+
+interface WhenToUseProps {
+ use: string[]
+ dontUse?: string[]
+}
+
+export function WhenToUse({ use, dontUse }: WhenToUseProps) {
+ return (
+
+
+
When to use
+
+ {use.map((item, i) => (
+
+
+ {item}
+
+ ))}
+
+
+ {dontUse && (
+
+
When not to use
+
+ {dontUse.map((item, i) => (
+
+
+ {item}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts b/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts
new file mode 100644
index 000000000..fa64e72cb
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts
@@ -0,0 +1,51 @@
+import type { IconName } from '@/components/Global/Icons/Icon'
+
+export interface NavItem {
+ label: string
+ href: string
+ icon: IconName
+}
+
+export const TIERS = [
+ { label: 'Foundations', href: '/dev/ds/foundations', icon: 'bulb' as IconName },
+ { label: 'Primitives', href: '/dev/ds/primitives', icon: 'switch' as IconName },
+ { label: 'Patterns', href: '/dev/ds/patterns', icon: 'docs' as IconName },
+ { label: 'Playground', href: '/dev/ds/playground', icon: 'bulb' as IconName },
+]
+
+export const SIDEBAR_CONFIG: Record = {
+ foundations: [
+ { label: 'Colors', icon: 'bulb', href: '/dev/ds/foundations/colors' },
+ { label: 'Typography', icon: 'docs', href: '/dev/ds/foundations/typography' },
+ { label: 'Spacing', icon: 'switch', href: '/dev/ds/foundations/spacing' },
+ { label: 'Shadows', icon: 'docs', href: '/dev/ds/foundations/shadows' },
+ { label: 'Icons', icon: 'search', href: '/dev/ds/foundations/icons' },
+ { label: 'Borders', icon: 'docs', href: '/dev/ds/foundations/borders' },
+ ],
+ primitives: [
+ { label: 'Button', icon: 'switch', href: '/dev/ds/primitives/button' },
+ { label: 'Card', icon: 'docs', href: '/dev/ds/primitives/card' },
+ { label: 'BaseInput', icon: 'clip', href: '/dev/ds/primitives/base-input' },
+ { label: 'BaseSelect', icon: 'clip', href: '/dev/ds/primitives/base-select' },
+ { label: 'Checkbox', icon: 'check', href: '/dev/ds/primitives/checkbox' },
+ { label: 'Toast', icon: 'bell', href: '/dev/ds/primitives/toast' },
+ { label: 'Divider', icon: 'minus-circle', href: '/dev/ds/primitives/divider' },
+ { label: 'Title', icon: 'docs', href: '/dev/ds/primitives/title' },
+ { label: 'PageContainer', icon: 'docs', href: '/dev/ds/primitives/page-container' },
+ ],
+ patterns: [
+ { label: 'Modal', icon: 'link', href: '/dev/ds/patterns/modal' },
+ { label: 'Drawer', icon: 'link', href: '/dev/ds/patterns/drawer' },
+ { label: 'Navigation', icon: 'link', href: '/dev/ds/patterns/navigation' },
+ { label: 'Loading', icon: 'processing', href: '/dev/ds/patterns/loading' },
+ { label: 'Feedback', icon: 'meter', href: '/dev/ds/patterns/feedback' },
+ { label: 'Copy & Share', icon: 'copy', href: '/dev/ds/patterns/copy-share' },
+ { label: 'Layouts', icon: 'switch', href: '/dev/ds/patterns/layouts' },
+ { label: 'Cards (Global)', icon: 'docs', href: '/dev/ds/patterns/cards-global' },
+ { label: 'AmountInput', icon: 'dollar', href: '/dev/ds/patterns/amount-input' },
+ ],
+ playground: [
+ { label: 'Shake & Confetti', icon: 'gift', href: '/dev/ds/playground/shake-test' },
+ { label: 'Perk Success', icon: 'check-circle', href: '/dev/ds/playground/perk-success' },
+ ],
+}
diff --git a/src/app/(mobile-ui)/dev/ds/_hooks/useHighlightedCode.ts b/src/app/(mobile-ui)/dev/ds/_hooks/useHighlightedCode.ts
new file mode 100644
index 000000000..da80e8620
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_hooks/useHighlightedCode.ts
@@ -0,0 +1,39 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import type { HighlighterCore } from 'shiki'
+
+let highlighterPromise: Promise | null = null
+
+function getHighlighter() {
+ if (!highlighterPromise) {
+ highlighterPromise = import('shiki/bundle/web').then((shiki) =>
+ shiki.createHighlighter({
+ themes: ['github-light'],
+ langs: ['tsx'],
+ })
+ )
+ }
+ return highlighterPromise
+}
+
+function escapeHtml(str: string) {
+ return str.replace(/&/g, '&').replace(//g, '>')
+}
+
+export function useHighlightedCode(code: string, lang = 'tsx') {
+ const [html, setHtml] = useState(() => `${escapeHtml(code)} `)
+
+ useEffect(() => {
+ let cancelled = false
+ getHighlighter().then((h) => {
+ if (cancelled) return
+ setHtml(h.codeToHtml(code, { lang, theme: 'github-light' }))
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [code, lang])
+
+ return html
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
new file mode 100644
index 000000000..1e645955d
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function BordersPage() {
+ return (
+
+
+
+ {/* Border radius */}
+
+
+ Always use rounded-sm. This is the standard across all components.
+
+
+
+
+
rounded-sm
+
standard
+
+
+
+
rounded-full
+
badges, avatars
+
+
+
+
+ {/* Border styles */}
+
+
+
+
+
border border-n-1
+
Standard 1px black border. Most common.
+
+
+
brutal-border
+
2px solid black. For emphasis.
+
+
+
border border-n-1/20
+
Subtle border. For code snippets, secondary containers.
+
+
+
border-dashed border-n-1/30
+
Dashed border. For drop zones, placeholders.
+
+
+
+
+
+
+
+
+
+ {/* Labels */}
+
+
+
+ {['label-stroke', 'label-purple', 'label-yellow', 'label-black', 'label-teal'].map((cls) => (
+
+ {cls.replace('label-', '')}
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
new file mode 100644
index 000000000..e3243d920
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
@@ -0,0 +1,118 @@
+'use client'
+
+import { useState } from 'react'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+
+const COLORS = [
+ { name: 'purple-1', bg: 'bg-purple-1', text: 'text-purple-1', hex: '#FF90E8', note: 'PINK not purple!' },
+ { name: 'primary-3', bg: 'bg-primary-3', text: 'text-primary-3', hex: '#EFE4FF', note: 'lavender' },
+ { name: 'primary-4', bg: 'bg-primary-4', text: 'text-primary-4', hex: '#D8C4F6', note: 'deeper lavender' },
+ { name: 'yellow-1', bg: 'bg-yellow-1', text: 'text-yellow-1', hex: '#FFC900', note: 'peanut yellow' },
+ { name: 'green-1', bg: 'bg-green-1', text: 'text-green-1', hex: '#98E9AB', note: 'success green' },
+ { name: 'n-1', bg: 'bg-n-1', text: 'text-n-1', hex: '#000000', note: 'black / primary text' },
+ { name: 'grey-1', bg: 'bg-grey-1', text: 'text-grey-1', hex: '#6B6B6B', note: 'secondary text' },
+ { name: 'teal-1', bg: 'bg-teal-1', text: 'text-teal-1', hex: '#C3F5E4', note: 'teal accent' },
+ { name: 'violet-1', bg: 'bg-violet-1', text: 'text-violet-1', hex: '#A78BFA', note: 'violet' },
+ { name: 'error-1', bg: 'bg-error-1', text: 'text-error-1', hex: '#FF6B6B', note: 'error red' },
+ { name: 'success-3', bg: 'bg-success-3', text: 'text-success-3', hex: '#4ADE80', note: 'success bg' },
+ { name: 'secondary-1', bg: 'bg-secondary-1', text: 'text-secondary-1', hex: '#FFC900', note: 'same as yellow-1' },
+]
+
+const BACKGROUNDS = [
+ { name: 'bg-peanut-repeat-normal', description: 'Normal peanut repeat pattern' },
+ { name: 'bg-peanut-repeat-large', description: 'Large peanut repeat pattern' },
+ { name: 'bg-peanut-repeat-small', description: 'Small peanut repeat pattern' },
+]
+
+export default function ColorsPage() {
+ const [copiedColor, setCopiedColor] = useState(null)
+
+ const copyClass = (cls: string) => {
+ navigator.clipboard.writeText(cls)
+ setCopiedColor(cls)
+ setTimeout(() => setCopiedColor(null), 1500)
+ }
+
+ return (
+
+
+
+
+ purple-1 / primary-1 = #FF90E8 — this is PINK, not purple. The naming is misleading but too widely used to rename.
+
+
+ {/* Color grid */}
+
+
+ {COLORS.map((color) => (
+
copyClass(color.bg)}
+ className="flex items-center gap-2 rounded-sm border border-n-1/20 p-2 text-left transition-colors hover:border-n-1/40"
+ >
+
+
+
{color.name}
+
+ {color.hex} · {color.note}
+
+
+ {copiedColor === color.bg ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {/* Text / BG pairs */}
+
+
+
+ text-n-1
+ Primary text — headings, labels, body (134 usages)
+
+
+ text-grey-1
+ Secondary text — descriptions, hints, metadata
+
+
+ text-error-1
+ Error text — validation messages, alerts
+
+
+ text-success-3
+ Success text — confirmations
+
+
+
+
+ Inline links: always use text-black underline — never text-purple-1.
+
+
+
+ {/* Background patterns */}
+
+
+ {BACKGROUNDS.map((bg) => (
+
copyClass(bg.name)}
+ className="w-full text-left"
+ >
+
+ .{bg.name}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
new file mode 100644
index 000000000..0d522b067
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
@@ -0,0 +1,83 @@
+'use client'
+
+import { useState } from 'react'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+const ALL_ICONS: IconName[] = [
+ 'alert', 'alert-filled', 'arrow-down', 'arrow-down-left', 'arrow-up', 'arrow-up-right',
+ 'arrow-exchange', 'badge', 'bank', 'bell', 'bulb', 'camera', 'camera-flip', 'cancel',
+ 'check', 'check-circle', 'chevron-up', 'chevron-down', 'clip', 'clock', 'copy', 'currency',
+ 'docs', 'dollar', 'double-check', 'download', 'error', 'exchange', 'external-link',
+ 'eye', 'eye-slash', 'failed', 'fees', 'gift', 'globe-lock', 'history', 'home',
+ 'info', 'info-filled', 'invite-heart', 'link', 'link-slash', 'lock', 'logout', 'meter',
+ 'minus-circle', 'mobile-install', 'paperclip', 'paste', 'peanut-support', 'pending',
+ 'plus', 'plus-circle', 'processing', 'qr-code', 'question-mark', 'retry', 'search',
+ 'share', 'shield', 'smile', 'split', 'star', 'success', 'switch', 'trophy',
+ 'txn-off', 'upload-cloud', 'user', 'user-id', 'user-plus', 'wallet', 'wallet-cancel',
+ 'wallet-outline', 'achievements',
+]
+
+export default function IconsPage() {
+ const [search, setSearch] = useState('')
+ const [copiedIcon, setCopiedIcon] = useState(null)
+
+ const filtered = search
+ ? ALL_ICONS.filter((name) => name.includes(search.toLowerCase()))
+ : ALL_ICONS
+
+ const copyIcon = (name: string) => {
+ navigator.clipboard.writeText(name)
+ setCopiedIcon(name)
+ setTimeout(() => setCopiedIcon(null), 1500)
+ }
+
+ return (
+
+
+
+ {/* Search */}
+ setSearch(e.target.value)}
+ placeholder="Search icons..."
+ className="w-full rounded-sm border border-n-1 px-3 py-2 text-sm"
+ />
+
+ {/* Grid */}
+
+ {filtered.map((name) => (
+ copyIcon(name)}
+ className={`flex flex-col items-center gap-0.5 rounded-sm border p-1.5 transition-colors ${
+ copiedIcon === name
+ ? 'border-success-3 bg-success-3/10'
+ : 'border-n-1/10 hover:border-n-1/40'
+ }`}
+ >
+
+ {name}
+
+ ))}
+
+
+ {filtered.length === 0 && (
+ No icons match "{search}"
+ )}
+
+
+
+ `}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/page.tsx
new file mode 100644
index 000000000..d74619cc7
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/page.tsx
@@ -0,0 +1,61 @@
+import { CatalogCard, CatalogGrid } from '../_components/CatalogCard'
+import { DocPage } from '../_components/DocPage'
+
+export default function FoundationsPage() {
+ return (
+
+
+
Foundations
+
+ Design tokens, visual primitives, and systemic building blocks.
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
new file mode 100644
index 000000000..b9074fef7
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
@@ -0,0 +1,73 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function ShadowsPage() {
+ return (
+
+
+
+
+ shadowSize="4" has 160+ usages. It is the standard. All others are negligible.
+
+
+ {/* Button shadows */}
+
+
+
+ {(['3', '4', '6', '8'] as const).map((s) => (
+
+
shadow {s}
+
+ {s === '4' ? '160 usages' : s === '3' ? '2 usages' : s === '6' ? '1 usage' : '1 usage'}
+
+
+ ))}
+
+
+
+ Label`}
+ />
+
+
+
+ {/* Card shadows */}
+
+
+
+ {(['4', '6', '8'] as const).map((s) => (
+
+ shadowSize="{s}"
+
+ ))}
+
+
+
+ content`}
+ />
+
+
+
+ {/* Tailwind shadow classes */}
+
+
+ {['shadow-2', 'shadow-4', 'shadow-sm', 'shadow-lg'].map((cls) => (
+
+ .{cls}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
new file mode 100644
index 000000000..478517aa4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function SpacingPage() {
+ return (
+
+
+
+ {/* Custom layout classes */}
+
+
+
+
+ .row
+ flex items-center gap-2
+
+
+ .col
+ flex flex-col gap-2
+
+
+
+
+
Example: .row
+
+
+
Example: .col
+
+
+
+
+ ... `} />
+
... `} />
+
+
+
+ {/* Common gap patterns */}
+
+
+ {[
+ ['gap-1', '4px', 'Tight grouping (icon + label)'],
+ ['gap-2', '8px', 'Default row/col spacing'],
+ ['gap-3', '12px', 'Card list spacing'],
+ ['gap-4', '16px', 'Section spacing within a card'],
+ ['gap-6', '24px', 'Content block spacing'],
+ ['gap-8', '32px', 'Major section spacing'],
+ ].map(([cls, px, note]) => (
+
+ {cls}
+ {px}
+ {note}
+
+ ))}
+
+
+
+ {/* Page padding */}
+
+
+
Standard page content padding: px-4 (16px)
+
Card internal padding: p-4 (16px) or p-6 (24px)
+
Section spacing: space-y-6 or gap-6
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
new file mode 100644
index 000000000..8d6636ed0
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import Title from '@/components/0_Bruddle/Title'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+const WEIGHTS = [
+ { class: 'font-light', label: 'Light', usages: 5 },
+ { class: 'font-normal', label: 'Normal', usages: 50 },
+ { class: 'font-medium', label: 'Medium', usages: 104 },
+ { class: 'font-semibold', label: 'Semibold', usages: 66 },
+ { class: 'font-bold', label: 'Bold', usages: 304 },
+ { class: 'font-extrabold', label: 'Extrabold', usages: 55 },
+ { class: 'font-black', label: 'Black', usages: 16 },
+]
+
+const SIZES = [
+ { class: 'text-xs', example: 'Extra small (12px)', note: 'metadata, badges, hints' },
+ { class: 'text-sm', example: 'Small (14px)', note: 'body text, descriptions' },
+ { class: 'text-base', example: 'Base (16px)', note: 'default' },
+ { class: 'text-lg', example: 'Large (18px)', note: 'section headings' },
+ { class: 'text-xl', example: 'Extra large (20px)', note: 'page titles' },
+ { class: 'text-2xl', example: '2XL (24px)', note: 'hero text' },
+]
+
+export default function TypographyPage() {
+ return (
+
+
+
+ {/* Font families */}
+
+
+
+
+
System Default
+
Primary body font. Used everywhere by default.
+
+
+
font-mono
+
Monospace for code, addresses, amounts. 21 usages.
+
+
+
font-roboto-flex
+
Roboto Flex for specific UI elements. 16 usages.
+
+
+
+
Display font with filled+outline double-render effect.
+
+
+
+
+
+ `}
+ />
+
+
+
+ {/* Font weights */}
+
+
+ {WEIGHTS.map((w) => (
+
+
+ {w.label} .{w.class}
+
+
{w.usages}
+
+ ))}
+
+
+ font-bold dominates (304 usages). Use font-bold for labels and headings, font-medium for secondary text.
+
+
+
+ {/* Text sizes */}
+
+
+ {SIZES.map((s) => (
+
+
{s.example}
+
+ .{s.class} — {s.note}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/layout.tsx b/src/app/(mobile-ui)/dev/ds/layout.tsx
new file mode 100644
index 000000000..fa87ff7bd
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/layout.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import NavHeader from '@/components/Global/NavHeader'
+import { TierNav } from './_components/TierNav'
+import { DocSidebar } from './_components/DocSidebar'
+
+export default function DesignSystemLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {/* Header */}
+
+
+
+
+ {/* Tier tabs */}
+
+
+ {/* Content area */}
+
+ {/* Desktop sidebar */}
+
+
+
+
+ {/* Main content */}
+
{children}
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/page.tsx b/src/app/(mobile-ui)/dev/ds/page.tsx
new file mode 100644
index 000000000..cc0b712eb
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/page.tsx
@@ -0,0 +1,103 @@
+'use client'
+
+import Link from 'next/link'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { Card } from '@/components/0_Bruddle/Card'
+import Title from '@/components/0_Bruddle/Title'
+import { DocPage } from './_components/DocPage'
+
+const sections = [
+ {
+ title: 'Foundations',
+ description: 'Color tokens, typography, spacing, shadows, icons, and borders',
+ href: '/dev/ds/foundations',
+ icon: 'bulb' as const,
+ count: 6,
+ },
+ {
+ title: 'Primitives',
+ description: 'Bruddle base components: Button, Card, Input, Select, Checkbox, Toast',
+ href: '/dev/ds/primitives',
+ icon: 'switch' as const,
+ count: 9,
+ },
+ {
+ title: 'Patterns',
+ description: 'Composed components: Modal, Drawer, Navigation, Loading, Feedback, Layouts',
+ href: '/dev/ds/patterns',
+ icon: 'docs' as const,
+ count: 9,
+ },
+ {
+ title: 'Playground',
+ description: 'Interactive test harnesses: shake animations, haptics, confetti, perk flows',
+ href: '/dev/ds/playground',
+ icon: 'bulb' as const,
+ count: 2,
+ },
+]
+
+export default function DesignSystemPage() {
+ return (
+
+ {/* Hero */}
+
+
+
Design System
+
+ Foundations → Primitives → Patterns → Playground
+
+
+
+ {/* Quick stats */}
+
+ {[
+ { label: 'Primitives', value: '9' },
+ { label: 'Global', value: '70+' },
+ { label: 'Icons', value: '85+' },
+ ].map((stat) => (
+
+
{stat.value}
+
{stat.label}
+
+ ))}
+
+
+ {/* Section cards */}
+
+ {sections.map((section) => (
+
+
+
+
+
+
+
+
+
{section.title}
+
+ {section.count}
+
+
+
{section.description}
+
+
+
+
+
+ ))}
+
+
+ {/* Design rules quick reference */}
+
+
Quick Rules
+
+ Primary CTA: variant="purple" shadowSize="4" w-full
+ Links: text-black underline — never text-purple-1
+ purple-1 is pink (#FF90E8), not purple
+ size="large" is h-10 (shorter than default h-13)
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
new file mode 100644
index 000000000..d567be3bc
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
@@ -0,0 +1,133 @@
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function AmountInputPage() {
+ return (
+
+
+
+ {/* Refactor Note */}
+
+
+ This component needs refactoring. It has 20+ props, mixes display logic with currency conversion
+ math, and requires multiple setter callbacks. Consider splitting into AmountDisplay (visual) and
+ useAmountConversion (hook) in a future pass.
+
+
+
+ {/* Visual Description */}
+
+
+
+
+
+ $
+ 0.00
+
+
≈ ETH 0.00
+
Balance: $ 42.50
+
+
+
+
+
+ The input uses a transparent background with auto-sizing width. A fake blinking caret (primary-1 color)
+ shows when the input is empty and not focused.
+
+
+
+
+
+ `} />
+
+ `} />
+
+ `} />
+
+
+
+
+
+ {/* Props */}
+
+ void', default: '-', required: true, description: 'Callback for the primary denomination amount' },
+ { name: 'primaryDenomination', type: '{ symbol, price, decimals }', default: "{ symbol: '$', price: 1, decimals: 2 }", description: 'Primary currency config' },
+ { name: 'secondaryDenomination', type: '{ symbol, price, decimals }', default: '(none)', description: 'Enables currency toggle when provided' },
+ { name: 'setSecondaryAmount', type: '(value: string) => void', default: '(none)', description: 'Callback for converted amount' },
+ { name: 'setDisplayedAmount', type: '(value: string) => void', default: '(none)', description: 'Callback for the currently displayed value' },
+ { name: 'setCurrentDenomination', type: '(denomination: string) => void', default: '(none)', description: 'Reports which denomination is active' },
+ { name: 'initialAmount', type: 'string', default: "''", description: 'Pre-fill amount' },
+ { name: 'initialDenomination', type: 'string', default: '(none)', description: 'Pre-select denomination' },
+ { name: 'walletBalance', type: 'string', default: '(none)', description: 'Formatted balance to display' },
+ { name: 'hideBalance', type: 'boolean', default: 'false', description: 'Hide the balance line' },
+ { name: 'hideCurrencyToggle', type: 'boolean', default: 'false', description: 'Hide the swap icon even with secondary denomination' },
+ { name: 'disabled', type: 'boolean', default: 'false', description: 'Disable input' },
+ { name: 'onSubmit', type: '() => void', default: '(none)', description: 'Enter key handler' },
+ { name: 'onBlur', type: '() => void', default: '(none)', description: 'Blur handler' },
+ { name: 'showSlider', type: 'boolean', default: 'false', description: 'Show percentage slider below input' },
+ { name: 'maxAmount', type: 'number', default: '(none)', description: 'Slider max value' },
+ { name: 'amountCollected', type: 'number', default: '0', description: 'Already collected (for pot snap logic)' },
+ { name: 'defaultSliderValue', type: 'number', default: '(none)', description: 'Initial slider percentage' },
+ { name: 'defaultSliderSuggestedAmount', type: 'number', default: '(none)', description: 'Suggested amount to pre-fill' },
+ { name: 'infoContent', type: 'ReactNode', default: '(none)', description: 'Content below the input area' },
+ { name: 'className', type: 'string', default: "''", description: 'Override form container styles' },
+ ]}
+ />
+
+
+ {/* Architecture Notes */}
+
+
+ Internally uses exactValue (scaled by 10^18) for precise integer arithmetic during currency conversion.
+ Display values are formatted separately from calculation values to avoid precision loss.
+
+
+ The component auto-focuses on desktop (DeviceType.WEB) but not on mobile to avoid keyboard popup.
+ Input width auto-sizes based on character count (ch units).
+
+
+ The slider has a 33.33% "magnetic snap point" that snaps to the remaining pot amount. This is specific
+ to the pot/group-pay use case and ideally should not be baked into the generic component.
+
+
+
+ {/* Refactoring Ideas */}
+
+
+
1. Extract conversion logic into a useAmountConversion hook
+
2. Split slider into a separate SliderAmountInput wrapper component
+
3. Remove pot-specific snap logic from the base component
+
4. Simplify the 7 callback props into a single onChange object
+
5. Consider using a controlled-only pattern (value + onChange) instead of internal state
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
new file mode 100644
index 000000000..fd04b97a4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
@@ -0,0 +1,182 @@
+'use client'
+
+import Card from '@/components/Global/Card'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function CardsGlobalPage() {
+ return (
+
+
+
+ {/* Import */}
+
+
+ This is the default export from Global/Card. The Bruddle Card is a named export:
+ import {'{ Card }'} from '@/components/0_Bruddle/Card'. They are different components.
+
+
+
+ {/* Single Card */}
+
+
+
+
+ Single Card
+ position="single"
+
+
+
+
+
+
+
+ Content
+`} />
+
+
+
+ {/* Stacked List */}
+
+
+
+ Cards stack seamlessly by using position props: first, middle, last. Only the first card has top
+ border-radius, only the last has bottom, and middle cards have no border-radius. Border-top is removed
+ on middle and last to avoid double borders.
+
+
+
+ {(['first', 'middle', 'middle', 'middle', 'last'] as const).map((pos, i) => (
+
+
+ Item {i + 1}
+ position="{pos}"
+
+
+ ))}
+
+
+
+ {
+ const position =
+ items.length === 1 ? 'single' :
+ index === 0 ? 'first' :
+ index === items.length - 1 ? 'last' :
+ 'middle'
+
+ return (
+
+ {/* Item content */}
+
+ )
+})}`} />
+
+
+
+ {/* Clickable */}
+
+
+
+
{}}>
+
+ Clickable item 1
+ →
+
+
+
{}}>
+
+ Clickable item 2
+ →
+
+
+
+
+
+ router.push('/detail')}>
+ Clickable card content
+`} />
+
+
+
+ {/* No Border */}
+
+
+
+
+ No border card
+
+
+
+
+ Content`} />
+
+
+
+
+
+ {/* Props */}
+
+ void', default: '(none)', description: 'Makes card clickable' },
+ { name: 'className', type: 'string', default: "''", description: 'Override styles (base: w-full bg-white px-4 py-2)' },
+ { name: 'children', type: 'ReactNode', default: '-', required: true },
+ { name: 'ref', type: 'Ref', default: '(none)' },
+ ]}
+ />
+
+
+ {/* Position behavior table */}
+
+
+
+
+
+ Position
+ Border Radius
+ Border
+
+
+
+ {[
+ ['single', 'rounded-sm (all)', 'border border-black'],
+ ['first', 'rounded-t-sm (top only)', 'border border-black'],
+ ['middle', 'none', 'border border-black border-t-0'],
+ ['last', 'rounded-b-sm (bottom only)', 'border border-black border-t-0'],
+ ].map(([pos, radius, border]) => (
+
+ {pos}
+ {radius}
+ {border}
+
+ ))}
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ Use Global Card for stacked lists (transaction history, settings, token lists). Use Bruddle Card for
+ standalone content cards with shadows and variants.
+
+
+ The base styles are: w-full bg-white px-4 py-2. Override with className for custom padding or
+ background.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
new file mode 100644
index 000000000..6fc113c2e
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
@@ -0,0 +1,204 @@
+'use client'
+
+import CopyField from '@/components/Global/CopyField'
+import CopyToClipboard from '@/components/Global/CopyToClipboard'
+import MoreInfo from '@/components/Global/MoreInfo'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function CopySharePage() {
+ return (
+
+
+
+ {/* CopyField */}
+
+
+
+ Input field + Copy button combo. The input is disabled (read-only display). Button shows
+ "Copied" feedback for 3 seconds.
+
+
+
+
+
+
+
+ void', default: '(none)', description: 'Handler when clicking disabled button' },
+ ]}
+ />
+
+
+
+
+
+ `} />
+
+
+
+ {/* CopyToClipboard */}
+
+
+
+ Icon-only or button-style copy trigger. Shows check icon for 2 seconds after copying.
+ Supports imperative copy via ref.
+
+
+
+
+
+ Icon mode:
+
+
+
+ Different sizes:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/* Button */}
+
+
+{/* Imperative */}
+const copyRef = useRef(null)
+
+copyRef.current?.copy()`} />
+
+
+
+ {/* ShareButton */}
+
+
+
+ Reference only. Uses the Web Share API (navigator.share) with clipboard fallback.
+ Typically composed inline rather than imported as a standalone component.
+
+
+
+ {
+ if (navigator.share) {
+ navigator.share({ url, title })
+ } else {
+ navigator.clipboard.writeText(url)
+ }
+ }}
+>
+ Share
+`} />
+
+
+
+ {/* AddressLink */}
+
+
+
+ Displays a shortened crypto address as a link. Resolves ENS names for Ethereum addresses.
+ Links to the user profile page.
+
+
+
+ AddressLink uses usePrimaryName hook (ENS resolution) which requires JustAName provider context.
+ Cannot demo in isolation. Showing code example only.
+
+
+
+
+
+
+
+
+ `} />
+
+
+
+ {/* MoreInfo */}
+
+
+
+ Info icon that toggles a positioned tooltip on click. Uses HeadlessUI Menu and createPortal for
+ correct z-indexing.
+
+
+
+ Network fee
+
+
+
+
+
+
+
+
+ Network fee
+ `} />
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ CopyField for displaying + copying full strings (links, codes). CopyToClipboard for inline copy icons
+ next to existing text.
+
+
+ MoreInfo tooltip is portaled to document.body and auto-positions to avoid viewport edges. Preferred
+ over native title attributes.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
new file mode 100644
index 000000000..4da08dcfe
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
@@ -0,0 +1,127 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger, DrawerHeader, DrawerFooter, DrawerDescription, DrawerClose } from '@/components/Global/Drawer'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function DrawerPage() {
+ return (
+
+
+
+ {/* Live Demo + Usage */}
+
+
+
+
+ Open Drawer
+
+
+
+ Example Drawer
+ This is a vaul-based bottom sheet. Swipe down to dismiss.
+
+
+
+ The Drawer component wraps vaul and provides a consistent bottom-sheet experience.
+ It includes an overlay, drag handle, and max-height constraint (80vh).
+
+
+
+
+ Close Drawer
+
+
+
+
+
+
+
+
+
+
+
+
+ Open
+
+
+
+ Title
+ Description
+
+
+ {/* Content */}
+
+
+
+
+ Done
+
+
+
+
+`} />
+
+
+
+ {/* Content */}
+
+`} />
+
+
+
+
+
+ {/* Compound Components */}
+
+
+
+
+ {/* Design Notes */}
+
+
+ Always include a DrawerTitle inside DrawerContent for accessibility (screen readers).
+
+
+ Drawer scales the background by default (shouldScaleBackground=true). The drag handle is a 40px wide
+ rounded bar at the top.
+
+
+ Content is capped at max-h-[80vh] with overflow-auto. For long lists, scrolling works inside the drawer.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
new file mode 100644
index 000000000..aa32ea232
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import StatusBadge, { type StatusType } from '@/components/Global/Badges/StatusBadge'
+import StatusPill, { type StatusPillType } from '@/components/Global/StatusPill'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import EmptyState from '@/components/Global/EmptyStates/EmptyState'
+import NoDataEmptyState from '@/components/Global/EmptyStates/NoDataEmptyState'
+import { Button } from '@/components/0_Bruddle/Button'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+const allStatuses: StatusType[] = ['completed', 'pending', 'processing', 'failed', 'cancelled', 'refunded', 'soon', 'closed']
+
+export default function FeedbackPage() {
+ return (
+
+
+
+ {/* StatusBadge */}
+
+
+
+ Rounded pill badge with text label. Three size variants. Shared StatusType across the codebase.
+
+
+ {/* All statuses */}
+
+
All Status Types
+
+ {allStatuses.map((status) => (
+
+ ))}
+
+
+
+ {/* Sizes */}
+
+
Sizes
+
+ {(['small', 'medium', 'large'] as const).map((size) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ `} />
+
+
+
+ {/* StatusPill */}
+
+
+
+ Tiny 14px circular icon indicator. Uses the same StatusType as StatusBadge (minus "custom").
+ Pairs well with list items.
+
+
+
+
All Status Types
+
+ {allStatuses.filter((s): s is StatusPillType => s !== 'custom').map((status) => (
+
+
+ {status}
+
+ ))}
+
+
+
+
+
+
+
+
+ `} />
+
+
+
+ {/* ErrorAlert */}
+
+
+
+ Inline error message with icon. Red text, left-aligned icon + description.
+
+
+
+
+
+
+
+
+
+
+
+ `} />
+
+
+
+ {/* EmptyState */}
+
+
+
+ Card-based empty state with icon, title, description, and optional CTA. Uses Global Card internally.
+
+
+
+
+ Send Money
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+ Send Money}
+/>`} />
+
+
+
+ {/* NoDataEmptyState */}
+
+
+
+ Branded empty state with crying Peanutman GIF animation. For "no data" scenarios.
+
+
+
+
+
+
+
+
+
+
+
+ `} />
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ StatusBadge for text labels in tables/lists. StatusPill for compact icon-only indicators next to items.
+
+
+ Use EmptyState (card-based, icon) for structured empty states inside content areas.
+ Use NoDataEmptyState (Peanutman GIF) for full-section "no data" states.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
new file mode 100644
index 000000000..7e23ac9dd
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
@@ -0,0 +1,268 @@
+'use client'
+
+import { Icon } from '@/components/Global/Icons/Icon'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function LayoutsPage() {
+ return (
+
+
+
+ {/* Recipe 1: Centered Content + CTA */}
+
+
+
+ Content vertically centered in viewport, CTA button pinned to the bottom.
+ Used for: claim pages, success states, amount input, confirmations.
+
+
+ {/* Wireframe */}
+
+
+
+
+
+
+
Main Content
+
flex-1 + items-center
+
+
+
+ CTA Button
+
+
+
+
+
+
+
+
+ {/* Centered content */}
+
+
+ {/* Icon, title, description */}
+
+
+
+ {/* Bottom CTA */}
+
+ Continue
+
+`} />
+
+
+
+ {/* Recipe 2: Pinned Footer CTA */}
+
+
+
+ Content flows naturally from top, CTA stays at the very bottom regardless of content height.
+ Used for: forms, settings, token selection.
+
+
+ {/* Wireframe */}
+
+
+
+ NavHeader
+
+
+
+ Form Field 1
+
+
+ Form Field 2
+
+
+ Form Field 3
+
+
+
+
+ Submit Button
+
+
+
+
+
+
+
+
+ {/* Top-aligned content */}
+
+
+
+
+
+ {/* Spacer pushes CTA to bottom */}
+
+
+ {/* Pinned CTA */}
+
+ Save Changes
+
+`} />
+
+
+
+ {/* Recipe 3: Scrollable List */}
+
+
+
+ Header + scrollable list area + optional footer. The list scrolls independently while header and
+ footer remain fixed. Used for: transaction history, token lists, contact lists.
+
+
+ {/* Wireframe */}
+
+
+
+ NavHeader + Search/Filter
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+
+ List Item {i}
+ detail
+
+
+ ))}
+
overflow-y-auto
+
+
+
+
+
+
+
+
+ {/* Fixed search bar */}
+
+
+
+
+ {/* Scrollable list */}
+
+ {items.map(item => (
+
+ {/* Item content */}
+
+ ))}
+
+`} />
+
+
+
+
+
+ {/* Common Mistakes */}
+
+
+ {/* Error callout */}
+
+
+
+ Wrong
+
+
+ Without h-full the flex container collapses to content height. The CTA sits right below content
+ instead of at the bottom.
+
+
+
+ {/* Success callout */}
+
+
+
+ Correct
+
+
+ h-full ensures the flex column fills the available height from PageContainer. flex-1 on the content
+ area pushes the CTA to the bottom.
+
+
+
+ {/* Error callout 2 */}
+
+
+
+ Wrong
+
+
+ overflow-y-auto alone does nothing unless the element has a bounded height. Use flex-1 inside a
+ flex-col container, or set an explicit max-height.
+
+
+
+ {/* Success callout 2 */}
+
+
+
+ Correct
+
+
+ Inside a flex column with h-full, flex-1 fills remaining space and provides the bounded height
+ that overflow-y-auto needs to actually scroll.
+
+
+
+
+
+ Content
+ Submit
+`} />
+
+
+ Content
+ Submit
+`} />
+
+
+ {items.map(...)}
+`} />
+
+
+
+ {items.map(...)}
+
+`} />
+
+
+
+ {/* Design Notes */}
+
+
+ Every page is wrapped in PageContainer which provides padding and max-width. Your layout div needs
+ h-full to fill it.
+
+
+ The key pattern is always: flex flex-col h-full. Then use flex-1 on the expanding section and let
+ the CTA sit naturally at the bottom.
+
+
+ Never use absolute/fixed positioning for bottom CTAs. The flex approach handles keyboard open, safe
+ areas, and content overflow correctly.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
new file mode 100644
index 000000000..c48e915f0
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
@@ -0,0 +1,124 @@
+'use client'
+
+import Loading from '@/components/Global/Loading'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function LoadingPage() {
+ return (
+
+
+
+ {/* Loading (CSS Spinner) */}
+
+
+
+ Minimal CSS-only spinner. Uses border animation. Size controlled via className.
+
+
+
+
+
+
+
+
+
+ {/* default 16px */}
+ {/* 32px */}`} />
+
+
+
+ {/* PeanutLoading */}
+
+
+
+ Spinning Peanut logo with optional message. Can cover the full screen as an overlay.
+
+
+ {/* Inline demo */}
+
+
+
+
+
+
+
+
+
+
+
+{/* Full screen overlay */}
+ `} />
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ Use Loading (CSS spinner) inside buttons, inline indicators, and small containers. Use PeanutLoading for
+ page-level or section-level loading states where brand presence matters.
+
+
+ PeanutLoading with coverFullScreen renders a fixed z-50 overlay. Make sure to conditionally render it
+ only when loading is active to avoid blocking the UI.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
new file mode 100644
index 000000000..0948f89ef
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/0_Bruddle/Button'
+import Modal from '@/components/Global/Modal'
+import ActionModal from '@/components/Global/ActionModal'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function ModalPage() {
+ const [showModal, setShowModal] = useState(false)
+ const [showActionModal, setShowActionModal] = useState(false)
+ const [actionCheckbox, setActionCheckbox] = useState(false)
+
+ return (
+
+
+
+ {/* Base Modal */}
+
+
+
+ HeadlessUI Dialog wrapper with animated overlay and panel. Use for custom modal content.
+
+
+
+
setShowModal(true)}>
+ Open Base Modal
+
+
setShowModal(false)} title="Example Modal">
+
+
+ This is the base Modal. It provides the overlay, panel animation, close button, and
+ optional title bar. You supply the children.
+
+
+ setShowModal(false)}>
+ Got it
+
+
+
+
+
+
+ void', default: '-', required: true, description: 'Called when overlay or close button clicked' },
+ { name: 'title', type: 'string', default: '(none)', description: 'Renders title bar with border' },
+ { name: 'className', type: 'string', default: "''", description: 'Class for the Dialog root' },
+ { name: 'classWrap', type: 'string', default: "''", description: 'Class for Dialog.Panel' },
+ { name: 'classOverlay', type: 'string', default: "''", description: 'Class for the backdrop overlay' },
+ { name: 'classButtonClose', type: 'string', default: "''", description: 'Class for the close button' },
+ { name: 'preventClose', type: 'boolean', default: 'false', description: 'Disables closing via overlay click' },
+ { name: 'hideOverlay', type: 'boolean', default: 'false', description: 'Hides close button and title, renders children directly' },
+ { name: 'video', type: 'boolean', default: 'false', description: 'Aspect-ratio video mode' },
+ { name: 'children', type: 'ReactNode', default: '-', required: true },
+ ]}
+ />
+
+
+
+
+ setVisible(false)} title="Example">
+
+ {/* Your content */}
+
+`} />
+
+
+
+ {/* ActionModal */}
+
+
+
+ Pre-composed modal with icon, title, description, CTA buttons, and optional checkbox. Built on top of
+ Base Modal.
+
+
+
+
setShowActionModal(true)}>
+ Open ActionModal
+
+
{
+ setShowActionModal(false)
+ setActionCheckbox(false)
+ }}
+ title="Confirm Action"
+ description="Are you sure you want to proceed? This action cannot be undone."
+ icon="alert"
+ checkbox={{
+ text: 'I understand the consequences',
+ checked: actionCheckbox,
+ onChange: setActionCheckbox,
+ }}
+ ctas={[
+ {
+ text: 'Cancel',
+ variant: 'stroke',
+ onClick: () => {
+ setShowActionModal(false)
+ setActionCheckbox(false)
+ },
+ },
+ {
+ text: 'Confirm',
+ variant: 'purple',
+ disabled: !actionCheckbox,
+ onClick: () => {
+ setShowActionModal(false)
+ setActionCheckbox(false)
+ },
+ },
+ ]}
+ />
+
+
+ void', default: '-', required: true },
+ { name: 'title', type: 'string | ReactNode', default: '-', required: true },
+ { name: 'description', type: 'string | ReactNode', default: '(none)', description: 'Subtitle text' },
+ { name: 'icon', type: 'IconName | ReactNode', default: '(none)', description: 'Displayed in pink circle above title' },
+ { name: 'iconProps', type: 'Partial', default: '(none)', description: 'Override icon size/color' },
+ { name: 'isLoadingIcon', type: 'boolean', default: 'false', description: 'Replace icon with spinner' },
+ { name: 'ctas', type: 'ActionModalButtonProps[]', default: '[]', description: 'Array of {text, variant, onClick, ...ButtonProps}' },
+ { name: 'checkbox', type: 'ActionModalCheckboxProps', default: '(none)', description: '{text, checked, onChange}' },
+ { name: 'preventClose', type: 'boolean', default: 'false', description: 'Block overlay-click dismiss' },
+ { name: 'hideModalCloseButton', type: 'boolean', default: 'false', description: 'Hides the X button' },
+ { name: 'content', type: 'ReactNode', default: '(none)', description: 'Custom content between description and CTAs' },
+ { name: 'footer', type: 'ReactNode', default: '(none)', description: 'Content below CTAs' },
+ ]}
+ />
+
+
+
+
+ setVisible(false)}
+ title="Confirm Action"
+ description="Are you sure?"
+ icon="alert"
+ checkbox={{
+ text: 'I understand',
+ checked: checked,
+ onChange: setChecked,
+ }}
+ ctas={[
+ { text: 'Cancel', variant: 'stroke', onClick: handleCancel },
+ { text: 'Confirm', variant: 'purple', onClick: handleConfirm },
+ ]}
+/>`} />
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ ActionModal is the preferred pattern for confirmations and simple actions. Use Base Modal only when you
+ need fully custom content.
+
+
+ ActionModal icon renders in a pink (primary-1) circle by default. Override with iconContainerClassName
+ if needed.
+
+
+
+ {/* Specialized Modals Reference */}
+
+
+ These are pre-built modals for specific flows. They compose ActionModal or Modal internally.
+
+
+
+
+
+ Component
+ Purpose
+
+
+
+ {[
+ ['InviteFriendsModal', 'Share referral link with copy + social buttons'],
+ ['ConfirmInviteModal', 'Confirm invitation before sending'],
+ ['GuestLoginModal', 'Prompt guest users to log in or register'],
+ ['KycVerifiedOrReviewModal', 'KYC verification status feedback'],
+ ['BalanceWarningModal', 'Warn about insufficient balance'],
+ ['TokenAndNetworkConfirmationModal', 'Confirm token + chain before transfer'],
+ ['TokenSelectorModal', 'Pick token from a list'],
+ ['ChainSelectorModal', 'Pick blockchain network'],
+ ['RecipientSelectorModal', 'Pick or enter recipient address'],
+ ['QRCodeModal', 'Display QR code for sharing'],
+ ['TransactionStatusModal', 'Show tx pending/success/failed state'],
+ ['WalletConnectModal', 'Wallet connection flow'],
+ ['ExportPrivateKeyModal', 'Reveal and copy private key'],
+ ['ConfirmTransactionModal', 'Final review before transaction submit'],
+ ].map(([name, purpose]) => (
+
+ {name}
+ {purpose}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
new file mode 100644
index 000000000..1bc1b5e78
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
@@ -0,0 +1,126 @@
+'use client'
+
+import { useState } from 'react'
+import FlowHeader from '@/components/Global/FlowHeader'
+import { Button } from '@/components/0_Bruddle/Button'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function NavigationPage() {
+ const [flowStep, setFlowStep] = useState(1)
+
+ return (
+
+
+
+ {/* NavHeader */}
+
+
+
+ Top navigation bar with back button (link or callback), centered title, and optional logout button.
+ Uses authContext for logout.
+
+
+
+ NavHeader uses useAuth() internally for the logout button. It cannot be rendered in isolation outside of
+ the auth provider. Showing code examples only.
+
+
+ void', default: '(none)', description: 'Callback replaces Link with Button' },
+ { name: 'icon', type: 'IconName', default: "'chevron-up'", description: 'Back button icon (rotated -90deg)' },
+ { name: 'disableBackBtn', type: 'boolean', default: 'false', description: 'Disables the back button' },
+ { name: 'showLogoutBtn', type: 'boolean', default: 'false', description: 'Shows logout icon button on right' },
+ { name: 'hideLabel', type: 'boolean', default: 'false', description: 'Hides the title text' },
+ { name: 'titleClassName', type: 'string', default: "''", description: 'Override title styles' },
+ ]}
+ />
+
+
+
+
+ `} />
+
+ router.back()} />`} />
+
+ `} />
+
+
+
+ {/* FlowHeader */}
+
+
+
+ Minimal header for multi-step flows. Back button on the left, optional element on the right.
+ No title -- the screen content below provides context.
+
+
+ {/* Live demo */}
+
+
Live Demo (step {flowStep}/3)
+
1 ? () => setFlowStep((s) => s - 1) : undefined}
+ disableBackBtn={flowStep <= 1}
+ rightElement={
+ {flowStep}/3
+ }
+ />
+
+ Step {flowStep} Content
+
+ {flowStep < 3 ? (
+ setFlowStep((s) => s + 1)}>
+ Next
+
+ ) : (
+ setFlowStep(1)}>
+ Restart
+
+ )}
+
+
+ void', default: '(none)', description: 'Back button handler. If omitted, no back button shown.' },
+ { name: 'disableBackBtn', type: 'boolean', default: 'false', description: 'Grays out the back button' },
+ { name: 'rightElement', type: 'ReactNode', default: '(none)', description: 'Element rendered on the right (e.g. step indicator)' },
+ ]}
+ />
+
+
+
+
+ setStep(step - 1)}
+ rightElement={2/3 }
+/>`} />
+
+
+
+
+
+ {/* Design Notes */}
+
+
+ Use NavHeader for standalone pages (Settings, Profile, etc.). Use FlowHeader for multi-step wizards
+ (Send, Request, Claim, etc.).
+
+
+ Both use a 28px (h-7 w-7) stroke button for the back arrow. This is the standard navigation button size.
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/page.tsx
new file mode 100644
index 000000000..a1c66175b
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/page.tsx
@@ -0,0 +1,90 @@
+import { CatalogCard, CatalogGrid } from '../_components/CatalogCard'
+import { DocPage } from '../_components/DocPage'
+
+export default function PatternsPage() {
+ return (
+
+
+
Patterns
+
+ Composed components and layout patterns built from primitives and Global shared components.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
new file mode 100644
index 000000000..7ddf8bdcc
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
@@ -0,0 +1,95 @@
+'use client'
+
+import { useState } from 'react'
+import BaseInput from '@/components/0_Bruddle/BaseInput'
+import { Playground } from '../../_components/Playground'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function BaseInputPage() {
+ const [value, setValue] = useState('')
+
+ return (
+
+
+
+ (
+ setValue(e.target.value)}
+ />
+ )}
+ codeTemplate={(props) => {
+ const parts = [' ')
+ return parts.join(' ')
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `}
+ />
+
+
+ `}
+ />
+
+
+
+
+
+ USD} />
+
+
+ USD}
+/>`}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
new file mode 100644
index 000000000..55613542c
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import { useState } from 'react'
+import BaseSelect from '@/components/0_Bruddle/BaseSelect'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function BaseSelectPage() {
+ const [value, setValue] = useState('')
+
+ return (
+
+
+
+
+
+ ', default: '(required)', required: true },
+ { name: 'placeholder', type: 'string', default: "'Select...'" },
+ { name: 'value', type: 'string', default: '(none)' },
+ { name: 'onValueChange', type: '(value: string) => void', default: '(none)' },
+ { name: 'disabled', type: 'boolean', default: 'false' },
+ { name: 'error', type: 'boolean', default: 'false' },
+ ]} />
+
+
+
+
+
+
+
+ `}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ `}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/button/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/button/page.tsx
new file mode 100644
index 000000000..558f96d0e
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/button/page.tsx
@@ -0,0 +1,307 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { WhenToUse } from '../../_components/WhenToUse'
+import { DoDont } from '../../_components/DoDont'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { Playground } from '../../_components/Playground'
+import { PropsTable } from '../../_components/PropsTable'
+import { DesignNote } from '../../_components/DesignNote'
+import { StatusTag } from '../../_components/StatusTag'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function ButtonPage() {
+ return (
+
+
+
+
+
+
+ Continue
+
+ }
+ doLabel='Default height (no size prop) for primary CTAs'
+ dontExample={
+
+ Continue
+
+ }
+ dontLabel='size="large" is actually shorter (h-10 vs h-13)'
+ />
+
+
+
+
+ {
+ const { children, ...rest } = props
+ return (
+
+ {children || 'Button'}
+
+ )
+ }}
+ codeTemplate={(props) => {
+ const parts = ['${props.children || 'Button'} `)
+ return parts.join(' ')
+ }}
+ />
+
+
+
+
+
+
+
+ {(
+ [
+ ['purple', '59 usages', 'production'],
+ ['stroke', '27 usages', 'production'],
+ ['primary-soft', '18 usages', 'production'],
+ ['transparent', '12 usages', 'production'],
+ ['dark', '2 usages', 'limited'],
+ ['transparent-dark', '3 usages', 'limited'],
+ ] as const
+ ).map(([variant, count, status]) => (
+
+
+ {variant}
+ {count}
+
+
+
{variant}
+
+ ))}
+
+
+ transparent-light
+ 2 usages
+
+
+
+ transparent-light
+
+
+
+
+
+ Primary
+Stroke
+Soft
+Transparent `}
+ />
+
+
+
+
+
+
+
+
default
+
h-13 (52px)
+
+
+
small
+
h-8 · 29 usages
+
+
+
medium
+
h-9 · 10 usages
+
+
+
large
+
h-10 · 5 usages
+
+
+
+
+ Default
+
+{/* Named sizes are SHORTER */}
+Small (h-8)
+Medium (h-9)
+Large (h-10) `}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ size="large" is h-10 — SHORTER than default h-13. Default is the tallest button. Primary
+ CTAs should use NO size prop.
+
+
+ Primary CTA pattern: variant="purple" shadowSize="4" className="w-full"
+ — no size prop.
+
+
+
+
+
+
+
+
+
Primary CTA (most common)
+
Continue
+
+
+
Secondary CTA
+
Go Back
+
+
+
With icon
+
+ Share
+ Copy
+
+
+
+
States
+
+ Disabled
+ Loading
+
+
+
+
+
+
+
+ Continue
+`}
+ />
+
+ Go Back
+`}
+ />
+
+ Share
+`}
+ />
+ Loading...
+Disabled `}
+ />
+ handleConfirm(),
+ }}
+>
+ Hold to confirm
+`}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
new file mode 100644
index 000000000..2fbc03097
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import { Card } from '@/components/0_Bruddle/Card'
+import { Playground } from '../../_components/Playground'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function CardPage() {
+ return (
+
+
+
+ (
+
+
+ Card Title
+ A description of the card content
+
+
+ Body content goes here
+
+
+ )}
+ codeTemplate={(props) => {
+ const parts = ['')
+ return parts.join(' ') + '\n \n Title \n Description \n \n Content \n '
+ }}
+ />
+
+
+
+
+
+
+
+
+
No shadow
+
shadowSize="4"
+
shadowSize="6"
+
shadowSize="8"
+
+
+
+
+
+
+
+
+
+
+
+ Card Title
+ description text
+
+
+ body content
+
+
+
+
+
+
+ Title
+ Description
+
+ Content
+`}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
new file mode 100644
index 000000000..dbcddb4d4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useState } from 'react'
+import Checkbox from '@/components/0_Bruddle/Checkbox'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function CheckboxPage() {
+ const [checked, setChecked] = useState(false)
+
+ return (
+
+
+
+
+
+ void', default: '(required)', required: true },
+ { name: 'label', type: 'string', default: '(none)' },
+ ]} />
+
+
+
+ setChecked(e.target.checked)}
+ />
+
+
+
{}} />
+ Without label
+
+
+
+
+ setChecked(e.target.checked)}
+/>`}
+ />
+ {}} />`}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
new file mode 100644
index 000000000..660bae781
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import Divider from '@/components/0_Bruddle/Divider'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function DividerPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `}
+ />
+ `}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
new file mode 100644
index 000000000..6ebf18b54
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function PageContainerPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Wraps mobile screens with responsive width constraints. Children inherit full width via the *:w-full selector. On desktop (md+), content is offset with md:pl-24 and capped at md:*:max-w-xl.
+
+
+
+
+
+
+
+
+ {/* content */}
+
+
+`}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/page.tsx
new file mode 100644
index 000000000..fd7dba229
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/page.tsx
@@ -0,0 +1,89 @@
+import { CatalogCard, CatalogGrid } from '../_components/CatalogCard'
+import { DocPage } from '../_components/DocPage'
+
+export default function PrimitivesPage() {
+ return (
+
+
+
Primitives
+
+ Bruddle base components. The lowest-level building blocks of the UI.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
new file mode 100644
index 000000000..e6e490023
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import Title from '@/components/0_Bruddle/Title'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function TitlePage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `}
+ />
+ `}
+ />
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
new file mode 100644
index 000000000..bc734c4f7
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import { useToast } from '@/components/0_Bruddle/Toast'
+import { PropsTable } from '../../_components/PropsTable'
+import { DocHeader } from '../../_components/DocHeader'
+import { DocSection } from '../../_components/DocSection'
+import { SectionDivider } from '../../_components/SectionDivider'
+import { DocPage } from '../../_components/DocPage'
+import { CodeBlock } from '../../_components/CodeBlock'
+
+export default function ToastPage() {
+ const { success, error, info, warning } = useToast()
+
+ return (
+
+
+
+
+
+
+ success('Operation successful!')}>success
+ error('Something went wrong')}>error
+ info('Did you know?')}>info
+ warning('Check this out')}>warning
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx
index 446ab6677..e2f74bb67 100644
--- a/src/app/(mobile-ui)/dev/page.tsx
+++ b/src/app/(mobile-ui)/dev/page.tsx
@@ -27,28 +27,10 @@ export default function DevToolsPage() {
icon: 'dollar',
},
{
- name: 'Shake Test',
- description: 'Test progressive shake animation and confetti for perk claiming',
- path: '/dev/shake-test',
- icon: 'bulb',
- },
- {
- name: 'Gift Test',
- description: 'Test gift box unwrap animations and variants',
- path: '/dev/gift-test',
- icon: 'gift',
- },
- {
- name: 'Perk Success Test',
- description: 'Test the perk claim success screen with mock perks',
- path: '/dev/perk-success-test',
- icon: 'check-circle',
- },
- {
- name: 'Components',
- description: 'Design system showcase: buttons, cards, inputs, and all variants',
- path: '/dev/components',
- icon: 'bulb',
+ name: 'Design System',
+ description: 'Foundations, primitives, patterns, and interactive playground',
+ path: '/dev/ds',
+ icon: 'docs',
},
]
diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx
new file mode 100644
index 000000000..15b7102b7
--- /dev/null
+++ b/src/app/[locale]/(marketing)/[country]/page.tsx
@@ -0,0 +1,48 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
+import { SUPPORTED_LOCALES, isValidLocale, getBareAlternates } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t } from '@/i18n'
+import { HubPageContent } from '@/components/Marketing/pages/HubPageContent'
+
+interface PageProps {
+ params: Promise<{ locale: string; country: string }>
+}
+
+export async function generateStaticParams() {
+ const countries = Object.keys(COUNTRIES_SEO)
+ return SUPPORTED_LOCALES.flatMap((locale) => countries.map((country) => ({ locale, country })))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const seo = COUNTRIES_SEO[country]
+ if (!seo) return {}
+
+ const i18n = getTranslations(locale as Locale)
+ const countryName = getCountryName(country, locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.hubTitle, { country: countryName })} | Peanut`,
+ description: t(i18n.hubSubtitle, { country: countryName }),
+ canonical: `/${locale}/${country}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/${country}`,
+ languages: getBareAlternates(country),
+ },
+ }
+}
+
+export default async function CountryHubPage({ params }: PageProps) {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) notFound()
+ if (!COUNTRIES_SEO[country]) notFound()
+
+ return
+}
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
new file mode 100644
index 000000000..94ae26187
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -0,0 +1,84 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { getAllPosts, getPostBySlug } from '@/lib/blog'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+
+interface PageProps {
+ params: Promise<{ locale: string; slug: string }>
+}
+
+export async function generateStaticParams() {
+ // Generate params for locales that have blog content (fall back to en slugs)
+ return SUPPORTED_LOCALES.flatMap((locale) => {
+ let posts = getAllPosts(locale as Locale)
+ if (posts.length === 0) posts = getAllPosts('en')
+ return posts.map((post) => ({ locale, slug: post.slug }))
+ })
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, slug } = await params
+ if (!isValidLocale(locale)) return {}
+
+ // Try locale-specific post first, fall back to English
+ const post = (await getPostBySlug(slug, locale as Locale)) ?? (await getPostBySlug(slug, 'en'))
+ if (!post) return {}
+
+ return {
+ ...metadataHelper({
+ title: `${post.frontmatter.title} | Peanut`,
+ description: post.frontmatter.description,
+ canonical: `/${locale}/blog/${slug}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/blog/${slug}`,
+ languages: getAlternates('blog', slug),
+ },
+ }
+}
+
+export default async function BlogPostPageLocalized({ params }: PageProps) {
+ const { locale, slug } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const post = (await getPostBySlug(slug, locale as Locale)) ?? (await getPostBySlug(slug, 'en'))
+ if (!post) notFound()
+
+ const blogPostSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BlogPosting',
+ headline: post.frontmatter.title,
+ description: post.frontmatter.description,
+ datePublished: post.frontmatter.date,
+ inLanguage: locale,
+ author: { '@type': 'Organization', name: post.frontmatter.author ?? 'Peanut' },
+ publisher: { '@type': 'Organization', name: 'Peanut', url: 'https://peanut.me' },
+ mainEntityOfPage: `https://peanut.me/${locale}/blog/${slug}`,
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
new file mode 100644
index 000000000..83ee84d9c
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
@@ -0,0 +1,99 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { getAllCategories, getPostsByCategory } from '@/lib/blog'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { BlogCard } from '@/components/Marketing/BlogCard'
+import Link from 'next/link'
+import { SUPPORTED_LOCALES, isValidLocale, getAlternates } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations } from '@/i18n'
+
+interface PageProps {
+ params: Promise<{ locale: string; cat: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.flatMap((locale) => {
+ // Use English categories as fallback
+ const cats = getAllCategories(locale as Locale)
+ const fallbackCats = cats.length > 0 ? cats : getAllCategories('en')
+ return fallbackCats.map((cat) => ({ locale, cat }))
+ })
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, cat } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const label = cat.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+
+ return {
+ ...metadataHelper({
+ title: `${label} — Blog | Peanut`,
+ description: label,
+ canonical: `/${locale}/blog/category/${cat}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/blog/category/${cat}`,
+ languages: getAlternates('blog', `category/${cat}`),
+ },
+ }
+}
+
+export default async function BlogCategoryPageLocalized({ params }: PageProps) {
+ const { locale, cat } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const typedLocale = locale as Locale
+ const i18n = getTranslations(typedLocale)
+
+ let posts = getPostsByCategory(cat, typedLocale)
+ if (posts.length === 0) posts = getPostsByCategory(cat, 'en')
+ if (posts.length === 0) notFound()
+
+ const label = cat.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+ const categories = getAllCategories(typedLocale).length > 0 ? getAllCategories(typedLocale) : getAllCategories('en')
+
+ return (
+ <>
+
+
+
+
+ {i18n.allArticles}
+
+ {categories.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+ {posts.map((post) => (
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/blog/page.tsx b/src/app/[locale]/(marketing)/blog/page.tsx
new file mode 100644
index 000000000..14c5d482b
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/page.tsx
@@ -0,0 +1,97 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { getAllPosts, getAllCategories } from '@/lib/blog'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { BlogCard } from '@/components/Marketing/BlogCard'
+import Link from 'next/link'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations } from '@/i18n'
+
+interface PageProps {
+ params: Promise<{ locale: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.map((locale) => ({ locale }))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${i18n.blog} | Peanut`,
+ description: i18n.allArticles,
+ canonical: `/${locale}/blog`,
+ }),
+ alternates: {
+ canonical: `/${locale}/blog`,
+ languages: getAlternates('blog'),
+ },
+ }
+}
+
+export default async function BlogIndexPageLocalized({ params }: PageProps) {
+ const { locale } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const typedLocale = locale as Locale
+ const i18n = getTranslations(typedLocale)
+
+ // Try locale-specific posts first, fall back to English
+ let posts = getAllPosts(typedLocale)
+ if (posts.length === 0) posts = getAllPosts('en')
+
+ const categories = getAllCategories(typedLocale)
+
+ return (
+ <>
+
+
+ {categories.length > 0 && (
+
+
+ {i18n.allArticles}
+
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
+
+ )}
+
+ {posts.length > 0 ? (
+
+ {posts.map((post) => (
+
+ ))}
+
+ ) : (
+ Blog posts coming soon.
+ )}
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
new file mode 100644
index 000000000..9b7895a0c
--- /dev/null
+++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
@@ -0,0 +1,119 @@
+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'
+
+interface PageProps {
+ params: Promise<{ locale: string; slug: string }>
+}
+
+export async function generateStaticParams() {
+ const slugs = Object.keys(COMPETITORS)
+ return SUPPORTED_LOCALES.flatMap((locale) => slugs.map((slug) => ({ locale, slug: `peanut-vs-${slug}` })))
+}
+
+/** Strip the "peanut-vs-" URL prefix to get the data key. Returns null if prefix missing. */
+function parseSlug(raw: string): string | null {
+ if (!raw.startsWith('peanut-vs-')) return null
+ return raw.slice('peanut-vs-'.length)
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, slug: rawSlug } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const slug = parseSlug(rawSlug)
+ if (!slug) return {}
+ const competitor = COMPETITORS[slug]
+ if (!competitor) return {}
+ const year = new Date().getFullYear()
+
+ return {
+ ...metadataHelper({
+ title: `Peanut vs ${competitor.name} ${year} | Peanut`,
+ description: `Peanut vs ${competitor.name}: ${competitor.tagline}`,
+ canonical: `/${locale}/compare/peanut-vs-${slug}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/compare/peanut-vs-${slug}`,
+ languages: getAlternates('compare', `peanut-vs-${slug}`),
+ },
+ }
+}
+
+export default async function ComparisonPageLocalized({ params }: PageProps) {
+ const { locale, slug: rawSlug } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const slug = parseSlug(rawSlug)
+ if (!slug) notFound()
+ const competitor = COMPETITORS[slug]
+ if (!competitor) notFound()
+
+ 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}`,
+ },
+ ],
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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] })}
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/convert/[pair]/page.tsx b/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
new file mode 100644
index 000000000..6004b59cb
--- /dev/null
+++ b/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
@@ -0,0 +1,139 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { CONVERT_PAIRS, CURRENCY_DISPLAY, parseConvertPair } from '@/data/seo'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { Section } from '@/components/Marketing/Section'
+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 } from '@/i18n'
+
+export const revalidate = 300
+
+interface PageProps {
+ params: Promise<{ locale: string; pair: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.flatMap((locale) => CONVERT_PAIRS.map((pair) => ({ locale, pair })))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, pair } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const parsed = parseConvertPair(pair)
+ if (!parsed) return {}
+ const fromDisplay = CURRENCY_DISPLAY[parsed.from]
+ const toDisplay = CURRENCY_DISPLAY[parsed.to]
+ if (!fromDisplay || !toDisplay) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.convertTitle, { from: parsed.from.toUpperCase(), to: parsed.to.toUpperCase() })} | Peanut`,
+ description: `${t(i18n.convertTitle, { from: fromDisplay.name, to: toDisplay.name })}`,
+ canonical: `/${locale}/convert/${pair}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/convert/${pair}`,
+ languages: getAlternates('convert', pair),
+ },
+ }
+}
+
+export default async function ConvertPairPageLocalized({ params }: PageProps) {
+ const { locale, pair } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const parsed = parseConvertPair(pair)
+ if (!parsed || !(CONVERT_PAIRS as readonly string[]).includes(pair)) notFound()
+
+ const fromDisplay = CURRENCY_DISPLAY[parsed.from]
+ const toDisplay = CURRENCY_DISPLAY[parsed.to]
+ if (!fromDisplay || !toDisplay) notFound()
+
+ const i18n = getTranslations(locale as Locale)
+ const fromCode = parsed.from.toUpperCase()
+ const toCode = parsed.to.toUpperCase()
+ const conversionAmounts = [10, 50, 100, 250, 500, 1000]
+
+ 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: t(i18n.convertTitle, { from: fromCode, to: toCode }),
+ item: `https://peanut.me/${locale}/convert/${pair}`,
+ },
+ ],
+ }
+
+ const faqs = [
+ {
+ q: t(i18n.convertTitle, { from: fromCode, to: toCode }) + '?',
+ a: `Peanut — ${t(i18n.convertTitle, { from: fromDisplay.name, to: toDisplay.name })}`,
+ },
+ ]
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {fromCode}
+ {toCode}
+
+
+
+ {conversionAmounts.map((amount, i) => (
+
+
+ {fromDisplay.symbol}
+ {amount.toLocaleString()}
+
+
+ {i18n.liveRate}
+
+
+ ))}
+
+
+
+
+
+
+
+ {i18n.stepCreateAccountDesc}
+ {t(i18n.stepDepositFundsDesc, { method: '' })}
+ {t(i18n.stepSendToDesc, { currency: toDisplay.name, method: '' })}
+
+
+
+ {/* TODO (marketer): Add 300+ words of editorial content per currency pair to avoid
+ thin content flags. Include: currency background, exchange rate trends,
+ tips for getting best rates, common use cases. See Wise convert pages for reference. */}
+
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
new file mode 100644
index 000000000..29d903421
--- /dev/null
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -0,0 +1,148 @@
+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'
+
+interface PageProps {
+ params: Promise<{ locale: string; exchange: string }>
+}
+
+export async function generateStaticParams() {
+ const exchanges = Object.keys(EXCHANGES)
+ return SUPPORTED_LOCALES.flatMap((locale) => exchanges.map((exchange) => ({ locale, exchange: `from-${exchange}` })))
+}
+
+/** Strip the "from-" URL prefix to get the data key. Returns null if prefix missing. */
+function parseExchange(raw: string): string | null {
+ if (!raw.startsWith('from-')) return null
+ return raw.slice('from-'.length)
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, exchange: rawExchange } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const exchange = parseExchange(rawExchange)
+ if (!exchange) return {}
+ const ex = EXCHANGES[exchange]
+ if (!ex) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.depositFrom, { exchange: ex.name })} | Peanut`,
+ description: `${t(i18n.depositFrom, { exchange: ex.name })}. ${i18n.recommendedNetwork}: ${ex.recommendedNetwork}.`,
+ canonical: `/${locale}/deposit/from-${exchange}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/deposit/from-${exchange}`,
+ languages: getAlternates('deposit', `from-${exchange}`),
+ },
+ }
+}
+
+export default async function DepositPageLocalized({ params }: PageProps) {
+ const { locale, exchange: rawExchange } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const exchange = parseExchange(rawExchange)
+ if (!exchange) notFound()
+ const ex = EXCHANGES[exchange]
+ if (!ex) notFound()
+
+ const i18n = getTranslations(locale as Locale)
+
+ 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,
+ })),
+ }
+
+ 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] })}
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
new file mode 100644
index 000000000..2c3696de7
--- /dev/null
+++ b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
@@ -0,0 +1,50 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { PAYMENT_METHODS, PAYMENT_METHOD_SLUGS } from '@/data/seo'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t } from '@/i18n'
+import { PayWithContent } from '@/components/Marketing/pages/PayWithContent'
+
+interface PageProps {
+ params: Promise<{ locale: string; method: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.flatMap((locale) =>
+ PAYMENT_METHOD_SLUGS.map((method) => ({ locale, method }))
+ )
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, method } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const pm = PAYMENT_METHODS[method]
+ if (!pm) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.payWith, { method: pm.name })} | Peanut`,
+ description: t(i18n.payWithDesc, { method: pm.name }),
+ canonical: `/${locale}/pay-with/${method}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/pay-with/${method}`,
+ languages: getAlternates('pay-with', method),
+ },
+ }
+}
+
+export default async function PayWithPage({ params }: PageProps) {
+ const { locale, method } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const pm = PAYMENT_METHODS[method]
+ if (!pm) notFound()
+
+ return
+}
diff --git a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
new file mode 100644
index 000000000..b25f574b1
--- /dev/null
+++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
@@ -0,0 +1,51 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+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'
+
+interface PageProps {
+ params: Promise<{ locale: string; country: string }>
+}
+
+/** Unique sending countries */
+function getReceiveSources(): string[] {
+ return [...new Set(CORRIDORS.map((c) => c.from))]
+}
+
+export async function generateStaticParams() {
+ const sources = getReceiveSources()
+ return SUPPORTED_LOCALES.flatMap((locale) => sources.map((country) => ({ locale, country })))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) return {}
+ if (!getReceiveSources().includes(country)) return {}
+
+ const i18n = getTranslations(locale as Locale)
+ const countryName = getCountryName(country, locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.receiveMoneyFrom, { country: countryName })} | Peanut`,
+ description: t(i18n.receiveMoneyFromDesc, { country: countryName }),
+ canonical: `/${locale}/receive-money-from/${country}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/receive-money-from/${country}`,
+ languages: getAlternates('receive-money-from', country),
+ },
+ }
+}
+
+export default async function ReceiveMoneyPage({ params }: PageProps) {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) notFound()
+ if (!getReceiveSources().includes(country)) notFound()
+
+ return
+}
diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
new file mode 100644
index 000000000..ec5cfe729
--- /dev/null
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -0,0 +1,54 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { COUNTRIES_SEO, 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 { FromToCorridorContent } from '@/components/Marketing/pages/FromToCorridorContent'
+import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
+
+interface PageProps {
+ params: Promise<{ locale: string; from: string; to: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.flatMap((locale) =>
+ CORRIDORS.map((c) => ({ locale, from: c.from, to: c.to }))
+ )
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, from, to } = await params
+ if (!isValidLocale(locale)) return {}
+
+ if (!CORRIDORS.some((c) => c.from === from && c.to === to)) return {}
+
+ const i18n = getTranslations(locale as Locale)
+ const fromName = getCountryName(from, locale as Locale)
+ const toName = getCountryName(to, locale as Locale)
+
+ const toMapping = countryCurrencyMappings.find(
+ (m) => m.path === to || m.country.toLowerCase().replace(/ /g, '-') === to
+ )
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.sendMoneyFromTo, { from: fromName, to: toName })} | Peanut`,
+ description: t(i18n.sendMoneyFromToDesc, { from: fromName, to: toName }),
+ canonical: `/${locale}/send-money-from/${from}/to/${to}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/send-money-from/${from}/to/${to}`,
+ languages: getAlternates('send-money-from', `${from}/to/${to}`),
+ },
+ }
+}
+
+export default async function FromToCorridorPage({ params }: PageProps) {
+ const { locale, from, to } = await params
+ if (!isValidLocale(locale)) notFound()
+ if (!CORRIDORS.some((c) => c.from === from && c.to === to)) notFound()
+
+ return
+}
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
new file mode 100644
index 000000000..2b93f2540
--- /dev/null
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -0,0 +1,54 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t } from '@/i18n'
+import { CorridorPageContent } from '@/components/Marketing/pages/CorridorPageContent'
+import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
+
+interface PageProps {
+ params: Promise<{ locale: string; country: string }>
+}
+
+export async function generateStaticParams() {
+ const countries = Object.keys(COUNTRIES_SEO)
+ return SUPPORTED_LOCALES.flatMap((locale) => countries.map((country) => ({ locale, country })))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const seo = COUNTRIES_SEO[country]
+ if (!seo) return {}
+
+ const i18n = getTranslations(locale as Locale)
+ const countryName = getCountryName(country, locale as Locale)
+ const mapping = countryCurrencyMappings.find(
+ (m) => m.path === country || m.country.toLowerCase().replace(/ /g, '-') === country
+ )
+
+ return {
+ ...metadataHelper({
+ title: `${t(i18n.sendMoneyTo, { country: countryName })} | Peanut`,
+ description: t(i18n.sendMoneyToSubtitle, {
+ country: countryName,
+ currency: mapping?.currencyCode ?? '',
+ }),
+ canonical: `/${locale}/send-money-to/${country}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/send-money-to/${country}`,
+ languages: getAlternates('send-money-to', country),
+ },
+ }
+}
+
+export default async function SendMoneyToCountryPageLocalized({ params }: PageProps) {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ return
+}
diff --git a/src/app/[locale]/(marketing)/send-money-to/page.tsx b/src/app/[locale]/(marketing)/send-money-to/page.tsx
new file mode 100644
index 000000000..12a9033df
--- /dev/null
+++ b/src/app/[locale]/(marketing)/send-money-to/page.tsx
@@ -0,0 +1,71 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { DestinationGrid } from '@/components/Marketing/DestinationGrid'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations } from '@/i18n'
+
+interface PageProps {
+ params: Promise<{ locale: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.map((locale) => ({ locale }))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${i18n.sendMoney} | Peanut`,
+ description: i18n.sendMoneyToSubtitle.replace('{country}', '').replace('{currency}', '').trim(),
+ canonical: `/${locale}/send-money-to`,
+ }),
+ alternates: {
+ canonical: `/${locale}/send-money-to`,
+ languages: getAlternates('send-money-to'),
+ },
+ }
+}
+
+export default async function SendMoneyToIndexPageLocalized({ params }: PageProps) {
+ const { locale } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const i18n = getTranslations(locale as Locale)
+
+ 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: i18n.sendMoney,
+ item: `https://peanut.me/${locale}/send-money-to`,
+ },
+ ],
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/(marketing)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx
new file mode 100644
index 000000000..cbae53ce5
--- /dev/null
+++ b/src/app/[locale]/(marketing)/team/page.tsx
@@ -0,0 +1,112 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { TEAM_MEMBERS } from '@/data/team'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+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 } from '@/i18n'
+
+interface PageProps {
+ params: Promise<{ locale: string }>
+}
+
+export async function generateStaticParams() {
+ return SUPPORTED_LOCALES.map((locale) => ({ locale }))
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { locale } = await params
+ if (!isValidLocale(locale)) return {}
+
+ const i18n = getTranslations(locale as Locale)
+
+ return {
+ ...metadataHelper({
+ title: `${i18n.teamTitle} | Peanut`,
+ description: i18n.teamSubtitle,
+ canonical: `/${locale}/team`,
+ }),
+ alternates: {
+ canonical: `/${locale}/team`,
+ languages: getAlternates('team'),
+ },
+ }
+}
+
+export default async function TeamPage({ params }: PageProps) {
+ const { locale } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const i18n = getTranslations(locale as Locale)
+
+ const orgSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ name: 'Peanut',
+ url: 'https://peanut.me',
+ member: TEAM_MEMBERS.map((m) => ({
+ '@type': 'Person',
+ name: m.name,
+ jobTitle: m.role,
+ ...(m.social?.linkedin ? { sameAs: [m.social.linkedin] } : {}),
+ })),
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {TEAM_MEMBERS.map((member) => (
+
+ {member.image ? (
+
+ ) : (
+
+ {member.name.charAt(0)}
+
+ )}
+
+
{member.name}
+
{member.role}
+
+ {member.bio}
+ {member.social && (
+
+ )}
+
+ ))}
+
+
+ >
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ebc435325..d4c24586d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -8,6 +8,7 @@ import { PEANUT_API_URL, BASE_URL } from '@/constants/general.consts'
import { type Metadata } from 'next'
const baseUrl = BASE_URL || 'https://peanut.me'
+const IS_PRODUCTION_DOMAIN = baseUrl === 'https://peanut.me'
export const metadata: Metadata = {
title: 'Peanut - Instant Global P2P Payments in Digital Dollars',
@@ -15,8 +16,11 @@ export const metadata: Metadata = {
'Send and receive money instantly with Peanut - a fast, peer-to-peer payments app powered by digital dollars. Easily transfer funds across borders. Enjoy cheap, instant remittances and cash out to local banks without technical hassle.',
metadataBase: new URL(baseUrl),
icons: { icon: '/favicon.ico' },
+ alternates: { canonical: '/' },
keywords:
'peer-to-peer payments, send money instantly, request money, fast global transfers, remittances, digital dollar transfers, Latin America, Argentina, Brazil, P2P payments, crypto payments, stablecoin, digital dollars',
+ // Block staging/preview deploys from indexing (belt-and-suspenders with robots.ts)
+ robots: IS_PRODUCTION_DOMAIN ? { index: true, follow: true } : { index: false, follow: false },
openGraph: {
type: 'website',
title: 'Peanut - Instant Global P2P Payments in Digital Dollars',
@@ -38,6 +42,51 @@ export const metadata: Metadata = {
applicationName: process.env.NODE_ENV === 'development' ? 'Peanut Dev' : 'Peanut',
}
+// JSON-LD structured data — site-wide schemas (Organization, WebApplication, WebSite)
+// FAQPage schema moved to page.tsx (homepage) where it belongs
+const jsonLd = {
+ '@context': 'https://schema.org',
+ '@graph': [
+ {
+ '@type': 'Organization',
+ '@id': `${baseUrl}/#organization`,
+ name: 'Peanut',
+ url: baseUrl,
+ logo: {
+ '@type': 'ImageObject',
+ url: `${baseUrl}/metadata-img.png`,
+ },
+ sameAs: [
+ 'https://twitter.com/PeanutProtocol',
+ 'https://github.com/peanutprotocol',
+ 'https://www.linkedin.com/company/peanut-trade/',
+ ],
+ },
+ {
+ '@type': 'WebApplication',
+ '@id': `${baseUrl}/#app`,
+ name: 'Peanut',
+ url: baseUrl,
+ applicationCategory: 'FinanceApplication',
+ operatingSystem: 'Web',
+ offers: {
+ '@type': 'Offer',
+ price: '0',
+ priceCurrency: 'USD',
+ },
+ description:
+ 'Send and receive money instantly with Peanut — a fast, peer-to-peer payments app powered by digital dollars.',
+ },
+ {
+ '@type': 'WebSite',
+ '@id': `${baseUrl}/#website`,
+ name: 'Peanut',
+ url: baseUrl,
+ publisher: { '@id': `${baseUrl}/#organization` },
+ },
+ ],
+}
+
const roboto = Roboto_Flex({
subsets: ['latin'],
display: 'swap',
@@ -94,6 +143,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+ {/* JSON-LD structured data */}
+
+
+ {/* AI-readable product description (llms.txt spec) */}
+
+
{/* DNS prefetch for API */}
diff --git a/src/app/lp/card/CardLandingPage.tsx b/src/app/lp/card/CardLandingPage.tsx
index 1cd3a6bbf..964a9b34c 100644
--- a/src/app/lp/card/CardLandingPage.tsx
+++ b/src/app/lp/card/CardLandingPage.tsx
@@ -1,16 +1,14 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
-import Layout from '@/components/Global/Layout'
import { Button } from '@/components/0_Bruddle/Button'
import { FAQsPanel } from '@/components/Global/FAQs'
import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
-import Footer from '@/components/LandingPage/Footer'
import { Marquee } from '@/components/LandingPage'
import { useAuth } from '@/context/authContext'
import { useRouter } from 'next/navigation'
import { Star, HandThumbsUp } from '@/assets'
-import { useState, useEffect } from 'react'
+import { useEffect } from 'react'
import underMaintenanceConfig from '@/config/underMaintenance.config'
const faqQuestions = [
@@ -44,8 +42,6 @@ const faqQuestions = [
const CardLandingPage = () => {
const { user } = useAuth()
const router = useRouter()
- const [isMobile, setIsMobile] = useState(false)
-
// feature flag: redirect to landing if card pioneers is disabled
useEffect(() => {
if (underMaintenanceConfig.disableCardPioneers) {
@@ -53,13 +49,6 @@ const CardLandingPage = () => {
}
}, [router])
- useEffect(() => {
- const checkMobile = () => setIsMobile(window.innerWidth < 768)
- checkMobile()
- window.addEventListener('resize', checkMobile)
- return () => window.removeEventListener('resize', checkMobile)
- }, [])
-
if (underMaintenanceConfig.disableCardPioneers) {
return null
}
@@ -79,10 +68,10 @@ const CardLandingPage = () => {
}
return (
-
+ <>
{/* Hero Section - Yellow with card */}
- {!isMobile && }
+
@@ -146,7 +135,7 @@ const CardLandingPage = () => {
className="relative overflow-hidden py-20"
style={{ backgroundColor: '#F9F4F0' }}
>
- {!isMobile &&
}
+
{
className="relative overflow-hidden py-20"
style={{ backgroundColor: '#F9F4F0' }}
>
- {!isMobile && }
+
{/* Visual - Simplified Invite Visual */}
@@ -632,7 +621,7 @@ const CardLandingPage = () => {
{/* Coverage - Yellow */}
- {!isMobile && }
+
{
{/* FAQ - Cream */}
- {!isMobile && }
+
@@ -744,7 +733,7 @@ const CardLandingPage = () => {
{/* Final CTA - Secondary Yellow */}
- {!isMobile && }
+
{
-
-
+ >
)
}
-// Floating stars component - matches Manteca.tsx pattern exactly
+// Floating stars component
const FloatingStars = () => {
- // Match Manteca's star configuration pattern
const starConfigs = [
{ className: 'absolute left-12 top-10', delay: 0.2 },
{ className: 'absolute left-56 top-1/2', delay: 0.2 },
@@ -820,7 +807,6 @@ const FloatingStars = () => {
width={50}
height={50}
className={`${config.className} hidden md:block`}
- // Exact Manteca animation pattern
initial={{ opacity: 0, translateY: 20, translateX: 5, rotate: 22 }}
whileInView={{ opacity: 1, translateY: 0, translateX: 0, rotate: 22 }}
transition={{ type: 'spring', damping: 5, delay: config.delay }}
diff --git a/src/app/lp/card/page.tsx b/src/app/lp/card/page.tsx
index 4952bf794..5446cf17c 100644
--- a/src/app/lp/card/page.tsx
+++ b/src/app/lp/card/page.tsx
@@ -1,4 +1,6 @@
import { generateMetadata as generateMeta } from '@/app/metadata'
+import { LandingPageShell } from '@/components/LandingPage/LandingPageShell'
+import Footer from '@/components/LandingPage/Footer'
import CardLandingPage from './CardLandingPage'
export const metadata = generateMeta({
@@ -10,5 +12,10 @@ export const metadata = generateMeta({
})
export default function CardLPPage() {
- return
+ return (
+
+
+
+
+ )
}
diff --git a/src/app/lp/layout.tsx b/src/app/lp/layout.tsx
new file mode 100644
index 000000000..60b9e0126
--- /dev/null
+++ b/src/app/lp/layout.tsx
@@ -0,0 +1,10 @@
+import { type Metadata } from 'next'
+
+// /lp is an alias for the root landing page — canonical points to /
+export const metadata: Metadata = {
+ alternates: { canonical: '/' },
+}
+
+export default function LpLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/src/app/lp/page.tsx b/src/app/lp/page.tsx
index e613c7406..fe98c7c1e 100644
--- a/src/app/lp/page.tsx
+++ b/src/app/lp/page.tsx
@@ -3,6 +3,33 @@
/**
* /lp route - Landing page that is ALWAYS accessible regardless of auth state.
* This allows logged-in users to view the marketing landing page.
- * For SEO, the root "/" remains the canonical landing page URL.
+ * Uses Layout (client) instead of LandingPageShell since SSR doesn't matter here.
*/
-export { default } from '@/app/page'
+
+import Layout from '@/components/Global/Layout'
+import { LandingPageClient } from '@/components/LandingPage/LandingPageClient'
+import Manteca from '@/components/LandingPage/Manteca'
+import { RegulatedRails } from '@/components/LandingPage/RegulatedRails'
+import { YourMoney } from '@/components/LandingPage/yourMoney'
+import { SecurityBuiltIn } from '@/components/LandingPage/securityBuiltIn'
+import { SendInSeconds } from '@/components/LandingPage/sendInSeconds'
+import Footer from '@/components/LandingPage/Footer'
+import { heroConfig, faqData, marqueeMessages } from '@/components/LandingPage/landingPageData'
+
+export default function LPPage() {
+ return (
+
+ }
+ regulatedRailsSlot={ }
+ yourMoneySlot={ }
+ securitySlot={ }
+ sendInSecondsSlot={ }
+ footerSlot={}
+ />
+
+ )
+}
diff --git a/src/app/metadata.ts b/src/app/metadata.ts
index c7aeb73c7..fefc08636 100644
--- a/src/app/metadata.ts
+++ b/src/app/metadata.ts
@@ -6,11 +6,14 @@ export function generateMetadata({
description,
image = '/metadata-img.png',
keywords,
+ canonical,
}: {
title: string
description: string
image?: string
keywords?: string
+ /** Canonical URL path (e.g. '/careers') or full URL. Resolved against metadataBase. */
+ canonical?: string
}): Metadata {
return {
title,
@@ -35,5 +38,6 @@ export function generateMetadata({
site: '@PeanutProtocol',
},
applicationName: process.env.NODE_ENV === 'development' ? 'Peanut Dev' : 'Peanut',
+ ...(canonical ? { alternates: { canonical } } : {}),
}
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index ee0810ef1..a59446b72 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,228 +1,36 @@
-'use client'
-
-import Layout from '@/components/Global/Layout'
-import {
- DropLink,
- FAQs,
- Hero,
- Marquee,
- NoFees,
- SecurityBuiltIn,
- SendInSeconds,
- YourMoney,
- RegulatedRails,
- CardPioneers,
-} from '@/components/LandingPage'
-import Footer from '@/components/LandingPage/Footer'
+import { Suspense } from 'react'
+import { LandingPageShell } from '@/components/LandingPage/LandingPageShell'
+import { LandingPageClient } from '@/components/LandingPage/LandingPageClient'
import Manteca from '@/components/LandingPage/Manteca'
-import TweetCarousel from '@/components/LandingPage/TweetCarousel'
-import underMaintenanceConfig from '@/config/underMaintenance.config'
-import { useFooterVisibility } from '@/context/footerVisibility'
-import { useEffect, useState, useRef } from 'react'
+import { RegulatedRails } from '@/components/LandingPage/RegulatedRails'
+import { YourMoney } from '@/components/LandingPage/yourMoney'
+import { SecurityBuiltIn } from '@/components/LandingPage/securityBuiltIn'
+import { SendInSeconds } from '@/components/LandingPage/sendInSeconds'
+import Footer from '@/components/LandingPage/Footer'
+import { faqSchema, JsonLd } from '@/lib/seo/schemas'
+import { heroConfig, faqData, marqueeMessages } from '@/components/LandingPage/landingPageData'
export default function LandingPage() {
- const { isFooterVisible } = useFooterVisibility()
- 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 previousScrollY = useRef(0)
-
- const hero = {
- heading: 'Peanut',
- marquee: {
- visible: true,
- message: ['No fees', 'Instant', '24/7', 'USD', 'EUR', 'CRYPTO', 'GLOBAL', 'SELF-CUSTODIAL'],
- },
- primaryCta: {
- label: 'SIGN UP',
- href: '/setup',
- subtext: 'currently in waitlist',
- },
- }
-
- const faqs = {
- heading: 'Faqs',
- questions: [
- {
- id: '0',
- question: 'Why Peanut?',
- answer: `It's time to take control of your money. No banks, no borders. Just buttery smooth global money.`,
- },
- {
- id: '1',
- question: 'What is Peanut?',
- answer: 'Peanut is the easiest way to send digital dollars to anyone anywhere. Peanut’s tech is powered by cutting-edge cryptography and the security of biometric user authentication as well as a network of modern and fully licensed banking providers.',
- },
- {
- id: '2',
- question: 'Do I have to KYC?',
- answer: 'No! You can use core functionalities (like sending and receiving money) without KYC. Bank connections, however, trigger a one‑time check handled by Persona, a SOC2 Type 2 certified and GDPR compliant ISO 27001–certified provider used by brands like Square and Robinhood. Your documents remain locked away with Persona, not Peanut, and Peanut only gets a yes/no response, keeping your privacy intact.',
- },
- {
- id: '3',
- question: 'Could a thief drain my wallet if they stole my phone?',
- answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\nThis means that neither Peanut or even regulators could freeze, us or you to hand over your account, because we can’t hand over what we don’t have. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.',
- },
- {
- id: '4',
- question: 'What happens to my funds if Peanut’s servers were breached?',
- answer: "Nothing. Your funds sit in your self‑custodied smart account (not on Peanut servers). Every transfer still needs a signature from your biometric passkey, so a server‑side attacker has no way to move a cent without the private key sealed in your device's Secure Enclave. Even if Peanut were offline, you could point any ERC‑4337‑compatible wallet at your smart account and recover access independently.",
- },
- {
- id: '5',
- question: 'How does Peanut make money?',
- answer: 'We plan to charge merchants for accepting Peanut as a payment method, whilst still being much cheaper than VISA and Mastercard. For users, we only charge minimal amounts!',
- },
- {
- id: '6',
- question: 'My question is not here',
- answer: 'Check out our full FAQ page at https://peanutprotocol.notion.site/FAQ-2a4838117579805dad62ff47c9d2eb7a or visit our support page at https://peanut.me/support for more help.',
- },
- ],
- marquee: {
- visible: false,
- message: 'Peanut',
- },
- }
-
- useEffect(() => {
- if (isFooterVisible) {
- setButtonVisible(false)
- } else {
- setButtonVisible(true)
- }
- }, [isFooterVisible])
-
- useEffect(() => {
- const handleScroll = () => {
- if (sendInSecondsRef.current) {
- const targetElement = document.getElementById('sticky-button-target')
- if (!targetElement) return
-
- const targetRect = targetElement.getBoundingClientRect()
- const currentScrollY = window.scrollY
-
- // Check if the sticky button should "freeze" at the target position
- // Calculate where the sticky button currently is (bottom-4 = 16px from bottom)
- const stickyButtonTop = window.innerHeight - 16 - 52 // 16px bottom margin, ~52px button height
- const stickyButtonBottom = window.innerHeight - 16
-
- // Freeze when the target element overlaps with the sticky button position (even lower)
- const shouldFreeze =
- targetRect.top <= stickyButtonBottom - 60 &&
- targetRect.bottom >= stickyButtonTop - 60 &&
- !animationComplete &&
- !shrinkingPhase &&
- !hasGrown
-
- if (shouldFreeze && !isScrollFrozen) {
- // Start freeze - prevent normal scrolling
- setIsScrollFrozen(true)
- frozenScrollY.current = currentScrollY
- virtualScrollY.current = 0
- document.body.style.overflow = 'hidden'
- window.scrollTo(0, frozenScrollY.current)
- } else if (isScrollFrozen && !animationComplete) {
- // During freeze - maintain scroll position
- window.scrollTo(0, frozenScrollY.current)
- } else if (animationComplete && !shrinkingPhase && currentScrollY > frozenScrollY.current + 50) {
- // Start shrinking phase when user scrolls further after animation complete
- setShrinkingPhase(true)
- } else if (shrinkingPhase) {
- // Shrink button back to original size based on scroll distance
- 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 // Scale from 1.5 back to 1
- setButtonScale(Math.max(1, newScale))
- } else if (animationComplete && currentScrollY < frozenScrollY.current - 100) {
- // Reset everything when scrolling back up past the SendInSeconds component
- setAnimationComplete(false)
- setShrinkingPhase(false)
- setButtonScale(1)
- setHasGrown(false)
- }
-
- // Update previous scroll position for direction tracking
- previousScrollY.current = currentScrollY
- }
- }
-
- const handleWheel = (event: WheelEvent) => {
- if (isScrollFrozen && !animationComplete) {
- event.preventDefault()
-
- // Only increase scale when scrolling down (positive deltaY)
- if (event.deltaY > 0) {
- virtualScrollY.current += event.deltaY
-
- // Scale button based on virtual scroll (max scale of 1.5) - requires more scrolling
- const maxVirtualScroll = 500 // Increased from 200 to require more scrolling
- const newScale = Math.min(1.5, 1 + (virtualScrollY.current / maxVirtualScroll) * 0.5)
- setButtonScale(newScale)
-
- // Complete animation when we reach max scale
- if (newScale >= 1.5) {
- setAnimationComplete(true)
- setHasGrown(true)
- document.body.style.overflow = ''
- setIsScrollFrozen(false)
- }
- }
- // When scrolling up (negative deltaY), don't change the scale
- }
- }
-
- window.addEventListener('scroll', handleScroll)
- window.addEventListener('wheel', handleWheel, { passive: false })
- handleScroll() // Check initial state
-
- return () => {
- window.removeEventListener('scroll', handleScroll)
- window.removeEventListener('wheel', handleWheel)
- document.body.style.overflow = '' // Cleanup
- }
- }, [isScrollFrozen, animationComplete, shrinkingPhase, hasGrown])
-
- const marqueeProps = { visible: hero.marquee.visible, message: hero.marquee.message }
+ const faqJsonLd = faqSchema(
+ faqData.questions.map((q) => ({ question: q.question, answer: q.answer }))
+ )
return (
-
-
-
-
-
- {!underMaintenanceConfig.disableCardPioneers && (
- <>
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ }
+ regulatedRailsSlot={ }
+ yourMoneySlot={ }
+ securitySlot={ }
+ sendInSecondsSlot={ }
+ footerSlot={}
+ />
+
+
)
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index d9d154d42..3ba28619d 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -1,27 +1,79 @@
import type { MetadataRoute } from 'next'
import { BASE_URL } from '@/constants/general.consts'
+const IS_PRODUCTION_DOMAIN = BASE_URL === 'https://peanut.me'
+
export default function robots(): MetadataRoute.Robots {
+ // Block indexing on staging, preview deploys, and non-production domains
+ if (!IS_PRODUCTION_DOMAIN) {
+ return {
+ rules: [{ userAgent: '*', disallow: ['/'] }],
+ }
+ }
+
return {
rules: [
+ // Allow Twitterbot to fetch OG images for link previews
{
userAgent: 'Twitterbot',
allow: ['/api/og'],
disallow: [],
},
+
+ // AI search engine crawlers — explicitly welcome
{
- userAgent: '*',
- allow: ['/', '/about', '/send', '/request/create', '/cashout', '/jobs'],
- disallow: ['/api/', '/sdk/', '/*dashboard', '/*profile'],
- },
- {
- userAgent: 'AhrefsBot',
- crawlDelay: 10,
+ userAgent: ['GPTBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Google-Extended', 'Applebot-Extended'],
+ allow: ['/'],
+ disallow: ['/api/', '/home', '/profile', '/settings', '/setup', '/dev/'],
},
+
+ // Default rules for all crawlers
{
- userAgent: 'SemrushBot',
- crawlDelay: 10,
+ userAgent: '*',
+ allow: [
+ '/',
+ '/careers',
+ '/privacy',
+ '/terms',
+ '/exchange',
+ '/lp/card',
+ // SEO routes (all locale-prefixed: /en/, /es/, /pt/)
+ '/en/',
+ '/es/',
+ '/pt/',
+ ],
+ disallow: [
+ '/api/',
+ '/sdk/',
+ // Auth-gated app routes
+ '/home',
+ '/profile',
+ '/settings',
+ '/send',
+ '/request',
+ '/setup',
+ '/claim',
+ '/pay',
+ '/dev/',
+ '/qr',
+ '/history',
+ '/points',
+ '/invite',
+ '/kyc',
+ '/maintenance',
+ '/quests',
+ '/receipt',
+ '/crisp-proxy',
+ '/card-payment',
+ '/add-money',
+ '/withdraw',
+ ],
},
+
+ // Rate-limit aggressive SEO crawlers
+ { userAgent: 'AhrefsBot', crawlDelay: 10 },
+ { userAgent: 'SemrushBot', crawlDelay: 10 },
+ { userAgent: 'MJ12bot', crawlDelay: 10 },
],
sitemap: `${BASE_URL}/sitemap.xml`,
}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 22b241b80..4728a6559 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,19 +1,117 @@
import { type MetadataRoute } from 'next'
+import { BASE_URL } from '@/constants/general.consts'
+import { COUNTRIES_SEO, CORRIDORS, CONVERT_PAIRS, COMPETITORS, EXCHANGES } from '@/data/seo'
+import { getAllPosts } from '@/lib/blog'
+import { SUPPORTED_LOCALES } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+
+// TODO (infra): Set up 301 redirect peanut.to/* → peanut.me/ at Vercel/Cloudflare level
+// TODO (infra): Set up 301 redirect docs.peanut.to/* → peanut.me/help
+// TODO (infra): Update GitHub org, Twitter bio, LinkedIn, npm package.json → peanut.me
+// TODO (infra): Add peanut.me to Google Search Console and submit this sitemap
+// TODO (GA4): Create data filter to exclude trafficheap.com referral traffic
+
+/** Payment methods with dedicated pages */
+const PAYMENT_METHODS = ['pix', 'mercadopago', 'spei', 'bank-transfer'] as const
async function generateSitemap(): Promise {
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://peanut.me'
+ type SitemapEntry = {
+ path: string
+ priority: number
+ changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']
+ }
+
+ const pages: SitemapEntry[] = [
+ // Homepage
+ { path: '', priority: 1.0, changeFrequency: 'weekly' },
+
+ // Product pages
+ { path: '/lp/card', priority: 0.9, changeFrequency: 'weekly' },
+
+ // Public pages
+ { path: '/careers', priority: 0.7, changeFrequency: 'monthly' },
+ { path: '/exchange', priority: 0.7, changeFrequency: 'weekly' },
+
+ // Legal
+ { path: '/privacy', priority: 0.5, changeFrequency: 'yearly' },
+ { path: '/terms', priority: 0.5, changeFrequency: 'yearly' },
+ ]
+
+ // --- Programmatic SEO pages (all locales with /{locale}/ prefix) ---
+ for (const locale of SUPPORTED_LOCALES) {
+ const isDefault = locale === 'en'
+ const basePriority = isDefault ? 1.0 : 0.9 // EN gets slightly higher priority
+
+ // Country hub pages
+ for (const country of Object.keys(COUNTRIES_SEO)) {
+ pages.push({ path: `/${locale}/${country}`, priority: 0.9 * basePriority, changeFrequency: 'weekly' })
+ }
+
+ // Corridor index + country pages
+ pages.push({ path: `/${locale}/send-money-to`, priority: 0.9 * basePriority, changeFrequency: 'weekly' })
+ for (const country of Object.keys(COUNTRIES_SEO)) {
+ pages.push({ path: `/${locale}/send-money-to/${country}`, priority: 0.8 * basePriority, changeFrequency: 'weekly' })
+ }
- const staticPages = ['', '/about', '/jobs']
+ // From-to corridor pages
+ for (const corridor of CORRIDORS) {
+ pages.push({
+ path: `/${locale}/send-money-from/${corridor.from}/to/${corridor.to}`,
+ priority: 0.85 * basePriority,
+ changeFrequency: 'weekly',
+ })
+ }
- // generate entries for static pages
- const staticEntries = staticPages.map((page) => ({
- url: `${baseUrl}${page}`,
+ // Receive money pages (unique sending countries from corridors)
+ const receiveSources = [...new Set(CORRIDORS.map((c) => c.from))]
+ for (const source of receiveSources) {
+ pages.push({
+ path: `/${locale}/receive-money-from/${source}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'weekly',
+ })
+ }
+
+ // Convert pages
+ for (const pair of CONVERT_PAIRS) {
+ pages.push({ path: `/${locale}/convert/${pair}`, priority: 0.7 * basePriority, changeFrequency: 'daily' })
+ }
+
+ // Comparison pages
+ for (const slug of Object.keys(COMPETITORS)) {
+ pages.push({ path: `/${locale}/compare/peanut-vs-${slug}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ }
+
+ // Deposit pages
+ for (const exchange of Object.keys(EXCHANGES)) {
+ pages.push({ path: `/${locale}/deposit/from-${exchange}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ }
+
+ // Pay-with pages
+ for (const method of PAYMENT_METHODS) {
+ pages.push({ path: `/${locale}/pay-with/${method}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ }
+
+ // Blog — only include posts that actually exist for this locale (avoid duplicate content)
+ const localePosts = getAllPosts(locale as Locale)
+ const enPosts = getAllPosts('en')
+ const postsToInclude = localePosts.length > 0 ? localePosts : isDefault ? enPosts : []
+
+ pages.push({ path: `/${locale}/blog`, priority: 0.8 * basePriority, changeFrequency: 'weekly' })
+ for (const post of postsToInclude) {
+ pages.push({ path: `/${locale}/blog/${post.slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly' })
+ }
+
+ // Team page
+ pages.push({ path: `/${locale}/team`, priority: 0.5 * basePriority, changeFrequency: 'monthly' })
+ }
+
+ return pages.map((page) => ({
+ url: `${BASE_URL}${page.path}`,
lastModified: new Date(),
- changeFrequency: 'weekly' as const,
- priority: 1.0,
+ changeFrequency: page.changeFrequency,
+ priority: page.priority,
}))
-
- return staticEntries
}
export default generateSitemap
diff --git a/src/components/0_Bruddle/BaseInput.tsx b/src/components/0_Bruddle/BaseInput.tsx
index 26ef2f515..d3e271766 100644
--- a/src/components/0_Bruddle/BaseInput.tsx
+++ b/src/components/0_Bruddle/BaseInput.tsx
@@ -29,4 +29,7 @@ const BaseInput = forwardRef(
}
)
+BaseInput.displayName = 'BaseInput'
+
+export { BaseInput }
export default BaseInput
diff --git a/src/components/0_Bruddle/BaseSelect.tsx b/src/components/0_Bruddle/BaseSelect.tsx
index acd8f2f84..13aa05fe7 100644
--- a/src/components/0_Bruddle/BaseSelect.tsx
+++ b/src/components/0_Bruddle/BaseSelect.tsx
@@ -100,4 +100,5 @@ const BaseSelect = forwardRef(
BaseSelect.displayName = 'BaseSelect'
+export { BaseSelect }
export default BaseSelect
diff --git a/src/components/0_Bruddle/Button.tsx b/src/components/0_Bruddle/Button.tsx
index a6604a5a5..a003522d5 100644
--- a/src/components/0_Bruddle/Button.tsx
+++ b/src/components/0_Bruddle/Button.tsx
@@ -1,9 +1,10 @@
'use client'
-import React, { forwardRef, useEffect, useRef, useState, useCallback } from 'react'
+import React, { forwardRef, useCallback, useEffect, useRef } from 'react'
import { twMerge } from 'tailwind-merge'
import { Icon, type IconName } from '../Global/Icons/Icon'
import Loading from '../Global/Loading'
import { useHaptic } from 'use-haptic'
+import { useLongPress } from '@/hooks/useLongPress'
export type ButtonVariant =
| 'purple'
@@ -11,15 +12,21 @@ export type ButtonVariant =
| 'stroke'
| 'transparent-light'
| 'transparent-dark'
- | 'green'
- | 'yellow'
| 'transparent'
| 'primary-soft'
-export type ButtonSize = 'small' | 'medium' | 'large' | 'xl' | 'xl-fixed'
+export type ButtonSize = 'small' | 'medium' | 'large'
type ButtonShape = 'default' | 'square'
type ShadowSize = '3' | '4' | '6' | '8'
type ShadowType = 'primary' | 'secondary'
+/**
+ * Primary button component.
+ *
+ * @prop variant - Visual style. 'purple' for primary CTAs, 'stroke' for secondary.
+ * @prop size - Height override. Omit for default h-13 (tallest). 'large' is h-10 (shorter!).
+ * @prop shadowSize - Drop shadow depth. '4' is standard (160+ usages).
+ * @prop longPress - Hold-to-confirm behavior with progress bar animation.
+ */
export interface ButtonProps extends React.ButtonHTMLAttributes {
variant?: ButtonVariant
size?: ButtonSize
@@ -47,8 +54,6 @@ const buttonVariants: Record = {
stroke: 'btn-stroke',
'transparent-light': 'btn-transparent-light',
'transparent-dark': 'btn-transparent-dark',
- green: 'bg-green-1',
- yellow: 'bg-secondary-1',
'primary-soft': 'bg-white',
transparent:
'bg-transparent border-none hover:bg-transparent !active:bg-transparent focus:bg-transparent disabled:bg-transparent disabled:hover:bg-transparent',
@@ -57,9 +62,8 @@ const buttonVariants: Record = {
const buttonSizes: Record = {
small: 'btn-small',
medium: 'btn-medium',
+ /** @deprecated large (h-10) is shorter than default (h-13). Avoid for primary CTAs. */
large: 'btn-large',
- xl: 'btn-xl',
- 'xl-fixed': 'btn-xl-fixed',
}
const buttonShadows: Record> = {
@@ -104,12 +108,7 @@ export const Button = forwardRef(
const buttonRef = (ref as React.RefObject) || localRef
const { triggerHaptic } = useHaptic()
-
- // Long press state
- const [isLongPressed, setIsLongPressed] = useState(false)
- const [pressTimer, setPressTimer] = useState(null)
- const [pressProgress, setPressProgress] = useState(0)
- const [progressInterval, setProgressInterval] = useState(null)
+ const { isLongPressed, pressProgress, handlers: longPressHandlers } = useLongPress(longPress)
useEffect(() => {
if (!buttonRef.current) return
@@ -117,83 +116,9 @@ export const Button = forwardRef(
buttonRef.current.classList.add('notranslate')
}, [])
- // Long press handlers
- const handlePressStart = useCallback(() => {
- if (!longPress) return
-
- longPress.onLongPressStart?.()
- setPressProgress(0)
-
- const duration = longPress.duration || 2000
- const updateInterval = 16 // ~60fps
- const increment = (100 / duration) * updateInterval
-
- // Progress animation
- const progressTimer = setInterval(() => {
- setPressProgress((prev) => {
- const newProgress = prev + increment
- if (newProgress >= 100) {
- clearInterval(progressTimer)
- return 100
- }
- return newProgress
- })
- }, updateInterval)
-
- setProgressInterval(progressTimer)
-
- // Long press completion timer
- const timer = setTimeout(() => {
- setIsLongPressed(true)
- longPress.onLongPress?.()
- clearInterval(progressTimer)
- }, duration)
-
- setPressTimer(timer)
- }, [longPress])
-
- const handlePressEnd = useCallback(() => {
- if (!longPress) return
-
- if (pressTimer) {
- clearTimeout(pressTimer)
- setPressTimer(null)
- }
-
- if (progressInterval) {
- clearInterval(progressInterval)
- setProgressInterval(null)
- }
-
- if (isLongPressed) {
- longPress.onLongPressEnd?.()
- setIsLongPressed(false)
- }
-
- setPressProgress(0)
- }, [longPress, pressTimer, progressInterval, isLongPressed])
-
- const handlePressCancel = useCallback(() => {
- if (!longPress) return
-
- if (pressTimer) {
- clearTimeout(pressTimer)
- setPressTimer(null)
- }
-
- if (progressInterval) {
- clearInterval(progressInterval)
- setProgressInterval(null)
- }
-
- setIsLongPressed(false)
- setPressProgress(0)
- }, [longPress, pressTimer, progressInterval])
-
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (longPress && !isLongPressed) {
- // If long press is enabled but not completed, don't trigger onClick
return
}
@@ -203,21 +128,9 @@ export const Button = forwardRef(
onClick?.(e)
},
- [longPress, isLongPressed, onClick]
+ [longPress, isLongPressed, onClick, disableHaptics, triggerHaptic]
)
- // Cleanup timers on unmount
- useEffect(() => {
- return () => {
- if (pressTimer) {
- clearTimeout(pressTimer)
- }
- if (progressInterval) {
- clearInterval(progressInterval)
- }
- }
- }, [pressTimer, progressInterval])
-
const buttonClasses = twMerge(
`btn w-full flex items-center gap-2 transition-all duration-100 active:translate-x-[3px] active:translate-y-[${shadowSize}px] active:shadow-none notranslate`,
buttonVariants[variant],
@@ -255,12 +168,12 @@ export const Button = forwardRef(
ref={buttonRef}
translate="no"
onClick={handleClick}
- onMouseDown={longPress ? handlePressStart : undefined}
- onMouseUp={longPress ? handlePressEnd : undefined}
- onMouseLeave={longPress ? handlePressCancel : undefined}
- onTouchStart={longPress ? handlePressStart : undefined}
- onTouchEnd={longPress ? handlePressEnd : undefined}
- onTouchCancel={longPress ? handlePressCancel : undefined}
+ onMouseDown={longPress ? longPressHandlers.onMouseDown : undefined}
+ onMouseUp={longPress ? longPressHandlers.onMouseUp : undefined}
+ onMouseLeave={longPress ? longPressHandlers.onMouseLeave : undefined}
+ onTouchStart={longPress ? longPressHandlers.onTouchStart : undefined}
+ onTouchEnd={longPress ? longPressHandlers.onTouchEnd : undefined}
+ onTouchCancel={longPress ? longPressHandlers.onTouchCancel : undefined}
{...props}
>
{/* Progress bar for long press */}
diff --git a/src/components/0_Bruddle/Card.tsx b/src/components/0_Bruddle/Card.tsx
index d42052e6b..c36bbe7bb 100644
--- a/src/components/0_Bruddle/Card.tsx
+++ b/src/components/0_Bruddle/Card.tsx
@@ -1,4 +1,3 @@
-import classNames from 'classnames'
import { twMerge } from 'tailwind-merge'
type ShadowSize = '4' | '6' | '8'
@@ -59,7 +58,7 @@ const Description = ({ children, className, ...props }: React.HTMLAttributes) => (
-
+
{children}
)
diff --git a/src/components/0_Bruddle/Checkbox.tsx b/src/components/0_Bruddle/Checkbox.tsx
index 0f991cf3a..f38dda4f0 100644
--- a/src/components/0_Bruddle/Checkbox.tsx
+++ b/src/components/0_Bruddle/Checkbox.tsx
@@ -26,4 +26,7 @@ const Checkbox = ({ className, label, value, onChange }: CheckboxProps) => (
)
+Checkbox.displayName = 'Checkbox'
+
+export { Checkbox }
export default Checkbox
diff --git a/src/components/0_Bruddle/CloudsBackground.tsx b/src/components/0_Bruddle/CloudsBackground.tsx
index 1128351a9..58357a927 100644
--- a/src/components/0_Bruddle/CloudsBackground.tsx
+++ b/src/components/0_Bruddle/CloudsBackground.tsx
@@ -227,4 +227,7 @@ const CloudsBackground: React.FC
= ({ minimal = false })
)
}
+CloudsBackground.displayName = 'CloudsBackground'
+
+export { CloudsBackground }
export default CloudsBackground
diff --git a/src/components/0_Bruddle/Divider.tsx b/src/components/0_Bruddle/Divider.tsx
index dacc77f8b..508e8f108 100644
--- a/src/components/0_Bruddle/Divider.tsx
+++ b/src/components/0_Bruddle/Divider.tsx
@@ -16,4 +16,7 @@ const Divider = ({ text, className, dividerClassname, textClassname, ...props }:
)
}
+Divider.displayName = 'Divider'
+
+export { Divider }
export default Divider
diff --git a/src/components/0_Bruddle/PageContainer.tsx b/src/components/0_Bruddle/PageContainer.tsx
index d35383e6a..60657d5b5 100644
--- a/src/components/0_Bruddle/PageContainer.tsx
+++ b/src/components/0_Bruddle/PageContainer.tsx
@@ -19,4 +19,7 @@ const PageContainer = (props: PageContainerProps) => {
)
}
+PageContainer.displayName = 'PageContainer'
+
+export { PageContainer }
export default PageContainer
diff --git a/src/components/0_Bruddle/Title.tsx b/src/components/0_Bruddle/Title.tsx
index fd6a45067..9f5df1c13 100644
--- a/src/components/0_Bruddle/Title.tsx
+++ b/src/components/0_Bruddle/Title.tsx
@@ -18,4 +18,7 @@ const Title = ({
)
}
+Title.displayName = 'Title'
+
+export { Title }
export default Title
diff --git a/src/components/Global/AnimateOnView.tsx b/src/components/Global/AnimateOnView.tsx
new file mode 100644
index 000000000..7d4fa5e37
--- /dev/null
+++ b/src/components/Global/AnimateOnView.tsx
@@ -0,0 +1,59 @@
+'use client'
+
+import { useRef, useEffect, type CSSProperties } from 'react'
+
+type AnimateOnViewProps = {
+ children: React.ReactNode
+ className?: string
+ delay?: string
+ y?: string
+ x?: string
+ rotate?: string
+ style?: CSSProperties
+} & React.HTMLAttributes
+
+export function AnimateOnView({
+ children,
+ className,
+ delay,
+ y,
+ x,
+ rotate,
+ style,
+ ...rest
+}: AnimateOnViewProps) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ el.classList.add('in-view')
+ observer.disconnect()
+ }
+ },
+ { threshold: 0.1 }
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/Global/FooterVisibilityObserver.tsx b/src/components/Global/FooterVisibilityObserver.tsx
new file mode 100644
index 000000000..bda7d96ad
--- /dev/null
+++ b/src/components/Global/FooterVisibilityObserver.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import { useFooterVisibility } from '@/context/footerVisibility'
+import { useEffect, useRef } from 'react'
+
+export function FooterVisibilityObserver() {
+ const footerRef = useRef(null)
+ const { setIsFooterVisible } = useFooterVisibility()
+
+ useEffect(() => {
+ const el = footerRef.current
+ if (!el) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ setIsFooterVisible(entry.isIntersecting)
+ })
+ },
+ { root: null, rootMargin: '0px', threshold: 0.1 }
+ )
+
+ observer.observe(el)
+
+ return () => {
+ observer.unobserve(el)
+ }
+ }, [setIsFooterVisible])
+
+ return
+}
diff --git a/src/components/LandingPage/CloudsCss.tsx b/src/components/LandingPage/CloudsCss.tsx
new file mode 100644
index 000000000..ab099317f
--- /dev/null
+++ b/src/components/LandingPage/CloudsCss.tsx
@@ -0,0 +1,41 @@
+import borderCloud from '@/assets/illustrations/border-cloud.svg'
+import { type CSSProperties } from 'react'
+
+type CloudConfig = {
+ top: string
+ width: number
+ speed: string
+ direction: 'ltr' | 'rtl'
+ delay?: string
+}
+
+const defaultClouds: CloudConfig[] = [
+ { top: '10%', width: 180, speed: '38s', direction: 'ltr' },
+ { top: '45%', width: 220, speed: '44s', direction: 'ltr' },
+ { top: '80%', width: 210, speed: '42s', direction: 'ltr' },
+ { top: '25%', width: 200, speed: '40s', direction: 'rtl' },
+ { top: '65%', width: 190, speed: '36s', direction: 'rtl' },
+]
+
+export function CloudsCss({ clouds = defaultClouds }: { clouds?: CloudConfig[] }) {
+ return (
+
+ {clouds.map((cloud, i) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
new file mode 100644
index 000000000..68520f09a
--- /dev/null
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useFooterVisibility } from '@/context/footerVisibility'
+import { useEffect, useState, useRef, type ReactNode } from 'react'
+import {
+ DropLink,
+ FAQs,
+ Hero,
+ Marquee,
+ NoFees,
+ CardPioneers,
+} from '@/components/LandingPage'
+import TweetCarousel from '@/components/LandingPage/TweetCarousel'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
+
+type CTAButton = {
+ label: string
+ href: string
+ isExternal?: boolean
+ subtext?: string
+}
+
+type FAQQuestion = {
+ id: string
+ question: string
+ answer: string
+}
+
+type LandingPageClientProps = {
+ heroConfig: {
+ primaryCta: CTAButton
+ }
+ faqData: {
+ heading: string
+ questions: FAQQuestion[]
+ marquee: { visible: boolean; message: string }
+ }
+ marqueeMessages: string[]
+ // Server-rendered slots
+ mantecaSlot: ReactNode
+ regulatedRailsSlot: ReactNode
+ yourMoneySlot: ReactNode
+ securitySlot: ReactNode
+ sendInSecondsSlot: ReactNode
+ footerSlot: ReactNode
+}
+
+export function LandingPageClient({
+ heroConfig,
+ faqData,
+ marqueeMessages,
+ mantecaSlot,
+ regulatedRailsSlot,
+ yourMoneySlot,
+ securitySlot,
+ sendInSecondsSlot,
+ footerSlot,
+}: LandingPageClientProps) {
+ const { isFooterVisible } = useFooterVisibility()
+ 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)
+
+ useEffect(() => {
+ if (isFooterVisible) {
+ setButtonVisible(false)
+ } else {
+ setButtonVisible(true)
+ }
+ }, [isFooterVisible])
+
+ 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 &&
+ !animationComplete &&
+ !shrinkingPhase &&
+ !hasGrown
+
+ if (shouldFreeze && !isScrollFrozen) {
+ setIsScrollFrozen(true)
+ frozenScrollY.current = currentScrollY
+ virtualScrollY.current = 0
+ document.body.style.overflow = 'hidden'
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (isScrollFrozen && !animationComplete) {
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (animationComplete && !shrinkingPhase && currentScrollY > frozenScrollY.current + 50) {
+ setShrinkingPhase(true)
+ } else if (shrinkingPhase) {
+ 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 (animationComplete && currentScrollY < frozenScrollY.current - 100) {
+ setAnimationComplete(false)
+ setShrinkingPhase(false)
+ setButtonScale(1)
+ setHasGrown(false)
+ }
+
+ }
+ }
+
+ const handleWheel = (event: WheelEvent) => {
+ if (isScrollFrozen && !animationComplete) {
+ event.preventDefault()
+
+ if (event.deltaY > 0) {
+ virtualScrollY.current += event.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)
+ }
+ }
+ }
+ }
+
+ window.addEventListener('scroll', handleScroll)
+ window.addEventListener('wheel', handleWheel, { passive: false })
+ handleScroll()
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll)
+ window.removeEventListener('wheel', handleWheel)
+ document.body.style.overflow = ''
+ }
+ }, [isScrollFrozen, animationComplete, shrinkingPhase, hasGrown])
+
+ const marqueeProps = { visible: true, message: marqueeMessages }
+
+ return (
+ <>
+
+
+ {mantecaSlot}
+
+ {!underMaintenanceConfig.disableCardPioneers && (
+ <>
+
+
+ >
+ )}
+
+
+ {regulatedRailsSlot}
+
+ {yourMoneySlot}
+
+
+
+ {securitySlot}
+
+
+ {sendInSecondsSlot}
+
+
+
+
+
+
+ {footerSlot}
+ >
+ )
+}
diff --git a/src/components/LandingPage/LandingPageShell.tsx b/src/components/LandingPage/LandingPageShell.tsx
new file mode 100644
index 000000000..a4a2ae922
--- /dev/null
+++ b/src/components/LandingPage/LandingPageShell.tsx
@@ -0,0 +1,10 @@
+import { FooterVisibilityObserver } from '@/components/Global/FooterVisibilityObserver'
+
+export function LandingPageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index 22ad4d9ee..8b600b256 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -1,58 +1,41 @@
-import { motion } from 'framer-motion'
-import { useEffect, useState } from 'react'
import mantecaIphone from '@/assets/iphone-ss/manteca_ss.png'
import Image from 'next/image'
import { MEPA_ARGENTINA_LOGO, PIX_BRZ_LOGO, Star } from '@/assets'
-import { CloudImages } from './imageAssets'
+import { CloudsCss } from './CloudsCss'
+import { AnimateOnView } from '@/components/Global/AnimateOnView'
-const Manteca = () => {
- const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
-
- const starConfigs = [
- { className: 'absolute left-12 top-10', delay: 0.2 },
- { className: 'absolute left-56 top-1/2', delay: 0.2 },
- { className: 'absolute bottom-20 left-20', delay: 0.2 },
- { className: 'absolute -top-16 right-20 md:top-58', delay: 0.6 },
- { className: 'absolute bottom-20 right-44', delay: 0.6 },
- ]
-
- const isMobile = screenWidth < 768
-
- useEffect(() => {
- const handleResize = () => {
- setScreenWidth(window.innerWidth)
- }
-
- handleResize()
- window.addEventListener('resize', handleResize)
- return () => window.removeEventListener('resize', handleResize)
- }, [])
+const starConfigs = [
+ { className: 'absolute left-12 top-10', delay: '0.2s', rotate: '22deg' },
+ { className: 'absolute left-56 top-1/2', delay: '0.2s', rotate: '22deg' },
+ { className: 'absolute bottom-20 left-20', delay: '0.2s', rotate: '22deg' },
+ { className: 'absolute -top-16 right-20 md:top-58', delay: '0.6s', rotate: '22deg' },
+ { className: 'absolute bottom-20 right-44', delay: '0.6s', rotate: '22deg' },
+]
+const Manteca = () => {
return (
- {!isMobile && }
+
+
+
- {!isMobile && (
- <>
- {starConfigs.map((config, index) => (
-
- ))}
- >
- )}
+
+ {starConfigs.map((config, index) => (
+
+
+
+ ))}
+
@@ -72,24 +55,22 @@ const Manteca = () => {
- {isMobile && (
-
-
+ {/* Mobile layout */}
+
- {!isMobile && (
-
-
-
-
-
- )}
+ {/* Desktop layout */}
+
+
+
+
+
)
}
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index 8ccea37f5..e1e391d68 100644
--- a/src/components/LandingPage/RegulatedRails.tsx
+++ b/src/components/LandingPage/RegulatedRails.tsx
@@ -1,4 +1,3 @@
-'use client'
import Image from 'next/image'
import { MarqueeWrapper } from '../Global/MarqueeWrapper'
import {
@@ -11,11 +10,10 @@ import {
MERCADO_PAGO_ICON,
PIX_ICON,
WISE_ICON,
+ Star,
} from '@/assets'
-import { useEffect, useState } from 'react'
-import { motion } from 'framer-motion'
-import borderCloud from '@/assets/illustrations/border-cloud.svg'
-import { Star } from '@/assets'
+import { CloudsCss } from './CloudsCss'
+import { AnimateOnView } from '@/components/Global/AnimateOnView'
const bgColor = '#F9F4F0'
@@ -31,78 +29,28 @@ const logos = [
{ logo: WISE_ICON, alt: 'Wise' },
]
-export function RegulatedRails() {
- const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
-
- useEffect(() => {
- const handleResize = () => {
- setScreenWidth(window.innerWidth)
- }
-
- handleResize()
- window.addEventListener('resize', handleResize)
- return () => window.removeEventListener('resize', handleResize)
- }, [])
-
- const createCloudAnimation = (side: 'left' | 'right', top: string, width: number, speed: number) => {
- const vpWidth = screenWidth || 1080
- const totalDistance = vpWidth + width
+const regulatedRailsClouds = [
+ { top: '20%', width: 200, speed: '38s', direction: 'ltr' as const },
+ { top: '60%', width: 220, speed: '34s', direction: 'rtl' as const },
+]
- return {
- initial: { x: side === 'left' ? -width : vpWidth },
- animate: { x: side === 'left' ? vpWidth : -width },
- transition: {
- ease: 'linear',
- duration: totalDistance / speed,
- repeat: Infinity,
- },
- }
- }
+export function RegulatedRails() {
return (
-
- {/* Animated clouds */}
-
-
-
+
+
- {/* Animated stars */}
-
-
+
+
+
+
+
+
+
REGULATED RAILS, SELF-CUSTODY CONTROL
diff --git a/src/components/LandingPage/SendInSecondsCTA.tsx b/src/components/LandingPage/SendInSecondsCTA.tsx
new file mode 100644
index 000000000..6004b845b
--- /dev/null
+++ b/src/components/LandingPage/SendInSecondsCTA.tsx
@@ -0,0 +1,27 @@
+'use client'
+
+import { motion } from 'framer-motion'
+import { Button } from '@/components/0_Bruddle/Button'
+
+export function SendInSecondsCTA() {
+ return (
+
+ )
+}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 0085fb2cb..c54f977d0 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -1,10 +1,9 @@
-import { ButterySmoothGlobalMoney, PeanutGuyGIF, Sparkle } from '@/assets'
+'use client'
+
+import { ButterySmoothGlobalMoney, PeanutGuyGIF, Star } from '@/assets'
import { motion } from 'framer-motion'
-import { useEffect, useState } from 'react'
-import { twMerge } from 'tailwind-merge'
-import { CloudImages, HeroImages } from './imageAssets'
-import Image from 'next/image'
import { Button } from '@/components/0_Bruddle/Button'
+import { CloudsCss } from './CloudsCss'
type CTAButton = {
label: string
@@ -20,7 +19,6 @@ type HeroProps = {
buttonScale?: number
}
-// Helper functions moved outside component for better performance
const getInitialAnimation = (variant: 'primary' | 'secondary') => ({
opacity: 0,
translateY: 4,
@@ -31,7 +29,7 @@ const getInitialAnimation = (variant: 'primary' | 'secondary') => ({
const getAnimateAnimation = (variant: 'primary' | 'secondary', buttonVisible?: boolean, buttonScale?: number) => ({
opacity: buttonVisible ? 1 : 0,
translateY: buttonVisible ? 0 : 20,
- translateX: buttonVisible ? (variant === 'primary' ? 0 : 0) : 20,
+ translateX: buttonVisible ? 0 : 20,
rotate: buttonVisible ? 0 : 1,
scale: buttonScale || 1,
pointerEvents: buttonVisible ? ('auto' as const) : ('none' as const),
@@ -48,34 +46,7 @@ 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)]'}`
-const getButtonClasses = (variant: 'primary' | 'secondary') =>
- `${variant === 'primary' ? 'btn bg-white fill-n-1 text-n-1 hover:bg-white/90' : 'btn-yellow'} px-7 md:px-9 py-3 md:py-8 text-base md:text-xl btn-shadow-primary-4`
-
-const renderSparkle = (variant: 'primary' | 'secondary') =>
- variant === 'primary' && (
-
- )
-
export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1 }: HeroProps) {
- const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
-
- useEffect(() => {
- const handleResize = () => {
- setScreenWidth(window.innerWidth)
- }
-
- handleResize()
- window.addEventListener('resize', handleResize)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- }
- }, [])
-
const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => {
return (
- {/* {renderSparkle(variant)} */}
-
-
+
-
+
+
-
+
TAP. SEND. ANYWHERE
@@ -141,7 +125,22 @@ export function Hero({ primaryCta, secondaryCta, buttonVisible, buttonScale = 1
{primaryCta && renderCTAButton(primaryCta, 'primary')}
{secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
-
+
+
)
diff --git a/src/components/LandingPage/imageAssets.tsx b/src/components/LandingPage/imageAssets.tsx
deleted file mode 100644
index 36f244ba8..000000000
--- a/src/components/LandingPage/imageAssets.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-'use client'
-import { Star } from '@/assets'
-import { motion } from 'framer-motion'
-import borderCloud from '@/assets/illustrations/border-cloud.svg'
-
-const CloudAnimation = ({
- top,
- imageSrc,
- styleMod,
- screenWidth,
- width = 200,
- speed = 45,
- delay = 0,
- direction = 'left-to-right',
-}: {
- top: string
- imageSrc: string
- styleMod?: string
- screenWidth?: number
- width?: number
- speed?: number
- delay?: number
- direction?: 'left-to-right' | 'right-to-left'
-}) => {
- const vpWidth = screenWidth || 1080
- const totalDistance = vpWidth + width
-
- return (
-
- )
-}
-
-export const CloudImages = ({ screenWidth }: { screenWidth: number }) => {
- return (
-
- {/* 3 clouds moving left-to-right */}
-
-
-
-
- {/* 2 clouds moving right-to-left */}
-
-
-
- )
-}
-
-export const HeroImages = () => {
- return (
- <>
-
-
- >
- )
-}
diff --git a/src/components/LandingPage/landingPageData.ts b/src/components/LandingPage/landingPageData.ts
new file mode 100644
index 000000000..87257fd50
--- /dev/null
+++ b/src/components/LandingPage/landingPageData.ts
@@ -0,0 +1,54 @@
+export const heroConfig = {
+ primaryCta: {
+ label: 'SIGN UP',
+ href: '/setup',
+ subtext: 'currently in waitlist',
+ },
+}
+
+export const marqueeMessages = ['No fees', 'Instant', '24/7', 'USD', 'EUR', 'CRYPTO', 'GLOBAL', 'SELF-CUSTODIAL']
+
+export const faqData = {
+ heading: 'Faqs',
+ questions: [
+ {
+ id: '0',
+ question: 'Why Peanut?',
+ answer: `It's time to take control of your money. No banks, no borders. Just buttery smooth global money.`,
+ },
+ {
+ id: '1',
+ question: 'What is Peanut?',
+ answer: `Peanut is the easiest way to send digital dollars to anyone anywhere. Peanut's tech is powered by cutting-edge cryptography and the security of biometric user authentication as well as a network of modern and fully licensed banking providers.`,
+ },
+ {
+ id: '2',
+ question: 'Do I have to KYC?',
+ answer: `No! You can use core functionalities (like sending and receiving money) without KYC. Bank connections, however, trigger a one\u2011time check handled by Persona, a SOC2 Type 2 certified and GDPR compliant ISO 27001\u2013certified provider used by brands like Square and Robinhood. Your documents remain locked away with Persona, not Peanut, and Peanut only gets a yes/no response, keeping your privacy intact.`,
+ },
+ {
+ id: '3',
+ question: 'Could a thief drain my wallet if they stole my phone?',
+ answer: `Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It\u2019s secured by NIST\u2011recommended P\u2011256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10\u00B9\u2070\u00B9\u2070 combinations of a 30\u2011character password made of emoji.\nThis means that neither Peanut or even regulators could freeze, us or you to hand over your account, because we can\u2019t hand over what we don\u2019t have. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.`,
+ },
+ {
+ id: '4',
+ question: `What happens to my funds if Peanut\u2019s servers were breached?`,
+ answer: "Nothing. Your funds sit in your self\u2011custodied smart account (not on Peanut servers). Every transfer still needs a signature from your biometric passkey, so a server\u2011side attacker has no way to move a cent without the private key sealed in your device's Secure Enclave. Even if Peanut were offline, you could point any ERC\u20114337\u2011compatible wallet at your smart account and recover access independently.",
+ },
+ {
+ id: '5',
+ question: 'How does Peanut make money?',
+ answer: 'We plan to charge merchants for accepting Peanut as a payment method, whilst still being much cheaper than VISA and Mastercard. For users, we only charge minimal amounts!',
+ },
+ {
+ id: '6',
+ question: 'My question is not here',
+ answer: 'Check out our full FAQ page at https://peanutprotocol.notion.site/FAQ-2a4838117579805dad62ff47c9d2eb7a or visit our support page at https://peanut.me/support for more help.',
+ },
+ ],
+ marquee: {
+ visible: false,
+ message: 'Peanut',
+ },
+}
diff --git a/src/components/LandingPage/sendInSeconds.tsx b/src/components/LandingPage/sendInSeconds.tsx
index 84707ff6b..2585a74d5 100644
--- a/src/components/LandingPage/sendInSeconds.tsx
+++ b/src/components/LandingPage/sendInSeconds.tsx
@@ -1,153 +1,42 @@
-import { useEffect, useState } from 'react'
-import { motion } from 'framer-motion'
import Image from 'next/image'
-import borderCloud from '@/assets/illustrations/border-cloud.svg'
import exclamations from '@/assets/illustrations/exclamations.svg'
import payZeroFees from '@/assets/illustrations/pay-zero-fees.svg'
import mobileSendInSeconds from '@/assets/illustrations/mobile-send-in-seconds.svg'
-import { Star, Sparkle } from '@/assets'
-import { Button } from '@/components/0_Bruddle/Button'
+import { Star } from '@/assets'
+import { CloudsCss } from './CloudsCss'
+import { AnimateOnView } from '@/components/Global/AnimateOnView'
+import { SendInSecondsCTA } from './SendInSecondsCTA'
+
+const sendInSecondsClouds = [
+ { top: '15%', width: 320, speed: '40s', direction: 'ltr' as const },
+ { top: '40%', width: 200, speed: '34s', direction: 'rtl' as const },
+ { top: '70%', width: 180, speed: '30s', direction: 'ltr' as const },
+ { top: '80%', width: 320, speed: '46s', direction: 'rtl' as const },
+]
+
+const starConfigs = [
+ { className: 'absolute right-10 top-10 md:right-1/4 md:top-20', width: 50, height: 50, delay: '0.2s', x: '5px', rotate: '45deg' },
+ { className: 'absolute bottom-16 left-1/3', width: 40, height: 40, delay: '0.4s', x: '-5px', rotate: '-10deg' },
+ { className: 'absolute bottom-20 left-[2rem] md:bottom-72 md:right-[14rem]', width: 50, height: 50, delay: '0.6s', x: '5px', rotate: '-22deg' },
+ { className: 'absolute left-[20rem] top-72', width: 60, height: 60, delay: '0.8s', x: '-5px', rotate: '12deg' },
+]
export function SendInSeconds() {
- const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
-
- useEffect(() => {
- const handleResize = () => {
- setScreenWidth(window.innerWidth)
- }
-
- handleResize()
- window.addEventListener('resize', handleResize)
- return () => window.removeEventListener('resize', handleResize)
- }, [])
-
- const createCloudAnimation = (side: 'left' | 'right', width: number, speed: number) => {
- const vpWidth = screenWidth || 1080
- const totalDistance = vpWidth + width
-
- return {
- initial: { x: side === 'left' ? -width : vpWidth },
- animate: { x: side === 'left' ? vpWidth : -width },
- transition: {
- ease: 'linear',
- duration: totalDistance / speed,
- repeat: Infinity,
- },
- }
- }
-
- // Button helper functions adapted from hero.tsx
- const getInitialAnimation = () => ({
- opacity: 0,
- translateY: 4,
- translateX: 0,
- rotate: 0.75,
- })
-
- const getAnimateAnimation = (buttonVisible: boolean, buttonScale: number = 1) => ({
- opacity: buttonVisible ? 1 : 0,
- translateY: buttonVisible ? 0 : 20,
- translateX: buttonVisible ? 0 : 20,
- rotate: buttonVisible ? 0 : 1,
- scale: buttonScale,
- pointerEvents: buttonVisible ? ('auto' as const) : ('none' as const),
- })
-
- const getHoverAnimation = () => ({
- translateY: 6,
- translateX: 0,
- rotate: 0.75,
- })
-
- const transitionConfig = { type: 'spring', damping: 15 } as const
-
- const getButtonClasses = () =>
- `btn bg-white fill-n-1 text-n-1 hover:bg-white/90 px-9 md:px-11 py-4 md:py-10 text-lg md:text-2xl btn-shadow-primary-4`
-
- const renderSparkle = () => (
-
- )
-
return (
- {/* Decorative clouds, stars, and exclamations */}
-
- {/* Animated clouds */}
-
-
-
-
-
-
- {/* Animated stars and exclamations */}
-
-
-
-
+
+
+ {starConfigs.map((config, i) => (
+
+
+
+ ))}
{/* Exclamations */}
- {/* Fixed CTA Button */}
-
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
index f512c4155..66d06d1bf 100644
--- a/src/components/Marketing/DestinationGrid.tsx
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link'
import { Card } from '@/components/0_Bruddle/Card'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
-import countryCurrencyMappings, { getFlagUrl } from '@/constants/countryCurrencyMapping'
+import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
import { localizedPath } from '@/i18n/config'
import type { Locale } from '@/i18n/types'
@@ -23,9 +23,7 @@ export function DestinationGrid({ countries, title = 'Send money to', locale = '
const seo = COUNTRIES_SEO[slug]
if (!seo) return null
- const mapping = countryCurrencyMappings.find(
- (m) => m.path === slug || m.country.toLowerCase().replace(/ /g, '-') === slug
- )
+ const mapping = findMappingBySlug(slug)
const countryName = getCountryName(slug, locale)
const flagCode = mapping?.flagCode
diff --git a/src/components/Marketing/JsonLd.tsx b/src/components/Marketing/JsonLd.tsx
new file mode 100644
index 000000000..40e4f2ba0
--- /dev/null
+++ b/src/components/Marketing/JsonLd.tsx
@@ -0,0 +1,12 @@
+/**
+ * Server component that renders JSON-LD structured data.
+ * Accepts any schema.org-compatible object.
+ */
+export function JsonLd({ data }: { data: Record
}) {
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/MarketingNav.tsx b/src/components/Marketing/MarketingNav.tsx
new file mode 100644
index 000000000..1291604b3
--- /dev/null
+++ b/src/components/Marketing/MarketingNav.tsx
@@ -0,0 +1,19 @@
+import Image from 'next/image'
+import Link from 'next/link'
+import { PEANUT_LOGO_BLACK } from '@/assets'
+
+export function MarketingNav() {
+ return (
+
+
+
+
+
+ Get Started
+
+
+ )
+}
diff --git a/src/components/Marketing/MarketingShell.tsx b/src/components/Marketing/MarketingShell.tsx
new file mode 100644
index 000000000..7ee14a00b
--- /dev/null
+++ b/src/components/Marketing/MarketingShell.tsx
@@ -0,0 +1,8 @@
+interface MarketingShellProps {
+ children: React.ReactNode
+ className?: string
+}
+
+export function MarketingShell({ children, className }: MarketingShellProps) {
+ return {children}
+}
diff --git a/src/components/Marketing/index.ts b/src/components/Marketing/index.ts
new file mode 100644
index 000000000..7d10cac3a
--- /dev/null
+++ b/src/components/Marketing/index.ts
@@ -0,0 +1,10 @@
+export { JsonLd } from './JsonLd'
+export { MarketingNav } from './MarketingNav'
+export { MarketingHero } from './MarketingHero'
+export { MarketingShell } from './MarketingShell'
+export { Section } from './Section'
+export { Steps } from './Steps'
+export { ComparisonTable } from './ComparisonTable'
+export { FAQSection } from './FAQSection'
+export { DestinationGrid } from './DestinationGrid'
+export { BlogCard } from './BlogCard'
diff --git a/src/components/Marketing/pages/CorridorPageContent.tsx b/src/components/Marketing/pages/CorridorPageContent.tsx
new file mode 100644
index 000000000..ab33f1773
--- /dev/null
+++ b/src/components/Marketing/pages/CorridorPageContent.tsx
@@ -0,0 +1,173 @@
+import { notFound } from 'next/navigation'
+import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
+import { COUNTRIES_SEO, getLocalizedSEO, getCountryName } from '@/data/seo'
+import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+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 { DestinationGrid } from '@/components/Marketing/DestinationGrid'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { RelatedPages } from '@/components/Marketing/RelatedPages'
+
+interface CorridorPageContentProps {
+ country: string
+ locale: Locale
+}
+
+export function CorridorPageContent({ country, locale }: CorridorPageContentProps) {
+ const seo = getLocalizedSEO(country, locale)
+ if (!seo) notFound()
+
+ const i18n = getTranslations(locale)
+ const countryName = getCountryName(country, locale)
+
+ const mapping = findMappingBySlug(country)
+ const currencyCode = mapping?.currencyCode ?? ''
+ const flagCode = mapping?.flagCode
+
+ const howToSteps = [
+ {
+ title: t(i18n.stepCreateAccount),
+ description: t(i18n.stepCreateAccountDesc),
+ },
+ {
+ title: t(i18n.stepDepositFunds),
+ description: t(i18n.stepDepositFundsDesc, { method: seo.instantPayment ?? '' }),
+ },
+ {
+ title: t(i18n.stepSendTo, { country: countryName }),
+ description: t(i18n.stepSendToDesc, {
+ currency: currencyCode || 'local currency',
+ method: seo.instantPayment ?? 'bank transfer',
+ }),
+ },
+ ]
+
+ const baseUrl = 'https://peanut.me'
+ const canonicalPath = localizedPath('send-money-to', locale, country)
+
+ const howToSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name: t(i18n.sendMoneyTo, { country: countryName }),
+ description: t(i18n.sendMoneyToSubtitle, { country: countryName, currency: currencyCode }),
+ inLanguage: locale,
+ step: howToSteps.map((step, i) => ({
+ '@type': 'HowToStep',
+ position: i + 1,
+ name: step.title,
+ text: step.description,
+ })),
+ }
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: i18n.sendMoney,
+ item: `${baseUrl}${localizedPath('send-money-to', locale)}`,
+ },
+ { '@type': 'ListItem', position: 3, name: countryName, item: `${baseUrl}${canonicalPath}` },
+ ],
+ }
+
+ const otherCountries = Object.keys(COUNTRIES_SEO).filter((c) => c !== country)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {flagCode && (
+
+ )}
+
{seo.context}
+
+
+
+
+
+ {seo.instantPayment && (
+
+
+
+
{seo.instantPayment}
+
+ {t(i18n.instantDeposits, { method: seo.instantPayment, country: countryName })}
+ {seo.payMerchants && ` ${i18n.qrPayments}`}
+
+
+
+
{i18n.stablecoins}
+
+ {t(i18n.stablecoinsDesc, { currency: currencyCode || 'local currency' })}
+
+
+
+
{i18n.bankTransfer}
+
{i18n.bankTransferDesc}
+
+
+
+ )}
+
+ {seo.faqs.length > 0 && }
+
+ {/* Related pages */}
+
+
+
+
+ {/* Last updated */}
+
+ {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })}
+
+
+ >
+ )
+}
diff --git a/src/components/Marketing/pages/FromToCorridorContent.tsx b/src/components/Marketing/pages/FromToCorridorContent.tsx
new file mode 100644
index 000000000..49d5fd275
--- /dev/null
+++ b/src/components/Marketing/pages/FromToCorridorContent.tsx
@@ -0,0 +1,262 @@
+import Link from 'next/link'
+import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
+import { COUNTRIES_SEO, getLocalizedSEO, getCountryName, CORRIDORS } from '@/data/seo'
+import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+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 { RelatedPages } from '@/components/Marketing/RelatedPages'
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface FromToCorridorContentProps {
+ from: string
+ to: string
+ locale: Locale
+}
+
+export function FromToCorridorContent({ from, to, locale }: FromToCorridorContentProps) {
+ const i18n = getTranslations(locale)
+ const fromName = getCountryName(from, locale)
+ const toName = getCountryName(to, locale)
+
+ const toSeo = getLocalizedSEO(to, locale)
+ const fromSeo = getLocalizedSEO(from, locale)
+
+ const fromMapping = findMappingBySlug(from)
+ const toMapping = findMappingBySlug(to)
+
+ const fromCurrency = fromMapping?.currencyCode ?? ''
+ const toCurrency = toMapping?.currencyCode ?? ''
+
+ const howToSteps = [
+ {
+ title: t(i18n.stepCreateAccount),
+ description: t(i18n.stepCreateAccountDesc),
+ },
+ {
+ title: t(i18n.stepDepositFunds),
+ description: t(i18n.stepDepositFundsDesc, { method: fromSeo?.instantPayment ?? '' }),
+ },
+ {
+ title: t(i18n.stepSendTo, { country: toName }),
+ description: t(i18n.stepSendToDesc, {
+ currency: toCurrency || 'local currency',
+ method: toSeo?.instantPayment ?? 'bank transfer',
+ }),
+ },
+ ]
+
+ const baseUrl = 'https://peanut.me'
+ const canonicalPath = `/${locale}/send-money-from/${from}/to/${to}`
+
+ const howToSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name: t(i18n.sendMoneyFromTo, { from: fromName, to: toName }),
+ description: t(i18n.sendMoneyFromToDesc, { from: fromName, to: toName }),
+ inLanguage: locale,
+ step: howToSteps.map((step, i) => ({
+ '@type': 'HowToStep',
+ position: i + 1,
+ name: step.title,
+ text: step.description,
+ })),
+ }
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: fromName,
+ item: `${baseUrl}${localizedBarePath(locale, from)}`,
+ },
+ {
+ '@type': 'ListItem',
+ position: 3,
+ name: t(i18n.sendMoneyFromTo, { from: fromName, to: toName }),
+ item: `${baseUrl}${canonicalPath}`,
+ },
+ ],
+ }
+
+ // Build FAQ from destination country FAQs
+ const faqs = toSeo?.faqs ?? []
+
+ // Related corridors from the same origin
+ const relatedFromSame = CORRIDORS.filter((c) => c.from === from && c.to !== to).slice(0, 6)
+
+ // Related pages for internal linking
+ const relatedPages = [
+ {
+ title: t(i18n.hubTitle, { country: fromName }),
+ href: localizedBarePath(locale, from),
+ },
+ {
+ title: t(i18n.hubTitle, { country: toName }),
+ href: localizedBarePath(locale, to),
+ },
+ {
+ title: t(i18n.sendMoneyTo, { country: toName }),
+ href: localizedPath('send-money-to', locale, to),
+ },
+ ]
+
+ if (toCurrency) {
+ relatedPages.push({
+ title: t(i18n.convertTitle, { from: fromCurrency || 'USD', to: toCurrency }),
+ href: localizedPath('convert', locale, `${(fromCurrency || 'usd').toLowerCase()}-to-${toCurrency.toLowerCase()}`),
+ })
+ }
+
+ const today = new Date().toISOString().split('T')[0]
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Route summary card */}
+
+
+
+ {fromMapping?.flagCode && (
+
+ )}
+
+
{i18n.sendMoney}
+
{fromName}
+ {fromCurrency &&
{fromCurrency} }
+
+
+ →
+
+ {toMapping?.flagCode && (
+
+ )}
+
+
{t(i18n.receiveMoneyFrom, { country: '' }).trim()}
+
{toName}
+ {toCurrency &&
{toCurrency} }
+
+
+
+
+
+ {/* Context paragraph */}
+
+
+ {t(i18n.fromToContext, { from: fromName, to: toName })}
+
+ {toSeo?.context && (
+ {toSeo.context}
+ )}
+
+
+ {/* How it works */}
+
+
+ {/* Payment methods */}
+ {(toSeo?.instantPayment || fromSeo?.instantPayment) && (
+
+
+ {fromSeo?.instantPayment && (
+
+ {fromSeo.instantPayment} ({fromName})
+
+ {t(i18n.instantDeposits, { method: fromSeo.instantPayment, country: fromName })}
+
+
+ )}
+ {toSeo?.instantPayment && (
+
+ {toSeo.instantPayment} ({toName})
+
+ {t(i18n.instantDeposits, { method: toSeo.instantPayment, country: toName })}
+
+
+ )}
+
+ {i18n.stablecoins}
+
+ {t(i18n.stablecoinsDesc, { currency: toCurrency || 'local currency' })}
+
+
+
+
+ )}
+
+ {/* Other corridors from same origin */}
+ {relatedFromSame.length > 0 && (
+
+
+ {relatedFromSame.map((c) => {
+ const destName = getCountryName(c.to, locale)
+ const destMapping = findMappingBySlug(c.to)
+ return (
+
+
+ {destMapping?.flagCode && (
+
+ )}
+
+ {fromName} → {destName}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* FAQs */}
+ {faqs.length > 0 && }
+
+ {/* Related pages */}
+
+
+ {/* Last updated */}
+
+ {t(i18n.lastUpdated, { date: today })}
+
+
+ >
+ )
+}
diff --git a/src/components/Marketing/pages/HubPageContent.tsx b/src/components/Marketing/pages/HubPageContent.tsx
new file mode 100644
index 000000000..75a1e1ed2
--- /dev/null
+++ b/src/components/Marketing/pages/HubPageContent.tsx
@@ -0,0 +1,275 @@
+import { notFound } from 'next/navigation'
+import Link from 'next/link'
+import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
+import { COUNTRIES_SEO, getLocalizedSEO, getCountryName, CORRIDORS, COMPETITORS, EXCHANGES } from '@/data/seo'
+import { readEntitySeo } from '@/lib/content'
+import { getTranslations, t, localizedPath } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { Section } from '@/components/Marketing/Section'
+import { FAQSection } from '@/components/Marketing/FAQSection'
+import { DestinationGrid } from '@/components/Marketing/DestinationGrid'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface HubPageContentProps {
+ country: string
+ locale: Locale
+}
+
+interface CountrySeoJson {
+ region: string
+ instantPayment: string | null
+ payMerchants: boolean
+ corridorsFrom: string[]
+ corridorsTo: string[]
+}
+
+interface HubLink {
+ title: string
+ description: string
+ href: string
+ emoji: string
+}
+
+export function HubPageContent({ country, locale }: HubPageContentProps) {
+ const seo = getLocalizedSEO(country, locale)
+ if (!seo) notFound()
+
+ const i18n = getTranslations(locale)
+ const countryName = getCountryName(country, locale)
+
+ const mapping = findMappingBySlug(country)
+ const currencyCode = mapping?.currencyCode ?? ''
+ const flagCode = mapping?.flagCode
+
+ // Load structured data for corridor relationships
+ const countrySeo = readEntitySeo('countries', country)
+
+ // Build hub spoke links
+ const links: HubLink[] = []
+
+ // 1. Send money corridor
+ links.push({
+ title: t(i18n.hubSendMoney, { country: countryName }),
+ description: t(i18n.hubSendMoneyDesc, { country: countryName }),
+ href: localizedPath('send-money-to', locale, country),
+ emoji: '💸',
+ })
+
+ // 2. Convert pages (relevant currency pairs)
+ if (currencyCode) {
+ const lowerCurrency = currencyCode.toLowerCase()
+ links.push({
+ title: t(i18n.hubConvert, { currency: currencyCode }),
+ description: t(i18n.hubConvertDesc),
+ href: localizedPath('convert', locale, `usd-to-${lowerCurrency}`),
+ emoji: '💱',
+ })
+ }
+
+ // 3. Deposit pages (related exchanges from country seo)
+ const relatedExchanges = Object.keys(EXCHANGES).slice(0, 3) // Top 3 exchanges
+ if (relatedExchanges.length > 0) {
+ links.push({
+ title: t(i18n.hubDeposit),
+ description: t(i18n.hubDepositDesc),
+ href: localizedPath('deposit', locale, `from-${relatedExchanges[0]}`),
+ emoji: '🏦',
+ })
+ }
+
+ // 4. Compare pages (if relevant competitors exist)
+ const competitorSlugs = Object.keys(COMPETITORS).filter(
+ (slug) => !['mercado-pago', 'pix', 'dolar-mep', 'cueva'].includes(slug)
+ )
+ if (competitorSlugs.length > 0) {
+ links.push({
+ title: t(i18n.hubCompare),
+ description: t(i18n.hubCompareDesc),
+ href: localizedPath('compare', locale, `peanut-vs-${competitorSlugs[0]}`),
+ emoji: '⚖️',
+ })
+ }
+
+ // Inbound corridors: countries that send money TO this country
+ const inboundCorridors = CORRIDORS.filter((c) => c.to === country).map((c) => c.from)
+
+ // Outbound corridors: countries this country sends money TO
+ const outboundCorridors = CORRIDORS.filter((c) => c.from === country).map((c) => c.to)
+
+ // Other countries for the grid
+ const otherCountries = Object.keys(COUNTRIES_SEO).filter((c) => c !== country)
+
+ const baseUrl = 'https://peanut.me'
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: countryName,
+ item: `${baseUrl}/${locale}/${country}`,
+ },
+ ],
+ }
+
+ const webPageSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'WebPage',
+ name: t(i18n.hubTitle, { country: countryName }),
+ description: t(i18n.hubSubtitle, { country: countryName }),
+ inLanguage: locale,
+ url: `${baseUrl}/${locale}/${country}`,
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Country context */}
+
+
+ {flagCode && (
+
+ )}
+
{seo.context}
+
+
+
+ {/* Hub spoke links grid */}
+
+
+ {links.map((link) => (
+
+
+ {link.emoji}
+ {link.title}
+ {link.description}
+
+
+ ))}
+
+
+
+ {/* Inbound corridors */}
+ {inboundCorridors.length > 0 && (
+
+
+ {t(i18n.hubInboundCorridors, { country: countryName })}
+
+
+ {inboundCorridors.map((fromSlug) => {
+ const fromName = getCountryName(fromSlug, locale)
+ const fromMapping = findMappingBySlug(fromSlug)
+ return (
+
+
+ {fromMapping?.flagCode && (
+
+ )}
+
+ {fromName} → {countryName}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Outbound corridors */}
+ {outboundCorridors.length > 0 && (
+
+
+ {outboundCorridors.map((toSlug) => {
+ const toName = getCountryName(toSlug, locale)
+ const toMapping = findMappingBySlug(toSlug)
+ return (
+
+
+ {toMapping?.flagCode && (
+
+ )}
+
+ {countryName} → {toName}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Instant payment highlight */}
+ {seo.instantPayment && (
+
+
+
+ {t(i18n.instantDeposits, {
+ method: seo.instantPayment,
+ country: countryName,
+ })}
+
+ {seo.payMerchants && {i18n.qrPayments}
}
+
+
+ )}
+
+ {/* FAQs */}
+ {seo.faqs.length > 0 && (
+
+ )}
+
+ {/* Other countries grid */}
+
+
+ {/* Last updated */}
+
+ {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })}
+
+
+ >
+ )
+}
diff --git a/src/components/Marketing/pages/PayWithContent.tsx b/src/components/Marketing/pages/PayWithContent.tsx
new file mode 100644
index 000000000..6cee0cf48
--- /dev/null
+++ b/src/components/Marketing/pages/PayWithContent.tsx
@@ -0,0 +1,108 @@
+import { PAYMENT_METHODS, getCountryName } from '@/data/seo'
+import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+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 { RelatedPages } from '@/components/Marketing/RelatedPages'
+
+interface PayWithContentProps {
+ method: string
+ locale: Locale
+}
+
+export function PayWithContent({ method, locale }: PayWithContentProps) {
+ const pm = PAYMENT_METHODS[method]
+ if (!pm) return null
+
+ const i18n = getTranslations(locale)
+
+ const steps = pm.steps.map((step, i) => ({
+ title: `${i + 1}`,
+ description: step,
+ }))
+
+ const baseUrl = 'https://peanut.me'
+
+ const howToSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name: t(i18n.payWith, { method: pm.name }),
+ description: pm.description,
+ inLanguage: locale,
+ step: steps.map((step, i) => ({
+ '@type': 'HowToStep',
+ position: i + 1,
+ name: step.title,
+ text: step.description,
+ })),
+ }
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: t(i18n.payWith, { method: pm.name }),
+ item: `${baseUrl}/${locale}/pay-with/${method}`,
+ },
+ ],
+ }
+
+ // Related pages: hub pages for countries where this method is available
+ const relatedPages = pm.countries.map((countrySlug) => ({
+ title: t(i18n.hubTitle, { country: getCountryName(countrySlug, locale) }),
+ href: localizedBarePath(locale, countrySlug),
+ }))
+
+ // Add send-money-to pages for related countries
+ for (const countrySlug of pm.countries.slice(0, 3)) {
+ relatedPages.push({
+ title: t(i18n.sendMoneyTo, { country: getCountryName(countrySlug, locale) }),
+ href: localizedPath('send-money-to', locale, countrySlug),
+ })
+ }
+
+ const today = new Date().toISOString().split('T')[0]
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Description */}
+
+
+ {/* Steps */}
+
+
+ {/* FAQs */}
+ {pm.faqs.length > 0 && }
+
+ {/* Related pages */}
+
+
+ {/* Last updated */}
+
+ {t(i18n.lastUpdated, { date: today })}
+
+
+ >
+ )
+}
diff --git a/src/components/Marketing/pages/ReceiveMoneyContent.tsx b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
new file mode 100644
index 000000000..7952f9821
--- /dev/null
+++ b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
@@ -0,0 +1,145 @@
+import Link from 'next/link'
+import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
+import { CORRIDORS, getCountryName, getLocalizedSEO } from '@/data/seo'
+import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+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 { RelatedPages } from '@/components/Marketing/RelatedPages'
+import { Card } from '@/components/0_Bruddle/Card'
+
+interface ReceiveMoneyContentProps {
+ sourceCountry: string
+ locale: Locale
+}
+
+export function ReceiveMoneyContent({ sourceCountry, locale }: ReceiveMoneyContentProps) {
+ const i18n = getTranslations(locale)
+ const sourceName = getCountryName(sourceCountry, locale)
+ const sourceSeo = getLocalizedSEO(sourceCountry, locale)
+
+ // Destinations that receive money from this source
+ const destinations = CORRIDORS.filter((c) => c.from === sourceCountry).map((c) => c.to)
+
+ const sourceMapping = findMappingBySlug(sourceCountry)
+
+ const howToSteps = [
+ {
+ title: t(i18n.stepCreateAccount),
+ description: t(i18n.stepCreateAccountDesc),
+ },
+ {
+ title: t(i18n.stepDepositFunds),
+ description: t(i18n.stepDepositFundsDesc, { method: sourceSeo?.instantPayment ?? '' }),
+ },
+ {
+ title: i18n.sendMoney,
+ description: t(i18n.receiveMoneyFromDesc, { country: sourceName }),
+ },
+ ]
+
+ const baseUrl = 'https://peanut.me'
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: t(i18n.receiveMoneyFrom, { country: sourceName }),
+ item: `${baseUrl}/${locale}/receive-money-from/${sourceCountry}`,
+ },
+ ],
+ }
+
+ const faqs = sourceSeo?.faqs ?? []
+
+ // Related pages for internal linking
+ const relatedPages = [
+ {
+ title: t(i18n.hubTitle, { country: sourceName }),
+ href: localizedBarePath(locale, sourceCountry),
+ },
+ {
+ title: t(i18n.sendMoneyTo, { country: sourceName }),
+ href: localizedPath('send-money-to', locale, sourceCountry),
+ },
+ ]
+
+ // Add from-to corridor links for each destination
+ for (const dest of destinations.slice(0, 3)) {
+ const destName = getCountryName(dest, locale)
+ relatedPages.push({
+ title: t(i18n.sendMoneyFromTo, { from: sourceName, to: destName }),
+ href: localizedPath('send-money-from', locale, `${sourceCountry}/to/${dest}`),
+ })
+ }
+
+ const today = new Date().toISOString().split('T')[0]
+
+ return (
+ <>
+
+
+
+
+
+ {/* Destination countries grid */}
+
+
+ {destinations.map((destSlug) => {
+ const destName = getCountryName(destSlug, locale)
+ const destMapping = findMappingBySlug(destSlug)
+ return (
+
+
+ {destMapping?.flagCode && (
+
+ )}
+
+ {sourceName} → {destName}
+
+
+
+ )
+ })}
+
+
+
+ {/* How it works */}
+
+
+ {/* FAQs */}
+ {faqs.length > 0 && }
+
+ {/* Related pages */}
+
+
+ {/* Last updated */}
+
+ {t(i18n.lastUpdated, { date: today })}
+
+
+ >
+ )
+}
diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts
index 0d07002d4..7922b1fa6 100644
--- a/src/constants/countryCurrencyMapping.ts
+++ b/src/constants/countryCurrencyMapping.ts
@@ -109,3 +109,10 @@ export function isUKCountry(countryIdentifier: string | undefined): boolean {
const lower = countryIdentifier.toLowerCase()
return lower === 'united-kingdom' || lower === 'gb' || lower === 'gbr'
}
+
+/** Find a currency mapping by country slug (e.g. 'argentina', 'united-kingdom'). */
+export function findMappingBySlug(slug: string): CountryCurrencyMapping | undefined {
+ return countryCurrencyMappings.find(
+ (m) => m.path === slug || m.country.toLowerCase().replace(/ /g, '-') === slug
+ )
+}
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 0a7b4f639..eff22ac08 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -10,6 +10,7 @@
* These should not be handled by catch-all routes
*/
export const DEDICATED_ROUTES = [
+ // App routes (auth-gated)
'qr',
'api',
'setup',
@@ -23,6 +24,41 @@ export const DEDICATED_ROUTES = [
'invite',
'support',
'dev',
+ 'send',
+ 'profile',
+ 'kyc',
+ 'maintenance',
+ 'quests',
+ 'receipt',
+ 'crisp-proxy',
+ 'card-payment',
+ 'add-money',
+ 'withdraw',
+ 'sdk',
+ 'qr-pay',
+
+ // Public pages (existing)
+ 'careers',
+ 'privacy',
+ 'terms',
+ 'lp',
+ 'exchange',
+
+ // Future SEO routes (pre-register so catch-all doesn't intercept)
+ 'send-money-to',
+ 'receive-money-from',
+ 'deposit',
+ 'pay-with',
+ 'convert',
+ 'compare',
+ 'blog',
+ 'help',
+ 'faq',
+ 'how-it-works',
+
+ // Future locale prefixes
+ 'es',
+ 'pt',
] as const
/**
diff --git a/src/content b/src/content
new file mode 120000
index 000000000..f17844c77
--- /dev/null
+++ b/src/content
@@ -0,0 +1 @@
+/home/hugo/Projects/Peanut/peanut-content
\ No newline at end of file
diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts
new file mode 100644
index 000000000..c8a596407
--- /dev/null
+++ b/src/data/seo/comparisons.ts
@@ -0,0 +1,56 @@
+// Typed wrappers for competitor comparison data.
+// Reads from per-competitor directories: peanut-content/competitors//
+// Public API unchanged from the previous monolithic JSON version.
+
+import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+
+export interface Competitor {
+ name: string
+ tagline: string
+ rows: Array<{ feature: string; peanut: string; competitor: string }>
+ prosCompetitor: string[]
+ consCompetitor: string[]
+ verdict: string
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface CompetitorSeoJson {
+ name: string
+ tagline: string
+ rows: Array<{ feature: string; peanut: string; competitor: string }>
+ prosCompetitor: string[]
+ consCompetitor: string[]
+ verdict: string
+}
+
+interface CompetitorFrontmatter {
+ title: string
+ description: string
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface CompetitorIndex {
+ competitors: Array<{ slug: string; name: string; locales: string[] }>
+}
+
+function loadCompetitors(): Record {
+ const index = readEntityIndex('competitors')
+ if (!index) return {}
+
+ const result: Record = {}
+
+ for (const { slug } of index.competitors) {
+ const seo = readEntitySeo('competitors', slug)
+ const content = readEntityContent('competitors', slug, 'en')
+ if (!seo || !content) continue
+
+ result[slug] = {
+ ...seo,
+ faqs: content.frontmatter.faqs ?? [],
+ }
+ }
+
+ return result
+}
+
+export const COMPETITORS: Record = loadCompetitors()
diff --git a/src/data/seo/convert.ts b/src/data/seo/convert.ts
new file mode 100644
index 000000000..9616b583b
--- /dev/null
+++ b/src/data/seo/convert.ts
@@ -0,0 +1,27 @@
+// Typed re-exports for currency conversion data.
+// Raw data lives in peanut-content (YAML). Types and logic live here.
+
+import fs from 'fs'
+import path from 'path'
+import matter from 'gray-matter'
+
+const yaml = matter.engines.yaml
+
+interface ConvertData {
+ pairs: string[]
+ currencyDisplay: Record
+}
+
+const convertData = yaml.parse(
+ fs.readFileSync(path.join(process.cwd(), 'src/content/convert/pairs.yaml'), 'utf8')
+) as ConvertData
+
+export const CONVERT_PAIRS: readonly string[] = convertData.pairs
+export const CURRENCY_DISPLAY: Record = convertData.currencyDisplay
+
+/** Parse a convert pair slug into from/to currencies: 'usd-to-ars' → { from: 'usd', to: 'ars' } */
+export function parseConvertPair(pair: string): { from: string; to: string } | null {
+ const match = pair.match(/^([a-z]+)-to-([a-z]+)$/)
+ if (!match) return null
+ return { from: match[1], to: match[2] }
+}
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
new file mode 100644
index 000000000..1e72896f6
--- /dev/null
+++ b/src/data/seo/corridors.ts
@@ -0,0 +1,96 @@
+// Typed wrappers for corridor/country SEO data.
+// Reads from per-country directories: peanut-content/countries//
+// Public API unchanged from the previous monolithic JSON version.
+
+import fs from 'fs'
+import path from 'path'
+import matter from 'gray-matter'
+import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+import type { Locale } from '@/i18n/types'
+
+const yaml = matter.engines.yaml
+const countryNamesData = yaml.parse(
+ fs.readFileSync(path.join(process.cwd(), 'src/content/i18n/country-names.yaml'), 'utf8')
+) as Record>
+
+export interface CountrySEO {
+ region: 'latam' | 'north-america' | 'europe' | 'asia-oceania'
+ context: string
+ instantPayment?: string
+ payMerchants: boolean
+ faqs: Array<{ q: string; a: string }>
+}
+
+export interface Corridor {
+ from: string
+ to: string
+}
+
+interface CountrySeoJson {
+ region: 'latam' | 'north-america' | 'europe' | 'asia-oceania'
+ instantPayment: string | null
+ payMerchants: boolean
+ corridorsFrom: string[]
+ corridorsTo: string[]
+}
+
+interface CountryFrontmatter {
+ title: string
+ description: string
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface CountryIndex {
+ countries: Array<{ slug: string; region: string; locales: string[] }>
+ corridors: Corridor[]
+}
+
+function loadAll() {
+ const index = readEntityIndex('countries')
+ if (!index) return { countries: {} as Record, corridors: [] as Corridor[] }
+
+ const countries: Record = {}
+
+ for (const { slug } of index.countries) {
+ const seo = readEntitySeo('countries', slug)
+ const content = readEntityContent('countries', slug, 'en')
+ if (!seo || !content) continue
+
+ countries[slug] = {
+ region: seo.region,
+ context: content.body,
+ instantPayment: seo.instantPayment ?? undefined,
+ payMerchants: seo.payMerchants,
+ faqs: content.frontmatter.faqs ?? [],
+ }
+ }
+
+ return { countries, corridors: index.corridors }
+}
+
+const _loaded = loadAll()
+
+export const COUNTRIES_SEO: Record = _loaded.countries
+export const CORRIDORS: Corridor[] = _loaded.corridors
+
+/** Get country SEO data with locale-specific content (falls back to English) */
+export function getLocalizedSEO(country: string, locale: Locale): CountrySEO | null {
+ const base = COUNTRIES_SEO[country]
+ if (!base) return null
+ if (locale === 'en') return base
+
+ const localized = readEntityContent('countries', country, locale)
+ if (!localized) return base
+
+ return {
+ ...base,
+ context: localized.body,
+ faqs: localized.frontmatter.faqs ?? base.faqs,
+ }
+}
+
+/** Get localized country display name */
+export function getCountryName(slug: string, locale: Locale): string {
+ const names = countryNamesData[slug]
+ return names?.[locale] ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+}
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
new file mode 100644
index 000000000..e99c75138
--- /dev/null
+++ b/src/data/seo/exchanges.ts
@@ -0,0 +1,62 @@
+// Typed wrappers for exchange deposit data.
+// Reads from per-exchange directories: peanut-content/exchanges//
+// Public API unchanged from the previous monolithic JSON version.
+
+import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+
+export interface Exchange {
+ name: string
+ recommendedNetwork: string
+ alternativeNetworks: string[]
+ withdrawalFee: string
+ processingTime: string
+ networkFee: string
+ steps: string[]
+ troubleshooting: Array<{ issue: string; fix: string }>
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface ExchangeSeoJson {
+ name: string
+ recommendedNetwork: string
+ alternativeNetworks: string[]
+ withdrawalFee: string
+ processingTime: string
+ networkFee: string
+}
+
+interface ExchangeFrontmatter {
+ title: string
+ description: string
+ steps: string[]
+ troubleshooting: Array<{ issue: string; fix: string }>
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface ExchangeIndex {
+ exchanges: Array<{ slug: string; name: string; locales: string[] }>
+}
+
+function loadExchanges(): Record {
+ const index = readEntityIndex('exchanges')
+ if (!index) return {}
+
+ const result: Record = {}
+
+ for (const { slug } of index.exchanges) {
+ const seo = readEntitySeo('exchanges', slug)
+ const content = readEntityContent('exchanges', slug, 'en')
+ if (!seo || !content) continue
+
+ result[slug] = {
+ ...seo,
+ steps: content.frontmatter.steps ?? [],
+ troubleshooting: content.frontmatter.troubleshooting ?? [],
+ faqs: content.frontmatter.faqs ?? [],
+ }
+ }
+
+ return result
+}
+
+export const EXCHANGES: Record = loadExchanges()
diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts
new file mode 100644
index 000000000..89581e534
--- /dev/null
+++ b/src/data/seo/index.ts
@@ -0,0 +1,13 @@
+export { COUNTRIES_SEO, CORRIDORS, getLocalizedSEO, getCountryName } from './corridors'
+export type { CountrySEO, Corridor } from './corridors'
+
+export { CONVERT_PAIRS, CURRENCY_DISPLAY, parseConvertPair } from './convert'
+
+export { COMPETITORS } from './comparisons'
+export type { Competitor } from './comparisons'
+
+export { EXCHANGES } from './exchanges'
+export type { Exchange } from './exchanges'
+
+export { PAYMENT_METHODS, PAYMENT_METHOD_SLUGS } from './payment-methods'
+export type { PaymentMethod } from './payment-methods'
diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts
new file mode 100644
index 000000000..46ab74a87
--- /dev/null
+++ b/src/data/seo/payment-methods.ts
@@ -0,0 +1,57 @@
+// Typed wrapper for payment method data.
+// Raw data lives in peanut-content/payment-methods/{slug}/. Types and logic live here.
+
+import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+
+export interface PaymentMethod {
+ slug: string
+ name: string
+ countries: string[]
+ description: string
+ steps: string[]
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface PaymentMethodDataJson {
+ name: string
+ countries: string[]
+}
+
+interface PaymentMethodFrontmatter {
+ title: string
+ description: string
+ steps: string[]
+ faqs: Array<{ q: string; a: string }>
+}
+
+interface PaymentMethodIndex {
+ methods: Array<{ slug: string; name: string; locales: string[] }>
+}
+
+function loadPaymentMethods(): Record {
+ const index = readEntityIndex('payment-methods')
+ if (!index) return {}
+
+ const result: Record = {}
+
+ for (const entry of index.methods) {
+ const data = readEntitySeo('payment-methods', entry.slug)
+ const content = readEntityContent('payment-methods', entry.slug, 'en')
+
+ if (!data || !content) continue
+
+ result[entry.slug] = {
+ slug: entry.slug,
+ name: data.name,
+ countries: data.countries,
+ description: content.body,
+ steps: content.frontmatter.steps ?? [],
+ faqs: content.frontmatter.faqs ?? [],
+ }
+ }
+
+ return result
+}
+
+export const PAYMENT_METHODS = loadPaymentMethods()
+export const PAYMENT_METHOD_SLUGS = Object.keys(PAYMENT_METHODS)
diff --git a/src/data/team.ts b/src/data/team.ts
new file mode 100644
index 000000000..480139e6d
--- /dev/null
+++ b/src/data/team.ts
@@ -0,0 +1,50 @@
+/**
+ * Team member data for the /team page and blog author attribution.
+ *
+ * TODO (team): Fill in real team member data:
+ * - name: Full name
+ * - role: Job title
+ * - bio: 1-2 sentence bio focusing on expertise (builds E-E-A-T for Google)
+ * - slug: URL-safe identifier (used for /team/{slug} if individual pages are added later)
+ * - image: Path to headshot in /public/team/ (recommended: 400x400px, WebP format)
+ * - social: Optional links to LinkedIn, Twitter/X, GitHub
+ *
+ * Why this matters for SEO:
+ * - Google's E-E-A-T (Experience, Expertise, Authoritativeness, Trust) signals
+ * - Blog posts linked to real author profiles rank better
+ * - Author structured data (schema.org/Person) builds entity recognition
+ */
+
+export interface TeamMember {
+ slug: string
+ name: string
+ role: string
+ bio: string
+ image?: string
+ social?: {
+ linkedin?: string
+ twitter?: string
+ github?: string
+ }
+}
+
+export const TEAM_MEMBERS: TeamMember[] = [
+ // TODO (team): Replace with real team data
+ {
+ slug: 'hugo',
+ name: 'Hugo Montenegro',
+ role: 'Co-Founder',
+ bio: 'Building Peanut to make cross-border payments accessible to everyone.',
+ },
+ {
+ slug: 'konrad',
+ name: 'Konrad',
+ role: 'Co-Founder',
+ bio: 'Focused on growth and making Peanut the easiest way to send money internationally.',
+ },
+]
+
+/** Find a team member by slug */
+export function getTeamMember(slug: string): TeamMember | undefined {
+ return TEAM_MEMBERS.find((m) => m.slug === slug)
+}
diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts
new file mode 100644
index 000000000..e438cde8f
--- /dev/null
+++ b/src/hooks/useLongPress.ts
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export interface LongPressOptions {
+ duration?: number // Duration in milliseconds (default: 2000)
+ onLongPress?: () => void
+ onLongPressStart?: () => void
+ onLongPressEnd?: () => void
+}
+
+export interface LongPressReturn {
+ isLongPressed: boolean
+ pressProgress: number
+ handlers: {
+ onMouseDown: () => void
+ onMouseUp: () => void
+ onMouseLeave: () => void
+ onTouchStart: () => void
+ onTouchEnd: () => void
+ onTouchCancel: () => void
+ }
+}
+
+export function useLongPress(options: LongPressOptions | undefined): LongPressReturn {
+ const [isLongPressed, setIsLongPressed] = useState(false)
+ const [pressProgress, setPressProgress] = useState(0)
+
+ const pressTimerRef = useRef(null)
+ const progressIntervalRef = useRef(null)
+ const isLongPressedRef = useRef(false)
+
+ // Keep ref in sync for use in callbacks without stale closures
+ isLongPressedRef.current = isLongPressed
+
+ const clearTimers = useCallback(() => {
+ if (pressTimerRef.current) {
+ clearTimeout(pressTimerRef.current)
+ pressTimerRef.current = null
+ }
+ if (progressIntervalRef.current) {
+ clearInterval(progressIntervalRef.current)
+ progressIntervalRef.current = null
+ }
+ }, [])
+
+ const handlePressStart = useCallback(() => {
+ if (!options) return
+
+ options.onLongPressStart?.()
+ setPressProgress(0)
+
+ const duration = options.duration || 2000
+ const updateInterval = 16 // ~60fps
+ const increment = (100 / duration) * updateInterval
+
+ const progressTimer = setInterval(() => {
+ setPressProgress((prev) => {
+ const newProgress = prev + increment
+ if (newProgress >= 100) {
+ clearInterval(progressTimer)
+ return 100
+ }
+ return newProgress
+ })
+ }, updateInterval)
+
+ progressIntervalRef.current = progressTimer
+
+ const timer = setTimeout(() => {
+ setIsLongPressed(true)
+ options.onLongPress?.()
+ clearInterval(progressTimer)
+ }, duration)
+
+ pressTimerRef.current = timer
+ }, [options])
+
+ const handlePressEnd = useCallback(() => {
+ if (!options) return
+
+ clearTimers()
+
+ if (isLongPressedRef.current) {
+ options.onLongPressEnd?.()
+ setIsLongPressed(false)
+ }
+
+ setPressProgress(0)
+ }, [options, clearTimers])
+
+ const handlePressCancel = useCallback(() => {
+ if (!options) return
+
+ clearTimers()
+ setIsLongPressed(false)
+ setPressProgress(0)
+ }, [options, clearTimers])
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ clearTimers()
+ }
+ }, [clearTimers])
+
+ return {
+ isLongPressed,
+ pressProgress,
+ handlers: {
+ onMouseDown: handlePressStart,
+ onMouseUp: handlePressEnd,
+ onMouseLeave: handlePressCancel,
+ onTouchStart: handlePressStart,
+ onTouchEnd: handlePressEnd,
+ onTouchCancel: handlePressCancel,
+ },
+ }
+}
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
new file mode 100644
index 000000000..adf324437
--- /dev/null
+++ b/src/i18n/config.ts
@@ -0,0 +1,60 @@
+import { type Locale, SUPPORTED_LOCALES, DEFAULT_LOCALE } from './types'
+
+/** All marketing route slugs — same across all locales (Wise pattern) */
+export const ROUTE_SLUGS = [
+ 'send-money-to',
+ 'send-money-from',
+ 'convert',
+ 'compare',
+ 'deposit',
+ 'blog',
+ 'receive-money-from',
+ 'pay-with',
+ 'team',
+] as const
+
+export type RouteSlug = (typeof ROUTE_SLUGS)[number]
+
+/** Build a localized path: all locales get /{locale}/ prefix */
+export function localizedPath(route: RouteSlug, locale: Locale, ...segments: string[]): string {
+ const suffix = segments.length > 0 ? `/${segments.join('/')}` : ''
+ return `/${locale}/${route}${suffix}`
+}
+
+/** Build a bare localized path (no route prefix): /{locale}/{segment} */
+export function localizedBarePath(locale: Locale, ...segments: string[]): string {
+ const suffix = segments.length > 0 ? `/${segments.join('/')}` : ''
+ return `/${locale}${suffix}`
+}
+
+/** Get all alternate URLs for hreflang tags */
+export function getAlternates(route: RouteSlug, ...segments: string[]): Record {
+ const alternates: Record = {}
+ for (const locale of SUPPORTED_LOCALES) {
+ const langCode = locale === 'en' ? 'x-default' : locale
+ alternates[langCode] = `https://peanut.me${localizedPath(route, locale, ...segments)}`
+ }
+ // Also add 'en' explicitly alongside x-default
+ alternates['en'] = `https://peanut.me${localizedPath(route, 'en', ...segments)}`
+ return alternates
+}
+
+/** Get alternate URLs for bare paths (hub pages at /{locale}/{country}) */
+export function getBareAlternates(...segments: string[]): Record {
+ const alternates: Record = {}
+ for (const locale of SUPPORTED_LOCALES) {
+ const langCode = locale === 'en' ? 'x-default' : locale
+ alternates[langCode] = `https://peanut.me${localizedBarePath(locale, ...segments)}`
+ }
+ alternates['en'] = `https://peanut.me${localizedBarePath('en', ...segments)}`
+ return alternates
+}
+
+export function isValidLocale(locale: string): locale is Locale {
+ return SUPPORTED_LOCALES.includes(locale as Locale)
+}
+
+/** Non-default locales (used in generateStaticParams for [locale] segment) */
+export const NON_DEFAULT_LOCALES = SUPPORTED_LOCALES.filter((l) => l !== DEFAULT_LOCALE)
+
+export { SUPPORTED_LOCALES, DEFAULT_LOCALE, type Locale }
diff --git a/src/i18n/en.json b/src/i18n/en.json
new file mode 100644
index 000000000..4fb9de91a
--- /dev/null
+++ b/src/i18n/en.json
@@ -0,0 +1,63 @@
+{
+ "sendMoneyTo": "Send Money to {country}",
+ "sendMoneyToSubtitle": "Fast, affordable transfers to {country} in {currency}. Better rates than banks.",
+ "getStarted": "Get Started",
+ "createAccount": "Create your Peanut account",
+ "howItWorks": "How It Works",
+ "paymentMethods": "Payment Methods",
+ "frequentlyAskedQuestions": "Frequently Asked Questions",
+ "sendMoneyToOtherCountries": "Send money to other countries",
+ "sendingMoneyTo": "Sending Money to {country}",
+ "stepCreateAccount": "Create your Peanut account",
+ "stepCreateAccountDesc": "Sign up in under 2 minutes with your email or wallet.",
+ "stepDepositFunds": "Deposit funds",
+ "stepDepositFundsDesc": "Add money via bank transfer, {method}, or stablecoins (USDC/USDT).",
+ "stepSendTo": "Send to {country}",
+ "stepSendToDesc": "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}.",
+ "instantDeposits": "Instant deposits and payments via {method} in {country}.",
+ "qrPayments": "Use your balance at millions of merchants via QR code payments.",
+ "stablecoins": "Stablecoins (USDC / USDT)",
+ "stablecoinsDesc": "Deposit stablecoins from any wallet or exchange. Converted to {currency} at market rates.",
+ "bankTransfer": "Bank Transfer",
+ "bankTransferDesc": "Traditional bank wire or local transfer. Settlement times vary by region.",
+ "readMore": "Read more",
+ "allArticles": "All articles",
+ "blog": "Blog",
+ "postedOn": "Posted on {date}",
+ "feature": "Feature",
+ "verdict": "Verdict",
+ "home": "Home",
+ "sendMoney": "Send Money",
+ "convertTitle": "Convert {from} to {to}",
+ "amount": "Amount",
+ "liveRate": "Live Rate",
+ "depositFrom": "Deposit from {exchange}",
+ "recommendedNetwork": "Recommended Network",
+ "withdrawalFee": "Withdrawal Fee",
+ "processingTime": "Processing Time",
+ "troubleshooting": "Troubleshooting",
+ "hubTitle": "Peanut in {country}",
+ "hubSubtitle": "Everything you need to send, receive, and spend money in {country}.",
+ "hubSendMoney": "Send Money to {country}",
+ "hubSendMoneyDesc": "Transfer money to {country} with competitive rates.",
+ "hubConvert": "Convert to {currency}",
+ "hubConvertDesc": "See live exchange rates and convert currencies.",
+ "hubDeposit": "Fund Your Account",
+ "hubDepositDesc": "Add money from popular exchanges and wallets.",
+ "hubCompare": "Compare Services",
+ "hubCompareDesc": "See how Peanut compares to other transfer options.",
+ "hubExploreCountries": "Explore other countries",
+ "hubInboundCorridors": "Send money to {country} from these countries:",
+ "hubSendMoneyFrom": "Send Money from {country}",
+ "sendMoneyFromTo": "Send Money from {from} to {to}",
+ "sendMoneyFromToDesc": "Transfer money from {from} to {to}. Fast, affordable, and secure.",
+ "fromToContext": "Peanut makes it easy to send money from {from} to {to}. Get competitive exchange rates, low fees, and fast delivery.",
+ "receiveMoneyFrom": "Receive Money from {country}",
+ "receiveMoneyFromDesc": "Get money sent to you from {country}. Fast and secure.",
+ "payWith": "Pay with {method}",
+ "payWithDesc": "Use {method} to send and receive money on Peanut.",
+ "teamTitle": "Our Team",
+ "teamSubtitle": "The people behind Peanut.",
+ "lastUpdated": "Last updated: {date}",
+ "relatedPages": "Related Pages"
+}
diff --git a/src/i18n/es.json b/src/i18n/es.json
new file mode 100644
index 000000000..5dff81d63
--- /dev/null
+++ b/src/i18n/es.json
@@ -0,0 +1,63 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Comenzar",
+ "createAccount": "Crea tu cuenta Peanut",
+ "howItWorks": "Cómo Funciona",
+ "paymentMethods": "Métodos de Pago",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar dinero a otros países",
+ "sendingMoneyTo": "Enviar Dinero a {country}",
+ "stepCreateAccount": "Crea tu cuenta Peanut",
+ "stepCreateAccountDesc": "Regístrate en menos de 2 minutos con tu email o wallet.",
+ "stepDepositFunds": "Deposita fondos",
+ "stepDepositFundsDesc": "Agrega dinero por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
+ "stepSendTo": "Envía a {country}",
+ "stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
+ "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
+ "qrPayments": "Usa tu saldo en millones de comercios con pagos QR.",
+ "stablecoins": "Stablecoins (USDC / USDT)",
+ "stablecoinsDesc": "Deposita stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
+ "bankTransfer": "Transferencia Bancaria",
+ "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
+ "readMore": "Leer más",
+ "allArticles": "Todos los artículos",
+ "blog": "Blog",
+ "postedOn": "Publicado el {date}",
+ "feature": "Característica",
+ "verdict": "Veredicto",
+ "home": "Inicio",
+ "sendMoney": "Enviar Dinero",
+ "convertTitle": "Convertir {from} a {to}",
+ "amount": "Monto",
+ "liveRate": "Tasa en Vivo",
+ "depositFrom": "Depositar desde {exchange}",
+ "recommendedNetwork": "Red Recomendada",
+ "withdrawalFee": "Comisión de Retiro",
+ "processingTime": "Tiempo de Procesamiento",
+ "troubleshooting": "Solución de Problemas",
+ "hubTitle": "Peanut en {country}",
+ "hubSubtitle": "Todo lo que necesitas para enviar, recibir y gastar dinero en {country}.",
+ "hubSendMoney": "Enviar Dinero a {country}",
+ "hubSendMoneyDesc": "Transfiere dinero a {country} con tasas competitivas.",
+ "hubConvert": "Convertir a {currency}",
+ "hubConvertDesc": "Consulta tasas de cambio en vivo y convierte divisas.",
+ "hubDeposit": "Fondea Tu Cuenta",
+ "hubDepositDesc": "Agrega dinero desde exchanges y wallets populares.",
+ "hubCompare": "Comparar Servicios",
+ "hubCompareDesc": "Compara Peanut con otras opciones de transferencia.",
+ "hubExploreCountries": "Explorar otros países",
+ "hubInboundCorridors": "Envía dinero a {country} desde estos países:",
+ "hubSendMoneyFrom": "Enviar Dinero desde {country}",
+ "sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
+ "sendMoneyFromToDesc": "Transfiere dinero de {from} a {to}. Rápido, económico y seguro.",
+ "fromToContext": "Peanut facilita enviar dinero de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
+ "receiveMoneyFrom": "Recibir Dinero de {country}",
+ "receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
+ "payWith": "Pagar con {method}",
+ "payWithDesc": "Usa {method} para enviar y recibir dinero en Peanut.",
+ "teamTitle": "Nuestro Equipo",
+ "teamSubtitle": "Las personas detrás de Peanut.",
+ "lastUpdated": "Última actualización: {date}",
+ "relatedPages": "Páginas Relacionadas"
+}
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 000000000..aa3b93664
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,34 @@
+import type { Locale, Translations } from './types'
+import en from './en.json'
+import es from './es.json'
+import pt from './pt.json'
+
+const messages: Record = {
+ en: en as Translations,
+ es: es as Translations,
+ pt: pt as Translations,
+}
+
+/** Get translations for a locale */
+export function getTranslations(locale: Locale): Translations {
+ return messages[locale] ?? messages.en
+}
+
+/** Simple template interpolation: replaces {key} with values */
+export function t(template: string, vars?: Record): string {
+ if (!vars) return template
+ return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`)
+}
+
+export { type Locale, type Translations } from './types'
+export { SUPPORTED_LOCALES, DEFAULT_LOCALE } from './types'
+export {
+ ROUTE_SLUGS,
+ localizedPath,
+ localizedBarePath,
+ getAlternates,
+ getBareAlternates,
+ isValidLocale,
+ NON_DEFAULT_LOCALES,
+ type RouteSlug,
+} from './config'
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
new file mode 100644
index 000000000..1549639f2
--- /dev/null
+++ b/src/i18n/pt.json
@@ -0,0 +1,63 @@
+{
+ "sendMoneyTo": "Enviar Dinheiro para {country}",
+ "sendMoneyToSubtitle": "Transferências rápidas e acessíveis para {country} em {currency}. Melhores taxas que os bancos.",
+ "getStarted": "Começar",
+ "createAccount": "Crie sua conta Peanut",
+ "howItWorks": "Como Funciona",
+ "paymentMethods": "Métodos de Pagamento",
+ "frequentlyAskedQuestions": "Perguntas Frequentes",
+ "sendMoneyToOtherCountries": "Enviar dinheiro para outros países",
+ "sendingMoneyTo": "Enviar Dinheiro para {country}",
+ "stepCreateAccount": "Crie sua conta Peanut",
+ "stepCreateAccountDesc": "Cadastre-se em menos de 2 minutos com seu email ou carteira.",
+ "stepDepositFunds": "Deposite fundos",
+ "stepDepositFundsDesc": "Adicione dinheiro por transferência bancária, {method}, ou stablecoins (USDC/USDT).",
+ "stepSendTo": "Envie para {country}",
+ "stepSendToDesc": "Insira os dados do destinatário e confirme. Eles recebem {currency} em minutos via {method}.",
+ "instantDeposits": "Depósitos e pagamentos instantâneos via {method} em {country}.",
+ "qrPayments": "Use seu saldo em milhões de estabelecimentos com pagamentos QR.",
+ "stablecoins": "Stablecoins (USDC / USDT)",
+ "stablecoinsDesc": "Deposite stablecoins de qualquer carteira ou exchange. Convertidos para {currency} a taxas de mercado.",
+ "bankTransfer": "Transferência Bancária",
+ "bankTransferDesc": "Transferência bancária tradicional ou local. Os tempos variam por região.",
+ "readMore": "Leia mais",
+ "allArticles": "Todos os artigos",
+ "blog": "Blog",
+ "postedOn": "Publicado em {date}",
+ "feature": "Recurso",
+ "verdict": "Veredito",
+ "home": "Início",
+ "sendMoney": "Enviar Dinheiro",
+ "convertTitle": "Converter {from} para {to}",
+ "amount": "Valor",
+ "liveRate": "Taxa ao Vivo",
+ "depositFrom": "Depositar de {exchange}",
+ "recommendedNetwork": "Rede Recomendada",
+ "withdrawalFee": "Taxa de Saque",
+ "processingTime": "Tempo de Processamento",
+ "troubleshooting": "Solução de Problemas",
+ "hubTitle": "Peanut em {country}",
+ "hubSubtitle": "Tudo que você precisa para enviar, receber e gastar dinheiro em {country}.",
+ "hubSendMoney": "Enviar Dinheiro para {country}",
+ "hubSendMoneyDesc": "Transfira dinheiro para {country} com taxas competitivas.",
+ "hubConvert": "Converter para {currency}",
+ "hubConvertDesc": "Veja taxas de câmbio ao vivo e converta moedas.",
+ "hubDeposit": "Financie Sua Conta",
+ "hubDepositDesc": "Adicione dinheiro de exchanges e carteiras populares.",
+ "hubCompare": "Comparar Serviços",
+ "hubCompareDesc": "Veja como o Peanut se compara a outras opções de transferência.",
+ "hubExploreCountries": "Explorar outros países",
+ "hubInboundCorridors": "Envie dinheiro para {country} destes países:",
+ "hubSendMoneyFrom": "Enviar Dinheiro de {country}",
+ "sendMoneyFromTo": "Enviar Dinheiro de {from} para {to}",
+ "sendMoneyFromToDesc": "Transfira dinheiro de {from} para {to}. Rápido, acessível e seguro.",
+ "fromToContext": "O Peanut facilita o envio de dinheiro de {from} para {to}. Taxas competitivas, baixas comissões e entrega rápida.",
+ "receiveMoneyFrom": "Receber Dinheiro de {country}",
+ "receiveMoneyFromDesc": "Receba dinheiro enviado de {country}. Rápido e seguro.",
+ "payWith": "Pagar com {method}",
+ "payWithDesc": "Use {method} para enviar e receber dinheiro no Peanut.",
+ "teamTitle": "Nossa Equipe",
+ "teamSubtitle": "As pessoas por trás do Peanut.",
+ "lastUpdated": "Última atualização: {date}",
+ "relatedPages": "Páginas Relacionadas"
+}
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
new file mode 100644
index 000000000..3b7829175
--- /dev/null
+++ b/src/i18n/types.ts
@@ -0,0 +1,97 @@
+export type Locale = 'en' | 'es' | 'pt'
+
+export const SUPPORTED_LOCALES: Locale[] = ['en', 'es', 'pt']
+export const DEFAULT_LOCALE: Locale = 'en'
+
+export interface Translations {
+ // Hero / CTA
+ sendMoneyTo: string // "Send Money to {country}"
+ sendMoneyToSubtitle: string // "Fast, affordable transfers to {country} in {currency}. Better rates than banks."
+ getStarted: string
+ createAccount: string
+
+ // Section titles
+ howItWorks: string
+ paymentMethods: string
+ frequentlyAskedQuestions: string
+ sendMoneyToOtherCountries: string
+ sendingMoneyTo: string // "Sending Money to {country}"
+
+ // Steps
+ stepCreateAccount: string
+ stepCreateAccountDesc: string
+ stepDepositFunds: string
+ stepDepositFundsDesc: string // "Add money via bank transfer, {method}, or stablecoins (USDC/USDT)."
+ stepSendTo: string // "Send to {country}"
+ stepSendToDesc: string // "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}."
+
+ // Payment methods
+ instantDeposits: string // "Instant deposits and payments via {method} in {country}."
+ qrPayments: string
+ stablecoins: string
+ stablecoinsDesc: string
+ bankTransfer: string
+ bankTransferDesc: string
+
+ // Blog
+ readMore: string
+ allArticles: string
+ blog: string
+ postedOn: string
+
+ // Comparison
+ feature: string
+ verdict: string
+
+ // Navigation
+ home: string
+ sendMoney: string
+
+ // Converter
+ convertTitle: string // "Convert {from} to {to}"
+ amount: string
+ liveRate: string
+
+ // Deposit
+ depositFrom: string // "Deposit from {exchange}"
+ recommendedNetwork: string
+ withdrawalFee: string
+ processingTime: string
+ troubleshooting: string
+
+ // Hub
+ hubTitle: string // "Peanut in {country}"
+ hubSubtitle: string // "Everything you need to send, receive, and spend money in {country}."
+ hubSendMoney: string // "Send Money to {country}"
+ hubSendMoneyDesc: string // "Transfer money to {country} with competitive rates."
+ hubConvert: string // "Convert to {currency}"
+ hubConvertDesc: string // "See live rates and convert currencies."
+ hubDeposit: string // "Fund Your Account"
+ hubDepositDesc: string // "Add money from popular exchanges and wallets."
+ hubCompare: string // "Compare Services"
+ hubCompareDesc: string // "See how Peanut compares to other options."
+ hubExploreCountries: string // "Explore other countries"
+ hubInboundCorridors: string // "Send money to {country} from these countries:"
+ hubSendMoneyFrom: string // "Send Money from {country}"
+
+ // From-to corridors
+ sendMoneyFromTo: string // "Send Money from {from} to {to}"
+ sendMoneyFromToDesc: string // "Transfer money from {from} to {to}. Fast, affordable, and secure."
+ fromToContext: string // "Peanut makes it easy to send money from {from} to {to}."
+
+ // Receive money
+ receiveMoneyFrom: string // "Receive Money from {country}"
+ receiveMoneyFromDesc: string // "Get money sent to you from {country}. Fast and secure."
+
+ // Pay with
+ payWith: string // "Pay with {method}"
+ payWithDesc: string // "Use {method} to send and receive money on Peanut."
+
+ // Team
+ teamTitle: string // "Our Team"
+ teamSubtitle: string // "The people behind Peanut."
+
+ // Misc
+ lastUpdated: string // "Last updated: {date}"
+ relatedPages: string // "Related Pages"
+}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 000000000..c9f77ef03
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,95 @@
+import matter from 'gray-matter'
+import { marked } from 'marked'
+import { createHighlighter, type Highlighter } from 'shiki'
+import fs from 'fs'
+import path from 'path'
+
+import type { Locale } from '@/i18n/types'
+
+function getBlogDir(locale: Locale = 'en') {
+ return path.join(process.cwd(), `src/content/blog/${locale}`)
+}
+
+export interface BlogPost {
+ slug: string
+ frontmatter: {
+ title: string
+ description: string
+ date: string
+ category?: string
+ author?: string
+ }
+ content: string
+}
+
+// Singleton highlighter — created once, reused across all posts
+let _highlighter: Highlighter | null = null
+
+async function getHighlighter(): Promise {
+ if (_highlighter) return _highlighter
+ _highlighter = await createHighlighter({
+ themes: ['github-light'],
+ langs: ['javascript', 'typescript', 'bash', 'json', 'yaml', 'html', 'css', 'python', 'solidity'],
+ })
+ return _highlighter
+}
+
+export function getAllPosts(locale: Locale = 'en'): BlogPost[] {
+ const dir = getBlogDir(locale)
+ if (!fs.existsSync(dir)) return []
+
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'))
+ return files
+ .map((file) => {
+ const raw = fs.readFileSync(path.join(dir, file), 'utf8')
+ const { data, content } = matter(raw)
+ return {
+ slug: file.replace('.md', ''),
+ frontmatter: data as BlogPost['frontmatter'],
+ content,
+ }
+ })
+ .sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime())
+}
+
+export async function getPostBySlug(
+ slug: string,
+ locale: Locale = 'en'
+): Promise<{ frontmatter: BlogPost['frontmatter']; html: string } | null> {
+ const filePath = path.join(getBlogDir(locale), `${slug}.md`)
+ if (!fs.existsSync(filePath)) return null
+
+ const raw = fs.readFileSync(filePath, 'utf8')
+ const { data, content } = matter(raw)
+
+ const highlighter = await getHighlighter()
+
+ // Custom renderer for code blocks with shiki syntax highlighting
+ const renderer = new marked.Renderer()
+ renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
+ const language = lang || 'text'
+ try {
+ return highlighter.codeToHtml(text, {
+ lang: language,
+ theme: 'github-light',
+ })
+ } catch {
+ // Fallback for unsupported languages
+ return `${text} `
+ }
+ }
+
+ const html = await marked(content, { renderer }) as string
+
+ return { frontmatter: data as BlogPost['frontmatter'], html }
+}
+
+export function getPostsByCategory(category: string, locale: Locale = 'en'): BlogPost[] {
+ return getAllPosts(locale).filter((p) => p.frontmatter.category === category)
+}
+
+export function getAllCategories(locale: Locale = 'en'): string[] {
+ const posts = getAllPosts(locale)
+ const cats = new Set(posts.map((p) => p.frontmatter.category).filter(Boolean) as string[])
+ return Array.from(cats).sort()
+}
diff --git a/src/lib/content.ts b/src/lib/content.ts
new file mode 100644
index 000000000..5e284fa3d
--- /dev/null
+++ b/src/lib/content.ts
@@ -0,0 +1,69 @@
+// Unified content loader for per-entity content directories.
+// Reads from peanut-content/{countries,exchanges,competitors}// structure.
+// Parses YAML for data files and YAML frontmatter + Markdown body from .md files.
+
+import fs from 'fs'
+import path from 'path'
+import matter from 'gray-matter'
+
+const CONTENT_ROOT = path.join(process.cwd(), 'src/content')
+
+const yaml = matter.engines.yaml
+
+// --- Low-level readers ---
+
+function readYamlFile(filePath: string): T | null {
+ try {
+ return yaml.parse(fs.readFileSync(filePath, 'utf8')) as T
+ } catch {
+ return null
+ }
+}
+
+interface MarkdownContent> {
+ frontmatter: T
+ body: string
+}
+
+function readMarkdownFile>(filePath: string): MarkdownContent | null {
+ try {
+ const raw = fs.readFileSync(filePath, 'utf8')
+ const { data, content } = matter(raw)
+ return { frontmatter: data as T, body: content.trim() }
+ } catch {
+ return null
+ }
+}
+
+// --- Entity directory readers ---
+
+/** Read data.yaml from an entity directory */
+export function readEntitySeo(entityType: string, slug: string): T | null {
+ return readYamlFile(path.join(CONTENT_ROOT, entityType, slug, 'data.yaml'))
+}
+
+/** Read a locale .md file from an entity directory */
+export function readEntityContent>(
+ entityType: string,
+ slug: string,
+ locale: string
+): MarkdownContent | null {
+ return readMarkdownFile(path.join(CONTENT_ROOT, entityType, slug, `${locale}.md`))
+}
+
+/** Read the _index.yaml manifest for an entity type */
+export function readEntityIndex(entityType: string): T | null {
+ return readYamlFile(path.join(CONTENT_ROOT, entityType, '_index.yaml'))
+}
+
+/** List all entity slugs by reading _index.yaml */
+export function listEntitySlugs(entityType: string, key: string): string[] {
+ const index = readEntityIndex>>(entityType)
+ if (!index?.[key]) return []
+ return index[key].map((item) => item.slug)
+}
+
+/** Check if a locale file exists for an entity */
+export function entityLocaleExists(entityType: string, slug: string, locale: string): boolean {
+ return fs.existsSync(path.join(CONTENT_ROOT, entityType, slug, `${locale}.md`))
+}
diff --git a/src/lib/seo/schemas.tsx b/src/lib/seo/schemas.tsx
new file mode 100644
index 000000000..dcecfa0ed
--- /dev/null
+++ b/src/lib/seo/schemas.tsx
@@ -0,0 +1,55 @@
+import { BASE_URL } from '@/constants/general.consts'
+
+const baseUrl = BASE_URL || 'https://peanut.me'
+
+export function faqSchema(faqs: { question: string; answer: string }[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: faqs.map((faq) => ({
+ '@type': 'Question',
+ name: faq.question,
+ acceptedAnswer: {
+ '@type': 'Answer',
+ text: faq.answer,
+ },
+ })),
+ }
+}
+
+export function howToSchema(name: string, description: string, steps: { name: string; text: string }[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name,
+ description,
+ step: steps.map((step, i) => ({
+ '@type': 'HowToStep',
+ position: i + 1,
+ name: step.name,
+ text: step.text,
+ })),
+ }
+}
+
+export function breadcrumbSchema(items: { name: string; url: string }[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: item.name,
+ item: `${baseUrl}${item.url}`,
+ })),
+ }
+}
+
+export function JsonLd({ data }: { data: object }) {
+ return (
+
+ )
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index ce97f85fa..153394850 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -685,3 +685,72 @@ input::placeholder {
z-index: 5;
border-radius: inherit;
}
+
+/* ── Landing page: CSS cloud drift animations ── */
+@keyframes cloud-drift-ltr {
+ from {
+ transform: translateX(-300px);
+ }
+ to {
+ transform: translateX(100vw);
+ }
+}
+
+@keyframes cloud-drift-rtl {
+ from {
+ transform: translateX(100vw);
+ }
+ to {
+ transform: translateX(-300px);
+ }
+}
+
+.cloud-ltr {
+ animation: cloud-drift-ltr var(--cloud-speed, 35s) linear infinite;
+ animation-delay: var(--cloud-delay, 0s);
+}
+
+.cloud-rtl {
+ animation: cloud-drift-rtl var(--cloud-speed, 35s) linear infinite;
+ animation-delay: var(--cloud-delay, 0s);
+}
+
+/* ── Landing page: entrance animations (replaces framer-motion whileInView) ── */
+/* Spring animation approximating framer-motion { type: 'spring', damping: 5, stiffness: 100 }
+ damping:5 is heavily underdamped — large overshoot, multiple visible oscillations.
+ Sampled from spring physics: e^(-ζωt) * cos(ωd*t) with ζ=0.25, ω=10 */
+@keyframes fade-in-up-spring {
+ 0% {
+ opacity: 0;
+ transform: translateY(var(--aov-y, 20px)) translateX(var(--aov-x, 0px)) rotate(var(--aov-rotate, 0deg));
+ }
+ 15% {
+ opacity: 1;
+ transform: translateY(calc(var(--aov-y, 20px) * -0.6)) translateX(calc(var(--aov-x, 0px) * -0.6)) rotate(var(--aov-rotate, 0deg));
+ }
+ 30% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.35)) translateX(calc(var(--aov-x, 0px) * 0.35)) rotate(var(--aov-rotate, 0deg));
+ }
+ 45% {
+ transform: translateY(calc(var(--aov-y, 20px) * -0.2)) translateX(calc(var(--aov-x, 0px) * -0.2)) rotate(var(--aov-rotate, 0deg));
+ }
+ 60% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.1)) translateX(calc(var(--aov-x, 0px) * 0.1)) rotate(var(--aov-rotate, 0deg));
+ }
+ 75% {
+ transform: translateY(calc(var(--aov-y, 20px) * -0.05)) translateX(calc(var(--aov-x, 0px) * -0.05)) rotate(var(--aov-rotate, 0deg));
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) translateX(0) rotate(var(--aov-rotate, 0deg));
+ }
+}
+
+.animate-on-view {
+ opacity: 0;
+}
+
+.animate-on-view.in-view {
+ animation: fade-in-up-spring 1.4s linear forwards;
+ animation-delay: var(--aov-delay, 0s);
+}
From 5c147f954c09b2189a50732231147e5a96523465 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 16 Feb 2026 17:21:16 +0000
Subject: [PATCH 03/61] Add image support and draft/published filtering to
content loaders
- Add optional `image` prop to MarketingHero, pass through from compare/deposit pages
- Add `isPublished()` helper to content.ts, filter drafts in listEntitySlugs()
- Update all 4 entity loaders to skip draft entities
- Corridors filter: both from/to countries must be published
- Update content symlink to track peanut-content changes
---
.../[locale]/(marketing)/compare/[slug]/page.tsx | 1 +
.../(marketing)/deposit/[exchange]/page.tsx | 1 +
src/components/Marketing/MarketingHero.tsx | 11 ++++++++++-
src/constants/routes.ts | 3 ++-
src/data/seo/comparisons.ts | 11 ++++++++---
src/data/seo/corridors.ts | 15 +++++++++++----
src/data/seo/exchanges.ts | 11 ++++++++---
src/data/seo/payment-methods.ts | 5 +++--
src/lib/content.ts | 13 ++++++++++---
9 files changed, 54 insertions(+), 17 deletions(-)
diff --git a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
index 9b7895a0c..6c32ccb18 100644
--- a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
@@ -84,6 +84,7 @@ export default async function ComparisonPageLocalized({ params }: PageProps) {
diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
index 29d903421..e5d05e726 100644
--- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -89,6 +89,7 @@ export default async function DepositPageLocalized({ params }: PageProps) {
diff --git a/src/components/Marketing/MarketingHero.tsx b/src/components/Marketing/MarketingHero.tsx
index 13b436de3..0615a6ff1 100644
--- a/src/components/Marketing/MarketingHero.tsx
+++ b/src/components/Marketing/MarketingHero.tsx
@@ -15,14 +15,23 @@ interface MarketingHeroProps {
subtitle: string
ctaText?: string
ctaHref?: string
+ image?: string
}
-export function MarketingHero({ title, subtitle, ctaText = 'Get Started', ctaHref = '/home' }: MarketingHeroProps) {
+export function MarketingHero({ title, subtitle, ctaText = 'Get Started', ctaHref = '/home', image }: MarketingHeroProps) {
return (
<>
+ {image && (
+
{ e.currentTarget.style.display = 'none' }}
+ />
+ )}
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index eff22ac08..64bff38aa 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -56,7 +56,8 @@ export const DEDICATED_ROUTES = [
'faq',
'how-it-works',
- // Future locale prefixes
+ // Locale prefixes
+ 'en',
'es',
'pt',
] as const
diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts
index c8a596407..2bcbdebc4 100644
--- a/src/data/seo/comparisons.ts
+++ b/src/data/seo/comparisons.ts
@@ -2,7 +2,7 @@
// Reads from per-competitor directories: peanut-content/competitors/
/
// Public API unchanged from the previous monolithic JSON version.
-import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
export interface Competitor {
name: string
@@ -12,6 +12,7 @@ export interface Competitor {
consCompetitor: string[]
verdict: string
faqs: Array<{ q: string; a: string }>
+ image?: string
}
interface CompetitorSeoJson {
@@ -26,11 +27,12 @@ interface CompetitorSeoJson {
interface CompetitorFrontmatter {
title: string
description: string
+ image?: string
faqs: Array<{ q: string; a: string }>
}
interface CompetitorIndex {
- competitors: Array<{ slug: string; name: string; locales: string[] }>
+ competitors: Array<{ slug: string; name: string; status?: string; locales: string[] }>
}
function loadCompetitors(): Record {
@@ -39,7 +41,9 @@ function loadCompetitors(): Record {
const result: Record = {}
- for (const { slug } of index.competitors) {
+ for (const entry of index.competitors) {
+ if (!isPublished(entry)) continue
+ const { slug } = entry
const seo = readEntitySeo('competitors', slug)
const content = readEntityContent('competitors', slug, 'en')
if (!seo || !content) continue
@@ -47,6 +51,7 @@ function loadCompetitors(): Record {
result[slug] = {
...seo,
faqs: content.frontmatter.faqs ?? [],
+ image: content.frontmatter.image,
}
}
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
index 1e72896f6..fabb73afb 100644
--- a/src/data/seo/corridors.ts
+++ b/src/data/seo/corridors.ts
@@ -5,7 +5,7 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
-import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
import type { Locale } from '@/i18n/types'
const yaml = matter.engines.yaml
@@ -41,7 +41,7 @@ interface CountryFrontmatter {
}
interface CountryIndex {
- countries: Array<{ slug: string; region: string; locales: string[] }>
+ countries: Array<{ slug: string; region: string; status?: string; locales: string[] }>
corridors: Corridor[]
}
@@ -51,7 +51,11 @@ function loadAll() {
const countries: Record = {}
- for (const { slug } of index.countries) {
+ const publishedSlugs = new Set(index.countries.filter(isPublished).map((c) => c.slug))
+
+ for (const entry of index.countries) {
+ if (!isPublished(entry)) continue
+ const { slug } = entry
const seo = readEntitySeo('countries', slug)
const content = readEntityContent('countries', slug, 'en')
if (!seo || !content) continue
@@ -65,7 +69,10 @@ function loadAll() {
}
}
- return { countries, corridors: index.corridors }
+ // Only include corridors where both endpoints are published
+ const corridors = index.corridors.filter((c) => publishedSlugs.has(c.from) && publishedSlugs.has(c.to))
+
+ return { countries, corridors }
}
const _loaded = loadAll()
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
index e99c75138..e5a5d8815 100644
--- a/src/data/seo/exchanges.ts
+++ b/src/data/seo/exchanges.ts
@@ -2,7 +2,7 @@
// Reads from per-exchange directories: peanut-content/exchanges//
// Public API unchanged from the previous monolithic JSON version.
-import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
export interface Exchange {
name: string
@@ -14,6 +14,7 @@ export interface Exchange {
steps: string[]
troubleshooting: Array<{ issue: string; fix: string }>
faqs: Array<{ q: string; a: string }>
+ image?: string
}
interface ExchangeSeoJson {
@@ -28,13 +29,14 @@ interface ExchangeSeoJson {
interface ExchangeFrontmatter {
title: string
description: string
+ image?: string
steps: string[]
troubleshooting: Array<{ issue: string; fix: string }>
faqs: Array<{ q: string; a: string }>
}
interface ExchangeIndex {
- exchanges: Array<{ slug: string; name: string; locales: string[] }>
+ exchanges: Array<{ slug: string; name: string; status?: string; locales: string[] }>
}
function loadExchanges(): Record {
@@ -43,7 +45,9 @@ function loadExchanges(): Record {
const result: Record = {}
- for (const { slug } of index.exchanges) {
+ for (const entry of index.exchanges) {
+ if (!isPublished(entry)) continue
+ const { slug } = entry
const seo = readEntitySeo('exchanges', slug)
const content = readEntityContent('exchanges', slug, 'en')
if (!seo || !content) continue
@@ -53,6 +57,7 @@ function loadExchanges(): Record {
steps: content.frontmatter.steps ?? [],
troubleshooting: content.frontmatter.troubleshooting ?? [],
faqs: content.frontmatter.faqs ?? [],
+ image: content.frontmatter.image,
}
}
diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts
index 46ab74a87..133e330b9 100644
--- a/src/data/seo/payment-methods.ts
+++ b/src/data/seo/payment-methods.ts
@@ -1,7 +1,7 @@
// Typed wrapper for payment method data.
// Raw data lives in peanut-content/payment-methods/{slug}/. Types and logic live here.
-import { readEntitySeo, readEntityContent, readEntityIndex } from '@/lib/content'
+import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
export interface PaymentMethod {
slug: string
@@ -25,7 +25,7 @@ interface PaymentMethodFrontmatter {
}
interface PaymentMethodIndex {
- methods: Array<{ slug: string; name: string; locales: string[] }>
+ methods: Array<{ slug: string; name: string; status?: string; locales: string[] }>
}
function loadPaymentMethods(): Record {
@@ -35,6 +35,7 @@ function loadPaymentMethods(): Record {
const result: Record = {}
for (const entry of index.methods) {
+ if (!isPublished(entry)) continue
const data = readEntitySeo('payment-methods', entry.slug)
const content = readEntityContent('payment-methods', entry.slug, 'en')
diff --git a/src/lib/content.ts b/src/lib/content.ts
index 5e284fa3d..05799058f 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -56,11 +56,18 @@ export function readEntityIndex(entityType: string): T | null {
return readYamlFile(path.join(CONTENT_ROOT, entityType, '_index.yaml'))
}
-/** List all entity slugs by reading _index.yaml */
+/** List all entity slugs by reading _index.yaml (published only) */
export function listEntitySlugs(entityType: string, key: string): string[] {
- const index = readEntityIndex>>(entityType)
+ const index = readEntityIndex>>(entityType)
if (!index?.[key]) return []
- return index[key].map((item) => item.slug)
+ return index[key]
+ .filter((item) => (item.status ?? 'published') === 'published')
+ .map((item) => item.slug)
+}
+
+/** Check if an entity is published (missing status = published) */
+export function isPublished(entry: { status?: string }): boolean {
+ return (entry.status ?? 'published') === 'published'
}
/** Check if a locale file exists for an entity */
From e488e6ab8cab7dba513dc87194820aeb61ba4750 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 16 Feb 2026 17:32:21 +0000
Subject: [PATCH 04/61] fix: prettier formatting, dynamicParams, content
caching
- Run prettier on all 60 files flagged by CI
- Add `dynamicParams = false` to all marketing pages + layout
(prevents runtime SSR for unknown params like /fr/blog)
- Add file-level Map cache to content.ts readers
(eliminates redundant fs reads during static generation)
- Extract findMappingBySlug() and fix DS primitive count (from prev)
---
docs/TODO-SEO.md | 21 +++
src/app/(mobile-ui)/dev/components/page.tsx | 1 -
.../dev/ds/_components/CatalogCard.tsx | 2 +-
.../dev/ds/_components/CodeBlock.tsx | 11 +-
.../(mobile-ui)/dev/ds/_components/DoDont.tsx | 7 +-
.../dev/ds/_components/PropsTable.tsx | 4 +-
.../dev/ds/foundations/borders/page.tsx | 30 ++--
.../dev/ds/foundations/colors/page.tsx | 24 ++-
.../dev/ds/foundations/icons/page.tsx | 95 ++++++++--
.../(mobile-ui)/dev/ds/foundations/page.tsx | 86 +++++-----
.../dev/ds/foundations/shadows/page.tsx | 27 +--
.../dev/ds/foundations/spacing/page.tsx | 14 +-
.../dev/ds/foundations/typography/page.tsx | 7 +-
src/app/(mobile-ui)/dev/ds/page.tsx | 114 ++++++------
.../dev/ds/patterns/amount-input/page.tsx | 150 ++++++++++++----
.../dev/ds/patterns/cards-global/page.tsx | 43 +++--
.../dev/ds/patterns/copy-share/page.tsx | 144 ++++++++++++----
.../dev/ds/patterns/drawer/page.tsx | 81 +++++++--
.../dev/ds/patterns/feedback/page.tsx | 162 ++++++++++++++----
.../dev/ds/patterns/layouts/page.tsx | 69 +++++---
.../dev/ds/patterns/loading/page.tsx | 43 +++--
.../dev/ds/patterns/modal/page.tsx | 161 +++++++++++++----
.../dev/ds/patterns/navigation/page.tsx | 112 +++++++++---
src/app/(mobile-ui)/dev/ds/patterns/page.tsx | 144 ++++++++--------
.../dev/ds/primitives/base-input/page.tsx | 38 ++--
.../dev/ds/primitives/base-select/page.tsx | 29 ++--
.../dev/ds/primitives/button/page.tsx | 79 ++++++---
.../dev/ds/primitives/card/page.tsx | 44 +++--
.../dev/ds/primitives/checkbox/page.tsx | 22 +--
.../dev/ds/primitives/divider/page.tsx | 27 ++-
.../dev/ds/primitives/page-container/page.tsx | 15 +-
.../(mobile-ui)/dev/ds/primitives/page.tsx | 142 +++++++--------
.../dev/ds/primitives/title/page.tsx | 36 ++--
.../dev/ds/primitives/toast/page.tsx | 55 ++++--
.../[locale]/(marketing)/[country]/page.tsx | 1 +
.../[locale]/(marketing)/blog/[slug]/page.tsx | 3 +-
.../(marketing)/blog/category/[cat]/page.tsx | 1 +
.../(marketing)/compare/[slug]/page.tsx | 1 +
.../(marketing)/convert/[pair]/page.tsx | 1 +
.../(marketing)/deposit/[exchange]/page.tsx | 7 +-
src/app/[locale]/(marketing)/layout.tsx | 1 +
.../(marketing)/pay-with/[method]/page.tsx | 5 +-
.../receive-money-from/[country]/page.tsx | 1 +
.../send-money-from/[from]/to/[to]/page.tsx | 5 +-
.../send-money-to/[country]/page.tsx | 1 +
.../(marketing)/send-money-to/page.tsx | 5 +-
src/app/[locale]/(marketing)/team/page.tsx | 21 ++-
src/app/layout.tsx | 5 +-
src/app/lp/card/CardLandingPage.tsx | 1 -
src/app/page.tsx | 4 +-
src/app/robots.ts | 9 +-
src/app/sitemap.ts | 30 +++-
src/components/Global/AnimateOnView.tsx | 27 ++-
.../LandingPage/LandingPageClient.tsx | 14 +-
.../LandingPage/SendInSecondsCTA.tsx | 9 +-
src/components/LandingPage/sendInSeconds.tsx | 18 +-
src/components/Marketing/BlogCard.tsx | 5 +-
src/components/Marketing/DestinationGrid.tsx | 5 +-
src/components/Marketing/MarketingHero.tsx | 12 +-
.../Marketing/pages/CorridorPageContent.tsx | 16 +-
.../Marketing/pages/FromToCorridorContent.tsx | 32 ++--
.../Marketing/pages/HubPageContent.tsx | 16 +-
.../Marketing/pages/PayWithContent.tsx | 4 +-
.../Marketing/pages/ReceiveMoneyContent.tsx | 6 +-
src/constants/countryCurrencyMapping.ts | 4 +-
src/lib/blog.ts | 2 +-
src/lib/content.ts | 21 ++-
src/styles/globals.css | 15 +-
68 files changed, 1565 insertions(+), 782 deletions(-)
diff --git a/docs/TODO-SEO.md b/docs/TODO-SEO.md
index e7114ec89..d99378cc2 100644
--- a/docs/TODO-SEO.md
+++ b/docs/TODO-SEO.md
@@ -25,6 +25,27 @@ Fill in real team member data in `src/data/team.ts`:
## Engineering Tasks
+### Content Submodule Migration (BLOCKING — do first)
+CI build fails because `src/content` is a local symlink. Once `0xkkonrad/peanut` PR #1
+(`reorganize-for-peanut-ui`) merges to `master`, run these steps:
+```bash
+# 1. Remove the symlink from git
+git rm --cached src/content
+rm src/content
+
+# 2. Add as submodule (same pattern as src/assets/animations)
+git submodule add https://github.com/0xkkonrad/peanut.git src/content
+
+# 3. Verify structure matches what code expects
+ls src/content/countries/argentina/ # should have data.yaml + en.md
+
+# 4. Commit and push
+git add .gitmodules src/content
+git commit -m "chore: add peanut-content as git submodule"
+git push
+```
+CI already has `submodules: true` in `.github/workflows/tests.yml`, so it will fetch automatically.
+
### Scroll-Depth CTAs
TODO: Add mid-content CTA cards on long editorial pages (blog posts, corridor pages).
Trigger: Insert CTA after 50% scroll or after the 3rd section.
diff --git a/src/app/(mobile-ui)/dev/components/page.tsx b/src/app/(mobile-ui)/dev/components/page.tsx
index b87cb0970..edac175e3 100644
--- a/src/app/(mobile-ui)/dev/components/page.tsx
+++ b/src/app/(mobile-ui)/dev/components/page.tsx
@@ -327,7 +327,6 @@ export default function ComponentsPage() {
Label`} />
-
diff --git a/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx b/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
index 7a586e668..17d29d2bc 100644
--- a/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
+++ b/src/app/(mobile-ui)/dev/ds/_components/CatalogCard.tsx
@@ -18,7 +18,7 @@ interface CatalogCardProps {
export function CatalogCard({ title, description, href, icon, status, quality, usages }: CatalogCardProps) {
return (
-
+
{icon && (
diff --git a/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx b/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
index 6573bac84..be340465e 100644
--- a/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
+++ b/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
@@ -23,18 +23,13 @@ export function CodeBlock({ code, label, language = 'tsx' }: CodeBlockProps) {
return (
- {label && (
- {label}
- )}
-
+ {label && {label} }
+
{copied ? : }
diff --git a/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx b/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
index a6f3565d4..8de7cb3f9 100644
--- a/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
+++ b/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
@@ -7,12 +7,7 @@ interface DoDontProps {
dontLabel?: string
}
-export function DoDont({
- doExample,
- doLabel = 'Do',
- dontExample,
- dontLabel = "Don't",
-}: DoDontProps) {
+export function DoDont({ doExample, doLabel = 'Do', dontExample, dontLabel = "Don't" }: DoDontProps) {
return (
diff --git a/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx b/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
index e1df83e4f..217a0ca37 100644
--- a/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
+++ b/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
@@ -36,9 +36,7 @@ export function PropsTable({ rows }: { rows: PropsTableRow[] }) {
{row.type}
{row.default}
{row.description && (
-
- {row.description}
-
+
{row.description}
)}
))}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
index 1e645955d..de94424e1 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
@@ -13,7 +13,8 @@ export default function BordersPage() {
{/* Border radius */}
- Always use rounded-sm. This is the standard across all components.
+ Always use rounded-sm. This is the standard
+ across all components.
@@ -42,24 +43,24 @@ export default function BordersPage() {
2px solid black. For emphasis.
-
border border-n-1/20
-
Subtle border. For code snippets, secondary containers.
+
+ border border-n-1/20
+
+
+ Subtle border. For code snippets, secondary containers.
+
-
border-dashed border-n-1/30
+
+ border-dashed border-n-1/30
+
Dashed border. For drop zones, placeholders.
-
-
+
+
@@ -68,10 +69,7 @@ export default function BordersPage() {
{['label-stroke', 'label-purple', 'label-yellow', 'label-black', 'label-teal'].map((cls) => (
-
+
{cls.replace('label-', '')}
))}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
index e3243d920..3452e1d5d 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
@@ -39,10 +39,14 @@ export default function ColorsPage() {
return (
-
+
- purple-1 / primary-1 = #FF90E8 — this is PINK, not purple. The naming is misleading but too widely used to rename.
+ purple-1 / primary-1 = #FF90E8 — this is PINK, not purple. The naming is misleading but too widely used
+ to rename.
{/* Color grid */}
@@ -64,7 +68,11 @@ export default function ColorsPage() {
{copiedColor === color.bg ? (
) : (
-
+
)}
))}
@@ -93,7 +101,9 @@ export default function ColorsPage() {
- Inline links: always use text-black underline — never text-purple-1.
+ Inline links: always use{' '}
+ text-black underline — never
+ text-purple-1.
@@ -101,11 +111,7 @@ export default function ColorsPage() {
{BACKGROUNDS.map((bg) => (
-
copyClass(bg.name)}
- className="w-full text-left"
- >
+ copyClass(bg.name)} className="w-full text-left">
.{bg.name}
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
index 0d522b067..0bf1f13e2 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
@@ -8,26 +8,88 @@ import { DocPage } from '../../_components/DocPage'
import { CodeBlock } from '../../_components/CodeBlock'
const ALL_ICONS: IconName[] = [
- 'alert', 'alert-filled', 'arrow-down', 'arrow-down-left', 'arrow-up', 'arrow-up-right',
- 'arrow-exchange', 'badge', 'bank', 'bell', 'bulb', 'camera', 'camera-flip', 'cancel',
- 'check', 'check-circle', 'chevron-up', 'chevron-down', 'clip', 'clock', 'copy', 'currency',
- 'docs', 'dollar', 'double-check', 'download', 'error', 'exchange', 'external-link',
- 'eye', 'eye-slash', 'failed', 'fees', 'gift', 'globe-lock', 'history', 'home',
- 'info', 'info-filled', 'invite-heart', 'link', 'link-slash', 'lock', 'logout', 'meter',
- 'minus-circle', 'mobile-install', 'paperclip', 'paste', 'peanut-support', 'pending',
- 'plus', 'plus-circle', 'processing', 'qr-code', 'question-mark', 'retry', 'search',
- 'share', 'shield', 'smile', 'split', 'star', 'success', 'switch', 'trophy',
- 'txn-off', 'upload-cloud', 'user', 'user-id', 'user-plus', 'wallet', 'wallet-cancel',
- 'wallet-outline', 'achievements',
+ 'alert',
+ 'alert-filled',
+ 'arrow-down',
+ 'arrow-down-left',
+ 'arrow-up',
+ 'arrow-up-right',
+ 'arrow-exchange',
+ 'badge',
+ 'bank',
+ 'bell',
+ 'bulb',
+ 'camera',
+ 'camera-flip',
+ 'cancel',
+ 'check',
+ 'check-circle',
+ 'chevron-up',
+ 'chevron-down',
+ 'clip',
+ 'clock',
+ 'copy',
+ 'currency',
+ 'docs',
+ 'dollar',
+ 'double-check',
+ 'download',
+ 'error',
+ 'exchange',
+ 'external-link',
+ 'eye',
+ 'eye-slash',
+ 'failed',
+ 'fees',
+ 'gift',
+ 'globe-lock',
+ 'history',
+ 'home',
+ 'info',
+ 'info-filled',
+ 'invite-heart',
+ 'link',
+ 'link-slash',
+ 'lock',
+ 'logout',
+ 'meter',
+ 'minus-circle',
+ 'mobile-install',
+ 'paperclip',
+ 'paste',
+ 'peanut-support',
+ 'pending',
+ 'plus',
+ 'plus-circle',
+ 'processing',
+ 'qr-code',
+ 'question-mark',
+ 'retry',
+ 'search',
+ 'share',
+ 'shield',
+ 'smile',
+ 'split',
+ 'star',
+ 'success',
+ 'switch',
+ 'trophy',
+ 'txn-off',
+ 'upload-cloud',
+ 'user',
+ 'user-id',
+ 'user-plus',
+ 'wallet',
+ 'wallet-cancel',
+ 'wallet-outline',
+ 'achievements',
]
export default function IconsPage() {
const [search, setSearch] = useState('')
const [copiedIcon, setCopiedIcon] = useState(null)
- const filtered = search
- ? ALL_ICONS.filter((name) => name.includes(search.toLowerCase()))
- : ALL_ICONS
+ const filtered = search ? ALL_ICONS.filter((name) => name.includes(search.toLowerCase())) : ALL_ICONS
const copyIcon = (name: string) => {
navigator.clipboard.writeText(name)
@@ -37,7 +99,10 @@ export default function IconsPage() {
return (
-
+
{/* Search */}
-
-
-
-
-
-
+
+
+
+
+
+
)
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
index b9074fef7..bda11777a 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
@@ -11,7 +11,10 @@ import { CodeBlock } from '../../_components/CodeBlock'
export default function ShadowsPage() {
return (
-
+
shadowSize="4" has 160+ usages. It is the standard. All others are negligible.
@@ -23,19 +26,24 @@ export default function ShadowsPage() {
{(['3', '4', '6', '8'] as const).map((s) => (
-
shadow {s}
+
+ shadow {s}
+
- {s === '4' ? '160 usages' : s === '3' ? '2 usages' : s === '6' ? '1 usage' : '1 usage'}
+ {s === '4'
+ ? '160 usages'
+ : s === '3'
+ ? '2 usages'
+ : s === '6'
+ ? '1 usage'
+ : '1 usage'}
))}
- Label `}
- />
+ Label `} />
@@ -51,10 +59,7 @@ export default function ShadowsPage() {
- content`}
- />
+ content`} />
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
index 478517aa4..167aba3d0 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
@@ -69,9 +69,17 @@ export default function SpacingPage() {
{/* Page padding */}
-
Standard page content padding: px-4 (16px)
-
Card internal padding: p-4 (16px) or p-6 (24px)
-
Section spacing: space-y-6 or gap-6
+
+ Standard page content padding: px-4 (16px)
+
+
+ Card internal padding: p-4 (16px) or{' '}
+ p-6 (24px)
+
+
+ Section spacing: space-y-6 or{' '}
+ gap-6
+
diff --git a/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx b/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
index 8d6636ed0..b16cbf5ae 100644
--- a/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
@@ -48,7 +48,9 @@ export default function TypographyPage() {
-
Display font with filled+outline double-render effect.
+
+ Display font with filled+outline double-render effect.
+
@@ -74,7 +76,8 @@ export default function TypographyPage() {
))}
- font-bold dominates (304 usages). Use font-bold for labels and headings, font-medium for secondary text.
+ font-bold dominates (304 usages). Use font-bold for labels and headings, font-medium for secondary
+ text.
diff --git a/src/app/(mobile-ui)/dev/ds/page.tsx b/src/app/(mobile-ui)/dev/ds/page.tsx
index cc0b712eb..b9e5f57ff 100644
--- a/src/app/(mobile-ui)/dev/ds/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/page.tsx
@@ -42,62 +42,74 @@ export default function DesignSystemPage() {
{/* Hero */}
-
-
Design System
-
- Foundations → Primitives → Patterns → Playground
-
-
+
+ Design System
+ Foundations → Primitives → Patterns → Playground
+
- {/* Quick stats */}
-
- {[
- { label: 'Primitives', value: '9' },
- { label: 'Global', value: '70+' },
- { label: 'Icons', value: '85+' },
- ].map((stat) => (
-
-
{stat.value}
-
{stat.label}
-
- ))}
-
+ {/* Quick stats */}
+
+ {[
+ { label: 'Primitives', value: '9' },
+ { label: 'Global', value: '70+' },
+ { label: 'Icons', value: '85+' },
+ ].map((stat) => (
+
+
{stat.value}
+
{stat.label}
+
+ ))}
+
- {/* Section cards */}
-
- {sections.map((section) => (
-
-
-
-
-
-
-
-
-
{section.title}
-
- {section.count}
-
+ {/* Section cards */}
+
+ {sections.map((section) => (
+
+
+
+
+
+
+
+
+
{section.title}
+
+ {section.count}
+
+
+
{section.description}
-
{section.description}
+
-
-
-
-
- ))}
-
+
+
+ ))}
+
- {/* Design rules quick reference */}
-
-
Quick Rules
-
- Primary CTA: variant="purple" shadowSize="4" w-full
- Links: text-black underline — never text-purple-1
- purple-1 is pink (#FF90E8), not purple
- size="large" is h-10 (shorter than default h-13)
-
-
+ {/* Design rules quick reference */}
+
+
Quick Rules
+
+
+ Primary CTA:{' '}
+
+ variant="purple" shadowSize="4" w-full
+
+
+
+ Links: text-black underline{' '}
+ — never text-purple-1
+
+
+ purple-1 is pink
+ (#FF90E8), not purple
+
+ size="large" is h-10 (shorter than default h-13)
+
+
)
}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
index d567be3bc..3b4727980 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
@@ -36,38 +36,46 @@ export default function AmountInputPage() {
≈ ETH 0.00
Balance: $ 42.50
-
-
+
- The input uses a transparent background with auto-sizing width. A fake blinking caret (primary-1 color)
- shows when the input is empty and not focused.
+ The input uses a transparent background with auto-sizing width. A fake blinking caret (primary-1
+ color) shows when the input is empty and not focused.
- `} />
+/>`}
+ />
- `} />
+/>`}
+ />
- `} />
+/>`}
+ />
@@ -77,27 +85,108 @@ export default function AmountInputPage() {
void', default: '-', required: true, description: 'Callback for the primary denomination amount' },
- { name: 'primaryDenomination', type: '{ symbol, price, decimals }', default: "{ symbol: '$', price: 1, decimals: 2 }", description: 'Primary currency config' },
- { name: 'secondaryDenomination', type: '{ symbol, price, decimals }', default: '(none)', description: 'Enables currency toggle when provided' },
- { name: 'setSecondaryAmount', type: '(value: string) => void', default: '(none)', description: 'Callback for converted amount' },
- { name: 'setDisplayedAmount', type: '(value: string) => void', default: '(none)', description: 'Callback for the currently displayed value' },
- { name: 'setCurrentDenomination', type: '(denomination: string) => void', default: '(none)', description: 'Reports which denomination is active' },
+ {
+ name: 'setPrimaryAmount',
+ type: '(value: string) => void',
+ default: '-',
+ required: true,
+ description: 'Callback for the primary denomination amount',
+ },
+ {
+ name: 'primaryDenomination',
+ type: '{ symbol, price, decimals }',
+ default: "{ symbol: '$', price: 1, decimals: 2 }",
+ description: 'Primary currency config',
+ },
+ {
+ name: 'secondaryDenomination',
+ type: '{ symbol, price, decimals }',
+ default: '(none)',
+ description: 'Enables currency toggle when provided',
+ },
+ {
+ name: 'setSecondaryAmount',
+ type: '(value: string) => void',
+ default: '(none)',
+ description: 'Callback for converted amount',
+ },
+ {
+ name: 'setDisplayedAmount',
+ type: '(value: string) => void',
+ default: '(none)',
+ description: 'Callback for the currently displayed value',
+ },
+ {
+ name: 'setCurrentDenomination',
+ type: '(denomination: string) => void',
+ default: '(none)',
+ description: 'Reports which denomination is active',
+ },
{ name: 'initialAmount', type: 'string', default: "''", description: 'Pre-fill amount' },
- { name: 'initialDenomination', type: 'string', default: '(none)', description: 'Pre-select denomination' },
- { name: 'walletBalance', type: 'string', default: '(none)', description: 'Formatted balance to display' },
- { name: 'hideBalance', type: 'boolean', default: 'false', description: 'Hide the balance line' },
- { name: 'hideCurrencyToggle', type: 'boolean', default: 'false', description: 'Hide the swap icon even with secondary denomination' },
+ {
+ name: 'initialDenomination',
+ type: 'string',
+ default: '(none)',
+ description: 'Pre-select denomination',
+ },
+ {
+ name: 'walletBalance',
+ type: 'string',
+ default: '(none)',
+ description: 'Formatted balance to display',
+ },
+ {
+ name: 'hideBalance',
+ type: 'boolean',
+ default: 'false',
+ description: 'Hide the balance line',
+ },
+ {
+ name: 'hideCurrencyToggle',
+ type: 'boolean',
+ default: 'false',
+ description: 'Hide the swap icon even with secondary denomination',
+ },
{ name: 'disabled', type: 'boolean', default: 'false', description: 'Disable input' },
{ name: 'onSubmit', type: '() => void', default: '(none)', description: 'Enter key handler' },
{ name: 'onBlur', type: '() => void', default: '(none)', description: 'Blur handler' },
- { name: 'showSlider', type: 'boolean', default: 'false', description: 'Show percentage slider below input' },
+ {
+ name: 'showSlider',
+ type: 'boolean',
+ default: 'false',
+ description: 'Show percentage slider below input',
+ },
{ name: 'maxAmount', type: 'number', default: '(none)', description: 'Slider max value' },
- { name: 'amountCollected', type: 'number', default: '0', description: 'Already collected (for pot snap logic)' },
- { name: 'defaultSliderValue', type: 'number', default: '(none)', description: 'Initial slider percentage' },
- { name: 'defaultSliderSuggestedAmount', type: 'number', default: '(none)', description: 'Suggested amount to pre-fill' },
- { name: 'infoContent', type: 'ReactNode', default: '(none)', description: 'Content below the input area' },
- { name: 'className', type: 'string', default: "''", description: 'Override form container styles' },
+ {
+ name: 'amountCollected',
+ type: 'number',
+ default: '0',
+ description: 'Already collected (for pot snap logic)',
+ },
+ {
+ name: 'defaultSliderValue',
+ type: 'number',
+ default: '(none)',
+ description: 'Initial slider percentage',
+ },
+ {
+ name: 'defaultSliderSuggestedAmount',
+ type: 'number',
+ default: '(none)',
+ description: 'Suggested amount to pre-fill',
+ },
+ {
+ name: 'infoContent',
+ type: 'ReactNode',
+ default: '(none)',
+ description: 'Content below the input area',
+ },
+ {
+ name: 'className',
+ type: 'string',
+ default: "''",
+ description: 'Override form container styles',
+ },
]}
/>
@@ -105,16 +194,17 @@ export default function AmountInputPage() {
{/* Architecture Notes */}
- Internally uses exactValue (scaled by 10^18) for precise integer arithmetic during currency conversion.
- Display values are formatted separately from calculation values to avoid precision loss.
+ Internally uses exactValue (scaled by 10^18) for precise integer arithmetic during currency
+ conversion. Display values are formatted separately from calculation values to avoid precision loss.
The component auto-focuses on desktop (DeviceType.WEB) but not on mobile to avoid keyboard popup.
Input width auto-sizes based on character count (ch units).
- The slider has a 33.33% "magnetic snap point" that snaps to the remaining pot amount. This is specific
- to the pot/group-pay use case and ideally should not be baked into the generic component.
+ The slider has a 33.33% "magnetic snap point" that snaps to the remaining pot amount. This
+ is specific to the pot/group-pay use case and ideally should not be baked into the generic
+ component.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
index fd04b97a4..99be830d4 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
@@ -21,8 +21,8 @@ export default function CardsGlobalPage() {
{/* Import */}
- This is the default export from Global/Card. The Bruddle Card is a named export:
- import {'{ Card }'} from '@/components/0_Bruddle/Card'. They are different components.
+ This is the default export from Global/Card. The Bruddle Card is a named export: import {'{ Card }'}{' '}
+ from '@/components/0_Bruddle/Card'. They are different components.
@@ -39,9 +39,12 @@ export default function CardsGlobalPage() {
-
+
Content
- `} />
+ `}
+ />
@@ -50,8 +53,8 @@ export default function CardsGlobalPage() {
Cards stack seamlessly by using position props: first, middle, last. Only the first card has top
- border-radius, only the last has bottom, and middle cards have no border-radius. Border-top is removed
- on middle and last to avoid double borders.
+ border-radius, only the last has bottom, and middle cards have no border-radius. Border-top is
+ removed on middle and last to avoid double borders.
@@ -66,7 +69,9 @@ export default function CardsGlobalPage() {
- {
+ {
const position =
items.length === 1 ? 'single' :
index === 0 ? 'first' :
@@ -78,7 +83,8 @@ export default function CardsGlobalPage() {
{/* Item content */}
)
-})}`} />
+})}`}
+ />
@@ -101,9 +107,12 @@ export default function CardsGlobalPage() {
- router.push('/detail')}>
+ router.push('/detail')}>
Clickable card content
-`} />
+`}
+ />
@@ -127,10 +136,20 @@ export default function CardsGlobalPage() {
void', default: '(none)', description: 'Makes card clickable' },
- { name: 'className', type: 'string', default: "''", description: 'Override styles (base: w-full bg-white px-4 py-2)' },
+ {
+ name: 'className',
+ type: 'string',
+ default: "''",
+ description: 'Override styles (base: w-full bg-white px-4 py-2)',
+ },
{ name: 'children', type: 'ReactNode', default: '-', required: true },
{ name: 'ref', type: 'Ref', default: '(none)' },
]}
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
index 6fc113c2e..970bf8caf 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
@@ -35,19 +35,43 @@ export default function CopySharePage() {
void', default: '(none)', description: 'Handler when clicking disabled button' },
+ {
+ name: 'onDisabledClick',
+ type: '() => void',
+ default: '(none)',
+ description: 'Handler when clicking disabled button',
+ },
]}
/>
-
- `} />
+
+ `}
+ />
@@ -55,8 +79,8 @@ export default function CopySharePage() {
- Icon-only or button-style copy trigger. Shows check icon for 2 seconds after copying.
- Supports imperative copy via ref.
+ Icon-only or button-style copy trigger. Shows check icon for 2 seconds after copying. Supports
+ imperative copy via ref.
@@ -79,19 +103,45 @@ export default function CopySharePage() {
-
+
-
{/* Button */}
@@ -100,7 +150,8 @@ export default function CopySharePage() {
{/* Imperative */}
const copyRef = useRef(null)
-copyRef.current?.copy()`} />
+copyRef.current?.copy()`}
+ />
@@ -108,12 +159,14 @@ copyRef.current?.copy()`} />
- Reference only. Uses the Web Share API (navigator.share) with clipboard fallback.
- Typically composed inline rather than imported as a standalone component.
+ Reference only. Uses the Web Share API (navigator.share) with clipboard fallback. Typically
+ composed inline rather than imported as a standalone component.
- {
@@ -125,7 +178,8 @@ copyRef.current?.copy()`} />
}}
>
Share
-`} />
+`}
+ />
@@ -133,8 +187,8 @@ copyRef.current?.copy()`} />
- Displays a shortened crypto address as a link. Resolves ENS names for Ethereum addresses.
- Links to the user profile page.
+ Displays a shortened crypto address as a link. Resolves ENS names for Ethereum addresses. Links
+ to the user profile page.
@@ -144,8 +198,19 @@ copyRef.current?.copy()`} />
@@ -153,8 +218,11 @@ copyRef.current?.copy()`} />
-
- `} />
+
+ `}
+ />
@@ -173,16 +241,30 @@ copyRef.current?.copy()`} />
- Network fee
- `} />
+ Network fee
+ `}
+ />
@@ -191,8 +273,8 @@ copyRef.current?.copy()`} />
{/* Design Notes */}
- CopyField for displaying + copying full strings (links, codes). CopyToClipboard for inline copy icons
- next to existing text.
+ CopyField for displaying + copying full strings (links, codes). CopyToClipboard for inline copy
+ icons next to existing text.
MoreInfo tooltip is portaled to document.body and auto-positions to avoid viewport edges. Preferred
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
index 4da08dcfe..b2eeb3a1d 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
@@ -1,7 +1,16 @@
'use client'
import { Button } from '@/components/0_Bruddle/Button'
-import { Drawer, DrawerContent, DrawerTitle, DrawerTrigger, DrawerHeader, DrawerFooter, DrawerDescription, DrawerClose } from '@/components/Global/Drawer'
+import {
+ Drawer,
+ DrawerContent,
+ DrawerTitle,
+ DrawerTrigger,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerDescription,
+ DrawerClose,
+} from '@/components/Global/Drawer'
import { PropsTable } from '../../_components/PropsTable'
import { DesignNote } from '../../_components/DesignNote'
import { DocHeader } from '../../_components/DocHeader'
@@ -29,7 +38,9 @@ export default function DrawerPage() {
Example Drawer
- This is a vaul-based bottom sheet. Swipe down to dismiss.
+
+ This is a vaul-based bottom sheet. Swipe down to dismiss.
+
@@ -48,7 +59,9 @@ export default function DrawerPage() {
-
+} from '@/components/Global/Drawer'`}
+ />
-
+
Open
@@ -79,15 +95,19 @@ export default function DrawerPage() {
-`} />
+`}
+ />
-
{/* Content */}
-`} />
+`}
+ />
@@ -97,14 +117,44 @@ export default function DrawerPage() {
@@ -119,7 +169,8 @@ export default function DrawerPage() {
rounded bar at the top.
- Content is capped at max-h-[80vh] with overflow-auto. For long lists, scrolling works inside the drawer.
+ Content is capped at max-h-[80vh] with overflow-auto. For long lists, scrolling works inside the
+ drawer.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
index aa32ea232..2a1f73973 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
@@ -14,7 +14,16 @@ import { SectionDivider } from '../../_components/SectionDivider'
import { DocPage } from '../../_components/DocPage'
import { CodeBlock } from '../../_components/CodeBlock'
-const allStatuses: StatusType[] = ['completed', 'pending', 'processing', 'failed', 'cancelled', 'refunded', 'soon', 'closed']
+const allStatuses: StatusType[] = [
+ 'completed',
+ 'pending',
+ 'processing',
+ 'failed',
+ 'cancelled',
+ 'refunded',
+ 'soon',
+ 'closed',
+]
export default function FeedbackPage() {
return (
@@ -57,19 +66,37 @@ export default function FeedbackPage() {
-
+
-
+
- `} />
+ `}
+ />
@@ -77,25 +104,33 @@ export default function FeedbackPage() {
- Tiny 14px circular icon indicator. Uses the same StatusType as StatusBadge (minus "custom").
- Pairs well with list items.
+ Tiny 14px circular icon indicator. Uses the same StatusType as StatusBadge (minus
+ "custom"). Pairs well with list items.
All Status Types
- {allStatuses.filter((s): s is StatusPillType => s !== 'custom').map((status) => (
-
-
- {status}
-
- ))}
+ {allStatuses
+ .filter((s): s is StatusPillType => s !== 'custom')
+ .map((status) => (
+
+
+ {status}
+
+ ))}
@@ -119,17 +154,36 @@ export default function FeedbackPage() {
- `} />
+ `}
+ />
@@ -137,7 +191,8 @@ export default function FeedbackPage() {
- Card-based empty state with icon, title, description, and optional CTA. Uses Global Card internally.
+ Card-based empty state with icon, title, description, and optional CTA. Uses Global Card
+ internally.
@@ -152,32 +207,50 @@ export default function FeedbackPage() {
}
/>
-
+
-
+
- Send Money}
-/>`} />
+/>`}
+ />
@@ -194,14 +267,28 @@ export default function FeedbackPage() {
-
+
`} />
@@ -212,11 +299,12 @@ export default function FeedbackPage() {
{/* Design Notes */}
- StatusBadge for text labels in tables/lists. StatusPill for compact icon-only indicators next to items.
+ StatusBadge for text labels in tables/lists. StatusPill for compact icon-only indicators next to
+ items.
- Use EmptyState (card-based, icon) for structured empty states inside content areas.
- Use NoDataEmptyState (Peanutman GIF) for full-section "no data" states.
+ Use EmptyState (card-based, icon) for structured empty states inside content areas. Use
+ NoDataEmptyState (Peanutman GIF) for full-section "no data" states.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
index 7e23ac9dd..32e70b78b 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
@@ -21,8 +21,8 @@ export default function LayoutsPage() {
- Content vertically centered in viewport, CTA button pinned to the bottom.
- Used for: claim pages, success states, amount input, confirmations.
+ Content vertically centered in viewport, CTA button pinned to the bottom. Used for: claim pages,
+ success states, amount input, confirmations.
{/* Wireframe */}
@@ -48,7 +48,9 @@ export default function LayoutsPage() {
-
+
{/* Centered content */}
@@ -62,7 +64,8 @@ export default function LayoutsPage() {
Continue
- `} />
+ `}
+ />
@@ -99,7 +102,9 @@ export default function LayoutsPage() {
-
+
{/* Top-aligned content */}
@@ -115,7 +120,8 @@ export default function LayoutsPage() {
Save Changes
- `} />
+ `}
+ />
@@ -148,7 +154,9 @@ export default function LayoutsPage() {
-
+
{/* Fixed search bar */}
@@ -164,7 +172,8 @@ export default function LayoutsPage() {
))}
- `} />
+
`}
+ />
@@ -180,8 +189,8 @@ export default function LayoutsPage() {
Wrong
- Without h-full the flex container collapses to content height. The CTA sits right below content
- instead of at the bottom.
+ Without h-full the flex container collapses to content height. The CTA sits right below
+ content instead of at the bottom.
@@ -192,8 +201,8 @@ export default function LayoutsPage() {
Correct
- h-full ensures the flex column fills the available height from PageContainer. flex-1 on the content
- area pushes the CTA to the bottom.
+ h-full ensures the flex column fills the available height from PageContainer. flex-1 on the
+ content area pushes the CTA to the bottom.
@@ -204,8 +213,8 @@ export default function LayoutsPage() {
Wrong
- overflow-y-auto alone does nothing unless the element has a bounded height. Use flex-1 inside a
- flex-col container, or set an explicit max-height.
+ overflow-y-auto alone does nothing unless the element has a bounded height. Use flex-1
+ inside a flex-col container, or set an explicit max-height.
@@ -216,35 +225,47 @@ export default function LayoutsPage() {
Correct
- Inside a flex column with h-full, flex-1 fills remaining space and provides the bounded height
- that overflow-y-auto needs to actually scroll.
+ Inside a flex column with h-full, flex-1 fills remaining space and provides the bounded
+ height that overflow-y-auto needs to actually scroll.
-
Content
Submit
-`} />
+`}
+ />
-
Content
Submit
-`} />
+`}
+ />
-
{items.map(...)}
-`} />
+`}
+ />
-
{items.map(...)}
-`} />
+`}
+ />
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
index c48e915f0..3929ad9ef 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
@@ -54,15 +54,23 @@ export default function LoadingPage() {
- {/* default 16px */}
- {/* 32px */}`} />
+ {/* default 16px */}
+ {/* 32px */}`}
+ />
@@ -90,19 +98,32 @@ export default function LoadingPage() {
-
{/* Full screen overlay */}
- `} />
+ `}
+ />
@@ -111,12 +132,12 @@ export default function LoadingPage() {
{/* Design Notes */}
- Use Loading (CSS spinner) inside buttons, inline indicators, and small containers. Use PeanutLoading for
- page-level or section-level loading states where brand presence matters.
+ Use Loading (CSS spinner) inside buttons, inline indicators, and small containers. Use PeanutLoading
+ for page-level or section-level loading states where brand presence matters.
- PeanutLoading with coverFullScreen renders a fixed z-50 overlay. Make sure to conditionally render it
- only when loading is active to avoid blocking the UI.
+ PeanutLoading with coverFullScreen renders a fixed z-50 overlay. Make sure to conditionally render
+ it only when loading is active to avoid blocking the UI.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
index 0948f89ef..e16c79855 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
@@ -43,7 +43,12 @@ export default function ModalPage() {
optional title bar. You supply the children.
- setShowModal(false)}>
+ setShowModal(false)}
+ >
Got it
@@ -53,16 +58,63 @@ export default function ModalPage() {
void', default: '-', required: true, description: 'Called when overlay or close button clicked' },
- { name: 'title', type: 'string', default: '(none)', description: 'Renders title bar with border' },
- { name: 'className', type: 'string', default: "''", description: 'Class for the Dialog root' },
+ {
+ name: 'visible',
+ type: 'boolean',
+ default: '-',
+ required: true,
+ description: 'Controls modal visibility',
+ },
+ {
+ name: 'onClose',
+ type: '() => void',
+ default: '-',
+ required: true,
+ description: 'Called when overlay or close button clicked',
+ },
+ {
+ name: 'title',
+ type: 'string',
+ default: '(none)',
+ description: 'Renders title bar with border',
+ },
+ {
+ name: 'className',
+ type: 'string',
+ default: "''",
+ description: 'Class for the Dialog root',
+ },
{ name: 'classWrap', type: 'string', default: "''", description: 'Class for Dialog.Panel' },
- { name: 'classOverlay', type: 'string', default: "''", description: 'Class for the backdrop overlay' },
- { name: 'classButtonClose', type: 'string', default: "''", description: 'Class for the close button' },
- { name: 'preventClose', type: 'boolean', default: 'false', description: 'Disables closing via overlay click' },
- { name: 'hideOverlay', type: 'boolean', default: 'false', description: 'Hides close button and title, renders children directly' },
- { name: 'video', type: 'boolean', default: 'false', description: 'Aspect-ratio video mode' },
+ {
+ name: 'classOverlay',
+ type: 'string',
+ default: "''",
+ description: 'Class for the backdrop overlay',
+ },
+ {
+ name: 'classButtonClose',
+ type: 'string',
+ default: "''",
+ description: 'Class for the close button',
+ },
+ {
+ name: 'preventClose',
+ type: 'boolean',
+ default: 'false',
+ description: 'Disables closing via overlay click',
+ },
+ {
+ name: 'hideOverlay',
+ type: 'boolean',
+ default: 'false',
+ description: 'Hides close button and title, renders children directly',
+ },
+ {
+ name: 'video',
+ type: 'boolean',
+ default: 'false',
+ description: 'Aspect-ratio video mode',
+ },
{ name: 'children', type: 'ReactNode', default: '-', required: true },
]}
/>
@@ -70,11 +122,14 @@ export default function ModalPage() {
- setVisible(false)} title="Example">
+ setVisible(false)} title="Example">
{/* Your content */}
-`} />
+`}
+ />
@@ -82,8 +137,8 @@ export default function ModalPage() {
- Pre-composed modal with icon, title, description, CTA buttons, and optional checkbox. Built on top of
- Base Modal.
+ Pre-composed modal with icon, title, description, CTA buttons, and optional checkbox. Built on
+ top of Base Modal.
@@ -131,15 +186,60 @@ export default function ModalPage() {
{ name: 'visible', type: 'boolean', default: '-', required: true },
{ name: 'onClose', type: '() => void', default: '-', required: true },
{ name: 'title', type: 'string | ReactNode', default: '-', required: true },
- { name: 'description', type: 'string | ReactNode', default: '(none)', description: 'Subtitle text' },
- { name: 'icon', type: 'IconName | ReactNode', default: '(none)', description: 'Displayed in pink circle above title' },
- { name: 'iconProps', type: 'Partial
', default: '(none)', description: 'Override icon size/color' },
- { name: 'isLoadingIcon', type: 'boolean', default: 'false', description: 'Replace icon with spinner' },
- { name: 'ctas', type: 'ActionModalButtonProps[]', default: '[]', description: 'Array of {text, variant, onClick, ...ButtonProps}' },
- { name: 'checkbox', type: 'ActionModalCheckboxProps', default: '(none)', description: '{text, checked, onChange}' },
- { name: 'preventClose', type: 'boolean', default: 'false', description: 'Block overlay-click dismiss' },
- { name: 'hideModalCloseButton', type: 'boolean', default: 'false', description: 'Hides the X button' },
- { name: 'content', type: 'ReactNode', default: '(none)', description: 'Custom content between description and CTAs' },
+ {
+ name: 'description',
+ type: 'string | ReactNode',
+ default: '(none)',
+ description: 'Subtitle text',
+ },
+ {
+ name: 'icon',
+ type: 'IconName | ReactNode',
+ default: '(none)',
+ description: 'Displayed in pink circle above title',
+ },
+ {
+ name: 'iconProps',
+ type: 'Partial',
+ default: '(none)',
+ description: 'Override icon size/color',
+ },
+ {
+ name: 'isLoadingIcon',
+ type: 'boolean',
+ default: 'false',
+ description: 'Replace icon with spinner',
+ },
+ {
+ name: 'ctas',
+ type: 'ActionModalButtonProps[]',
+ default: '[]',
+ description: 'Array of {text, variant, onClick, ...ButtonProps}',
+ },
+ {
+ name: 'checkbox',
+ type: 'ActionModalCheckboxProps',
+ default: '(none)',
+ description: '{text, checked, onChange}',
+ },
+ {
+ name: 'preventClose',
+ type: 'boolean',
+ default: 'false',
+ description: 'Block overlay-click dismiss',
+ },
+ {
+ name: 'hideModalCloseButton',
+ type: 'boolean',
+ default: 'false',
+ description: 'Hides the X button',
+ },
+ {
+ name: 'content',
+ type: 'ReactNode',
+ default: '(none)',
+ description: 'Custom content between description and CTAs',
+ },
{ name: 'footer', type: 'ReactNode', default: '(none)', description: 'Content below CTAs' },
]}
/>
@@ -147,7 +247,9 @@ export default function ModalPage() {
- setVisible(false)}
title="Confirm Action"
@@ -162,7 +264,8 @@ export default function ModalPage() {
{ text: 'Cancel', variant: 'stroke', onClick: handleCancel },
{ text: 'Confirm', variant: 'purple', onClick: handleConfirm },
]}
-/>`} />
+/>`}
+ />
@@ -171,12 +274,12 @@ export default function ModalPage() {
{/* Design Notes */}
- ActionModal is the preferred pattern for confirmations and simple actions. Use Base Modal only when you
- need fully custom content.
+ ActionModal is the preferred pattern for confirmations and simple actions. Use Base Modal only when
+ you need fully custom content.
- ActionModal icon renders in a pink (primary-1) circle by default. Override with iconContainerClassName
- if needed.
+ ActionModal icon renders in a pink (primary-1) circle by default. Override with
+ iconContainerClassName if needed.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
index 1bc1b5e78..2905d1f34 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
@@ -26,25 +26,60 @@ export default function NavigationPage() {
- Top navigation bar with back button (link or callback), centered title, and optional logout button.
- Uses authContext for logout.
+ Top navigation bar with back button (link or callback), centered title, and optional logout
+ button. Uses authContext for logout.
- NavHeader uses useAuth() internally for the logout button. It cannot be rendered in isolation outside of
- the auth provider. Showing code examples only.
+ NavHeader uses useAuth() internally for the logout button. It cannot be rendered in isolation
+ outside of the auth provider. Showing code examples only.
void', default: '(none)', description: 'Callback replaces Link with Button' },
- { name: 'icon', type: 'IconName', default: "'chevron-up'", description: 'Back button icon (rotated -90deg)' },
- { name: 'disableBackBtn', type: 'boolean', default: 'false', description: 'Disables the back button' },
- { name: 'showLogoutBtn', type: 'boolean', default: 'false', description: 'Shows logout icon button on right' },
- { name: 'hideLabel', type: 'boolean', default: 'false', description: 'Hides the title text' },
- { name: 'titleClassName', type: 'string', default: "''", description: 'Override title styles' },
+ {
+ name: 'href',
+ type: 'string',
+ default: "'/home'",
+ description: 'Link destination when no onPrev',
+ },
+ {
+ name: 'onPrev',
+ type: '() => void',
+ default: '(none)',
+ description: 'Callback replaces Link with Button',
+ },
+ {
+ name: 'icon',
+ type: 'IconName',
+ default: "'chevron-up'",
+ description: 'Back button icon (rotated -90deg)',
+ },
+ {
+ name: 'disableBackBtn',
+ type: 'boolean',
+ default: 'false',
+ description: 'Disables the back button',
+ },
+ {
+ name: 'showLogoutBtn',
+ type: 'boolean',
+ default: 'false',
+ description: 'Shows logout icon button on right',
+ },
+ {
+ name: 'hideLabel',
+ type: 'boolean',
+ default: 'false',
+ description: 'Hides the title text',
+ },
+ {
+ name: 'titleClassName',
+ type: 'string',
+ default: "''",
+ description: 'Override title styles',
+ },
]}
/>
@@ -53,7 +88,10 @@ export default function NavigationPage() {
`} />
- router.back()} />`} />
+ router.back()} />`}
+ />
`} />
@@ -63,25 +101,30 @@ export default function NavigationPage() {
- Minimal header for multi-step flows. Back button on the left, optional element on the right.
- No title -- the screen content below provides context.
+ Minimal header for multi-step flows. Back button on the left, optional element on the right. No
+ title -- the screen content below provides context.
{/* Live demo */}
-
Live Demo (step {flowStep}/3)
+
+ Live Demo (step {flowStep}/3)
+
1 ? () => setFlowStep((s) => s - 1) : undefined}
disableBackBtn={flowStep <= 1}
- rightElement={
- {flowStep}/3
- }
+ rightElement={{flowStep}/3 }
/>
Step {flowStep} Content
{flowStep < 3 ? (
- setFlowStep((s) => s + 1)}>
+ setFlowStep((s) => s + 1)}
+ >
Next
) : (
@@ -93,19 +136,37 @@ export default function NavigationPage() {
void', default: '(none)', description: 'Back button handler. If omitted, no back button shown.' },
- { name: 'disableBackBtn', type: 'boolean', default: 'false', description: 'Grays out the back button' },
- { name: 'rightElement', type: 'ReactNode', default: '(none)', description: 'Element rendered on the right (e.g. step indicator)' },
+ {
+ name: 'onPrev',
+ type: '() => void',
+ default: '(none)',
+ description: 'Back button handler. If omitted, no back button shown.',
+ },
+ {
+ name: 'disableBackBtn',
+ type: 'boolean',
+ default: 'false',
+ description: 'Grays out the back button',
+ },
+ {
+ name: 'rightElement',
+ type: 'ReactNode',
+ default: '(none)',
+ description: 'Element rendered on the right (e.g. step indicator)',
+ },
]}
/>
- setStep(step - 1)}
rightElement={2/3 }
-/>`} />
+/>`}
+ />
@@ -118,7 +179,8 @@ export default function NavigationPage() {
(Send, Request, Claim, etc.).
- Both use a 28px (h-7 w-7) stroke button for the back arrow. This is the standard navigation button size.
+ Both use a 28px (h-7 w-7) stroke button for the back arrow. This is the standard navigation button
+ size.
diff --git a/src/app/(mobile-ui)/dev/ds/patterns/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/page.tsx
index a1c66175b..023c23901 100644
--- a/src/app/(mobile-ui)/dev/ds/patterns/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/patterns/page.tsx
@@ -12,78 +12,78 @@ export default function PatternsPage() {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
)
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
index 7ddf8bdcc..28ba2c1ae 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
@@ -15,7 +15,11 @@ export default function BaseInputPage() {
return (
-
+
-
+
@@ -59,14 +75,8 @@ export default function BaseInputPage() {
-
- `}
- />
+
+ `} />
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
index 55613542c..5b6b7d5fe 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
@@ -14,18 +14,24 @@ export default function BaseSelectPage() {
return (
-
+
- ', default: '(required)', required: true },
- { name: 'placeholder', type: 'string', default: "'Select...'" },
- { name: 'value', type: 'string', default: '(none)' },
- { name: 'onValueChange', type: '(value: string) => void', default: '(none)' },
- { name: 'disabled', type: 'boolean', default: 'false' },
- { name: 'error', type: 'boolean', default: 'false' },
- ]} />
+ ', default: '(required)', required: true },
+ { name: 'placeholder', type: 'string', default: "'Select...'" },
+ { name: 'value', type: 'string', default: '(none)' },
+ { name: 'onValueChange', type: '(value: string) => void', default: '(none)' },
+ { name: 'disabled', type: 'boolean', default: 'false' },
+ { name: 'error', type: 'boolean', default: 'false' },
+ ]}
+ />
@@ -41,10 +47,7 @@ export default function BaseSelectPage() {
/>
-
+
}
- doLabel='Default height (no size prop) for primary CTAs'
+ doLabel="Default height (no size prop) for primary CTAs"
dontExample={
Continue
@@ -172,15 +172,21 @@ export default function ButtonPage() {
h-13 (52px)
-
small
+
+ small
+
h-8 · 29 usages
-
medium
+
+ medium
+
h-9 · 10 usages
-
large
+
+ large
+
h-10 · 5 usages
@@ -205,15 +211,35 @@ export default function ButtonPage() {
@@ -224,12 +250,12 @@ export default function ButtonPage() {
- size="large" is h-10 — SHORTER than default h-13. Default is the tallest button. Primary
- CTAs should use NO size prop.
+ size="large" is h-10 — SHORTER than default h-13. Default is the tallest button.
+ Primary CTAs should use NO size prop.
- Primary CTA pattern: variant="purple" shadowSize="4" className="w-full"
- — no size prop.
+ Primary CTA pattern: variant="purple" shadowSize="4"
+ className="w-full" — no size prop.
@@ -239,33 +265,42 @@ export default function ButtonPage() {
Primary CTA (most common)
-
Continue
+
+ Continue
+
Secondary CTA
-
Go Back
+
+ Go Back
+
With icon
- Share
- Copy
+
+ Share
+
+
+ Copy
+
States
- Disabled
- Loading
+
+ Disabled
+
+
+ Loading
+
-
+
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
index 2fbc03097..0676a85ec 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
@@ -12,7 +12,11 @@ import { CodeBlock } from '../../_components/CodeBlock'
export default function CardPage() {
return (
-
+
')
- return parts.join(' ') + '\n \n Title \n Description \n \n Content \n'
+ return (
+ parts.join(' ') +
+ '\n \n Title \n Description \n \n Content \n'
+ )
}}
/>
-
+
-
No shadow
-
shadowSize="4"
-
shadowSize="6"
-
shadowSize="8"
+
+ No shadow
+
+
+ shadowSize="4"
+
+
+ shadowSize="6"
+
+
+ shadowSize="8"
+
-
+
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
index dbcddb4d4..07ca12c08 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
@@ -18,11 +18,13 @@ export default function CheckboxPage() {
- void', default: '(required)', required: true },
- { name: 'label', type: 'string', default: '(none)' },
- ]} />
+ void', default: '(required)', required: true },
+ { name: 'label', type: 'string', default: '(none)' },
+ ]}
+ />
@@ -38,10 +40,7 @@ export default function CheckboxPage() {
-
+
setChecked(e.target.checked)}
/>`}
/>
- {}} />`}
- />
+ {}} />`} />
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
index 660bae781..fd1c7b495 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
@@ -15,11 +15,13 @@ export default function DividerPage() {
-
+
@@ -35,18 +37,9 @@ export default function DividerPage() {
-
- `}
- />
- `}
- />
+
+ `} />
+ `} />
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
index 6ebf18b54..669fe696d 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
@@ -10,18 +10,23 @@ import { CodeBlock } from '../../_components/CodeBlock'
export default function PageContainerPage() {
return (
-
+
-
+
- Wraps mobile screens with responsive width constraints. Children inherit full width via the *:w-full selector. On desktop (md+), content is offset with md:pl-24 and capped at md:*:max-w-xl.
+ Wraps mobile screens with responsive width constraints. Children inherit full width via the{' '}
+ *:w-full selector. On desktop (md+), content is offset with{' '}
+ md:pl-24 and capped at{' '}
+ md:*:max-w-xl.
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/page.tsx
index fd7dba229..224b1c9c3 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/page.tsx
@@ -12,77 +12,77 @@ export default function PrimitivesPage() {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
)
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
index e6e490023..c891baf94 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
@@ -11,14 +11,25 @@ import { CodeBlock } from '../../_components/CodeBlock'
export default function TitlePage() {
return (
-
+
-
+
@@ -35,18 +46,9 @@ export default function TitlePage() {
-
- `}
- />
- `}
- />
+
+ `} />
+ `} />
diff --git a/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
index bc734c4f7..dc280dc89 100644
--- a/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
+++ b/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
@@ -14,26 +14,32 @@ export default function ToastPage() {
return (
-
+
- success('Operation successful!')}>success
- error('Something went wrong')}>error
- info('Did you know?')}>info
- warning('Check this out')}>warning
+ success('Operation successful!')}>
+ success
+
+ error('Something went wrong')}>
+ error
+
+ info('Did you know?')}>
+ info
+
+ warning('Check this out')}>
+ warning
+
-
-
+
+
-
+
)
}
diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx
index 15b7102b7..a5a2692b5 100644
--- a/src/app/[locale]/(marketing)/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/[country]/page.tsx
@@ -15,6 +15,7 @@ export async function generateStaticParams() {
const countries = Object.keys(COUNTRIES_SEO)
return SUPPORTED_LOCALES.flatMap((locale) => countries.map((country) => ({ locale, country })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, country } = await params
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
index 94ae26187..3efa1a75e 100644
--- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -19,6 +19,7 @@ export async function generateStaticParams() {
return posts.map((post) => ({ locale, slug: post.slug }))
})
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, slug } = await params
@@ -75,7 +76,7 @@ export default async function BlogPostPageLocalized({ params }: PageProps) {
{post.frontmatter.date}
diff --git a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
index 83ee84d9c..9926d05d4 100644
--- a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
@@ -22,6 +22,7 @@ export async function generateStaticParams() {
return fallbackCats.map((cat) => ({ locale, cat }))
})
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, cat } = await params
diff --git a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
index 6c32ccb18..765f42a1b 100644
--- a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
@@ -21,6 +21,7 @@ export async function generateStaticParams() {
const slugs = Object.keys(COMPETITORS)
return SUPPORTED_LOCALES.flatMap((locale) => slugs.map((slug) => ({ locale, slug: `peanut-vs-${slug}` })))
}
+export const dynamicParams = false
/** Strip the "peanut-vs-" URL prefix to get the data key. Returns null if prefix missing. */
function parseSlug(raw: string): string | null {
diff --git a/src/app/[locale]/(marketing)/convert/[pair]/page.tsx b/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
index 6004b59cb..7c31d7b96 100644
--- a/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
+++ b/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
@@ -21,6 +21,7 @@ interface PageProps {
export async function generateStaticParams() {
return SUPPORTED_LOCALES.flatMap((locale) => CONVERT_PAIRS.map((pair) => ({ locale, pair })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, pair } = await params
diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
index e5d05e726..39e50c9dc 100644
--- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -20,8 +20,11 @@ interface PageProps {
export async function generateStaticParams() {
const exchanges = Object.keys(EXCHANGES)
- return SUPPORTED_LOCALES.flatMap((locale) => exchanges.map((exchange) => ({ locale, exchange: `from-${exchange}` })))
+ return SUPPORTED_LOCALES.flatMap((locale) =>
+ exchanges.map((exchange) => ({ locale, exchange: `from-${exchange}` }))
+ )
}
+export const dynamicParams = false
/** Strip the "from-" URL prefix to get the data key. Returns null if prefix missing. */
function parseExchange(raw: string): string | null {
@@ -117,7 +120,7 @@ export default async function DepositPageLocalized({ params }: PageProps) {
{ex.troubleshooting.map((item, i) => (
- {item.issue}
+ {item.issue}
{item.fix}
))}
diff --git a/src/app/[locale]/(marketing)/layout.tsx b/src/app/[locale]/(marketing)/layout.tsx
index cb3c8efc3..2c362170e 100644
--- a/src/app/[locale]/(marketing)/layout.tsx
+++ b/src/app/[locale]/(marketing)/layout.tsx
@@ -13,6 +13,7 @@ interface LayoutProps {
export async function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
+export const dynamicParams = false
export default async function LocalizedMarketingLayout({ children, params }: LayoutProps) {
const { locale } = await params
diff --git a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
index 2c3696de7..556a7fe5e 100644
--- a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
+++ b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
@@ -12,10 +12,9 @@ interface PageProps {
}
export async function generateStaticParams() {
- return SUPPORTED_LOCALES.flatMap((locale) =>
- PAYMENT_METHOD_SLUGS.map((method) => ({ locale, method }))
- )
+ return SUPPORTED_LOCALES.flatMap((locale) => PAYMENT_METHOD_SLUGS.map((method) => ({ locale, method })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise
{
const { locale, method } = await params
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 b25f574b1..f8c0752d9 100644
--- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
@@ -20,6 +20,7 @@ export async function generateStaticParams() {
const sources = getReceiveSources()
return SUPPORTED_LOCALES.flatMap((locale) => sources.map((country) => ({ locale, country })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, country } = await params
diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
index ec5cfe729..dd2bef1d9 100644
--- a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -13,10 +13,9 @@ interface PageProps {
}
export async function generateStaticParams() {
- return SUPPORTED_LOCALES.flatMap((locale) =>
- CORRIDORS.map((c) => ({ locale, from: c.from, to: c.to }))
- )
+ return SUPPORTED_LOCALES.flatMap((locale) => CORRIDORS.map((c) => ({ locale, from: c.from, to: c.to })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, from, to } = await params
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
index 2b93f2540..39c828d55 100644
--- a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -16,6 +16,7 @@ export async function generateStaticParams() {
const countries = Object.keys(COUNTRIES_SEO)
return SUPPORTED_LOCALES.flatMap((locale) => countries.map((country) => ({ locale, country })))
}
+export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
const { locale, country } = await params
diff --git a/src/app/[locale]/(marketing)/send-money-to/page.tsx b/src/app/[locale]/(marketing)/send-money-to/page.tsx
index 12a9033df..4a8c38b60 100644
--- a/src/app/[locale]/(marketing)/send-money-to/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/page.tsx
@@ -59,10 +59,7 @@ export default async function SendMoneyToIndexPageLocalized({ params }: PageProp
return (
<>
-
+
diff --git a/src/app/[locale]/(marketing)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx
index cbae53ce5..41add6ef3 100644
--- a/src/app/[locale]/(marketing)/team/page.tsx
+++ b/src/app/[locale]/(marketing)/team/page.tsx
@@ -87,17 +87,32 @@ export default async function TeamPage({ params }: PageProps) {
{member.social && (
{member.social.linkedin && (
-
+
LinkedIn
)}
{member.social.twitter && (
-
+
X / Twitter
)}
{member.social.github && (
-
+
GitHub
)}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d4c24586d..da454dad8 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -144,10 +144,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* JSON-LD structured data */}
-
+
{/* AI-readable product description (llms.txt spec) */}
diff --git a/src/app/lp/card/CardLandingPage.tsx b/src/app/lp/card/CardLandingPage.tsx
index 964a9b34c..913f9eb40 100644
--- a/src/app/lp/card/CardLandingPage.tsx
+++ b/src/app/lp/card/CardLandingPage.tsx
@@ -782,7 +782,6 @@ const CardLandingPage = () => {
-
>
)
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index a59446b72..cbdb5e6c6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -11,9 +11,7 @@ import { faqSchema, JsonLd } from '@/lib/seo/schemas'
import { heroConfig, faqData, marqueeMessages } from '@/components/LandingPage/landingPageData'
export default function LandingPage() {
- const faqJsonLd = faqSchema(
- faqData.questions.map((q) => ({ question: q.question, answer: q.answer }))
- )
+ const faqJsonLd = faqSchema(faqData.questions.map((q) => ({ question: q.question, answer: q.answer })))
return (
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 3ba28619d..ba173f1c6 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -22,7 +22,14 @@ export default function robots(): MetadataRoute.Robots {
// AI search engine crawlers — explicitly welcome
{
- userAgent: ['GPTBot', 'ChatGPT-User', 'PerplexityBot', 'ClaudeBot', 'Google-Extended', 'Applebot-Extended'],
+ userAgent: [
+ 'GPTBot',
+ 'ChatGPT-User',
+ 'PerplexityBot',
+ 'ClaudeBot',
+ 'Google-Extended',
+ 'Applebot-Extended',
+ ],
allow: ['/'],
disallow: ['/api/', '/home', '/profile', '/settings', '/setup', '/dev/'],
},
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 4728a6559..22e2dad20 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -50,7 +50,11 @@ async function generateSitemap(): Promise {
// Corridor index + country pages
pages.push({ path: `/${locale}/send-money-to`, priority: 0.9 * basePriority, changeFrequency: 'weekly' })
for (const country of Object.keys(COUNTRIES_SEO)) {
- pages.push({ path: `/${locale}/send-money-to/${country}`, priority: 0.8 * basePriority, changeFrequency: 'weekly' })
+ pages.push({
+ path: `/${locale}/send-money-to/${country}`,
+ priority: 0.8 * basePriority,
+ changeFrequency: 'weekly',
+ })
}
// From-to corridor pages
@@ -79,17 +83,29 @@ async function generateSitemap(): Promise {
// Comparison pages
for (const slug of Object.keys(COMPETITORS)) {
- pages.push({ path: `/${locale}/compare/peanut-vs-${slug}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ pages.push({
+ path: `/${locale}/compare/peanut-vs-${slug}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'monthly',
+ })
}
// Deposit pages
for (const exchange of Object.keys(EXCHANGES)) {
- pages.push({ path: `/${locale}/deposit/from-${exchange}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ pages.push({
+ path: `/${locale}/deposit/from-${exchange}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'monthly',
+ })
}
// Pay-with pages
for (const method of PAYMENT_METHODS) {
- pages.push({ path: `/${locale}/pay-with/${method}`, priority: 0.7 * basePriority, changeFrequency: 'monthly' })
+ pages.push({
+ path: `/${locale}/pay-with/${method}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'monthly',
+ })
}
// Blog — only include posts that actually exist for this locale (avoid duplicate content)
@@ -99,7 +115,11 @@ async function generateSitemap(): Promise {
pages.push({ path: `/${locale}/blog`, priority: 0.8 * basePriority, changeFrequency: 'weekly' })
for (const post of postsToInclude) {
- pages.push({ path: `/${locale}/blog/${post.slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly' })
+ pages.push({
+ path: `/${locale}/blog/${post.slug}`,
+ priority: 0.6 * basePriority,
+ changeFrequency: 'monthly',
+ })
}
// Team page
diff --git a/src/components/Global/AnimateOnView.tsx b/src/components/Global/AnimateOnView.tsx
index 7d4fa5e37..64f07753b 100644
--- a/src/components/Global/AnimateOnView.tsx
+++ b/src/components/Global/AnimateOnView.tsx
@@ -12,16 +12,7 @@ type AnimateOnViewProps = {
style?: CSSProperties
} & React.HTMLAttributes
-export function AnimateOnView({
- children,
- className,
- delay,
- y,
- x,
- rotate,
- style,
- ...rest
-}: AnimateOnViewProps) {
+export function AnimateOnView({ children, className, delay, y, x, rotate, style, ...rest }: AnimateOnViewProps) {
const ref = useRef(null)
useEffect(() => {
@@ -44,13 +35,15 @@ export function AnimateOnView({
{children}
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index 68520f09a..eb8b218df 100644
--- a/src/components/LandingPage/LandingPageClient.tsx
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -2,14 +2,7 @@
import { useFooterVisibility } from '@/context/footerVisibility'
import { useEffect, useState, useRef, type ReactNode } from 'react'
-import {
- DropLink,
- FAQs,
- Hero,
- Marquee,
- NoFees,
- CardPioneers,
-} from '@/components/LandingPage'
+import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage'
import TweetCarousel from '@/components/LandingPage/TweetCarousel'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -116,7 +109,6 @@ export function LandingPageClient({
setButtonScale(1)
setHasGrown(false)
}
-
}
}
@@ -176,9 +168,7 @@ export function LandingPageClient({
{securitySlot}
-
- {sendInSecondsSlot}
-
+
{sendInSecondsSlot}
diff --git a/src/components/LandingPage/SendInSecondsCTA.tsx b/src/components/LandingPage/SendInSecondsCTA.tsx
index 6004b845b..65be1b83d 100644
--- a/src/components/LandingPage/SendInSecondsCTA.tsx
+++ b/src/components/LandingPage/SendInSecondsCTA.tsx
@@ -9,7 +9,14 @@ export function SendInSecondsCTA() {
diff --git a/src/components/LandingPage/sendInSeconds.tsx b/src/components/LandingPage/sendInSeconds.tsx
index 2585a74d5..15d58efab 100644
--- a/src/components/LandingPage/sendInSeconds.tsx
+++ b/src/components/LandingPage/sendInSeconds.tsx
@@ -15,9 +15,23 @@ const sendInSecondsClouds = [
]
const starConfigs = [
- { className: 'absolute right-10 top-10 md:right-1/4 md:top-20', width: 50, height: 50, delay: '0.2s', x: '5px', rotate: '45deg' },
+ {
+ className: 'absolute right-10 top-10 md:right-1/4 md:top-20',
+ width: 50,
+ height: 50,
+ delay: '0.2s',
+ x: '5px',
+ rotate: '45deg',
+ },
{ className: 'absolute bottom-16 left-1/3', width: 40, height: 40, delay: '0.4s', x: '-5px', rotate: '-10deg' },
- { className: 'absolute bottom-20 left-[2rem] md:bottom-72 md:right-[14rem]', width: 50, height: 50, delay: '0.6s', x: '5px', rotate: '-22deg' },
+ {
+ className: 'absolute bottom-20 left-[2rem] md:bottom-72 md:right-[14rem]',
+ width: 50,
+ height: 50,
+ delay: '0.6s',
+ x: '5px',
+ rotate: '-22deg',
+ },
{ className: 'absolute left-[20rem] top-72', width: 60, height: 60, delay: '0.8s', x: '-5px', rotate: '12deg' },
]
diff --git a/src/components/Marketing/BlogCard.tsx b/src/components/Marketing/BlogCard.tsx
index f7025add2..5ad762c6f 100644
--- a/src/components/Marketing/BlogCard.tsx
+++ b/src/components/Marketing/BlogCard.tsx
@@ -13,7 +13,10 @@ interface BlogCardProps {
export function BlogCard({ slug, title, excerpt, date, category, hrefPrefix = '/blog' }: BlogCardProps) {
return (
-
+
{category && (
{category}
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
index 66d06d1bf..879ea7f22 100644
--- a/src/components/Marketing/DestinationGrid.tsx
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -30,7 +30,10 @@ export function DestinationGrid({ countries, title = 'Send money to', locale = '
return (
-
+
{flagCode && (
@@ -29,7 +35,9 @@ export function MarketingHero({ title, subtitle, ctaText = 'Get Started', ctaHre
src={image}
alt=""
className="mx-auto mb-4 h-16 w-16 rounded-xl object-contain"
- onError={(e) => { e.currentTarget.style.display = 'none' }}
+ onError={(e) => {
+ e.currentTarget.style.display = 'none'
+ }}
/>
)}
diff --git a/src/components/Marketing/pages/CorridorPageContent.tsx b/src/components/Marketing/pages/CorridorPageContent.tsx
index ab33f1773..3e7d69335 100644
--- a/src/components/Marketing/pages/CorridorPageContent.tsx
+++ b/src/components/Marketing/pages/CorridorPageContent.tsx
@@ -145,10 +145,12 @@ export function CorridorPageContent({ country, locale }: CorridorPageContentProp
href: localizedBarePath(locale, country),
},
...(currencyCode
- ? [{
- title: t(i18n.convertTitle, { from: 'USD', to: currencyCode }),
- href: localizedPath('convert', locale, `usd-to-${currencyCode.toLowerCase()}`),
- }]
+ ? [
+ {
+ title: t(i18n.convertTitle, { from: 'USD', to: currencyCode }),
+ href: localizedPath('convert', locale, `usd-to-${currencyCode.toLowerCase()}`),
+ },
+ ]
: []),
{
title: t(i18n.receiveMoneyFrom, { country: countryName }),
@@ -157,11 +159,7 @@ export function CorridorPageContent({ country, locale }: CorridorPageContentProp
]}
/>
-
+
{/* Last updated */}
diff --git a/src/components/Marketing/pages/FromToCorridorContent.tsx b/src/components/Marketing/pages/FromToCorridorContent.tsx
index 49d5fd275..66857bbec 100644
--- a/src/components/Marketing/pages/FromToCorridorContent.tsx
+++ b/src/components/Marketing/pages/FromToCorridorContent.tsx
@@ -112,7 +112,11 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
if (toCurrency) {
relatedPages.push({
title: t(i18n.convertTitle, { from: fromCurrency || 'USD', to: toCurrency }),
- href: localizedPath('convert', locale, `${(fromCurrency || 'usd').toLowerCase()}-to-${toCurrency.toLowerCase()}`),
+ href: localizedPath(
+ 'convert',
+ locale,
+ `${(fromCurrency || 'usd').toLowerCase()}-to-${toCurrency.toLowerCase()}`
+ ),
})
}
@@ -160,7 +164,9 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
/>
)}
-
{t(i18n.receiveMoneyFrom, { country: '' }).trim()}
+
+ {t(i18n.receiveMoneyFrom, { country: '' }).trim()}
+
{toName}
{toCurrency &&
{toCurrency} }
@@ -170,12 +176,8 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
{/* Context paragraph */}
-
- {t(i18n.fromToContext, { from: fromName, to: toName })}
-
- {toSeo?.context && (
- {toSeo.context}
- )}
+ {t(i18n.fromToContext, { from: fromName, to: toName })}
+ {toSeo?.context && {toSeo.context}
}
{/* How it works */}
@@ -189,7 +191,9 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
{fromSeo?.instantPayment && (
- {fromSeo.instantPayment} ({fromName})
+
+ {fromSeo.instantPayment} ({fromName})
+
{t(i18n.instantDeposits, { method: fromSeo.instantPayment, country: fromName })}
@@ -197,7 +201,9 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
)}
{toSeo?.instantPayment && (
- {toSeo.instantPayment} ({toName})
+
+ {toSeo.instantPayment} ({toName})
+
{t(i18n.instantDeposits, { method: toSeo.instantPayment, country: toName })}
@@ -225,7 +231,7 @@ export function FromToCorridorContent({ from, to, locale }: FromToCorridorConten
key={c.to}
href={localizedPath('send-money-from', locale, `${c.from}/to/${c.to}`)}
>
-
+
{destMapping?.flagCode && (
{/* Last updated */}
-
- {t(i18n.lastUpdated, { date: today })}
-
+ {t(i18n.lastUpdated, { date: today })}
>
)
diff --git a/src/components/Marketing/pages/HubPageContent.tsx b/src/components/Marketing/pages/HubPageContent.tsx
index 75a1e1ed2..6a138ad13 100644
--- a/src/components/Marketing/pages/HubPageContent.tsx
+++ b/src/components/Marketing/pages/HubPageContent.tsx
@@ -159,7 +159,7 @@ export function HubPageContent({ country, locale }: HubPageContentProps) {
{links.map((link) => (
-
+
{link.emoji}
{link.title}
{link.description}
@@ -184,7 +184,7 @@ export function HubPageContent({ country, locale }: HubPageContentProps) {
key={fromSlug}
href={localizedPath('send-money-from', locale, `${fromSlug}/to/${country}`)}
>
-
+
{fromMapping?.flagCode && (
-
+
{toMapping?.flagCode && (
0 && (
-
- )}
+ {seo.faqs.length > 0 && }
{/* Other countries grid */}
-
+
{/* Last updated */}
diff --git a/src/components/Marketing/pages/PayWithContent.tsx b/src/components/Marketing/pages/PayWithContent.tsx
index 6cee0cf48..b6dbb633d 100644
--- a/src/components/Marketing/pages/PayWithContent.tsx
+++ b/src/components/Marketing/pages/PayWithContent.tsx
@@ -99,9 +99,7 @@ export function PayWithContent({ method, locale }: PayWithContentProps) {
{/* Last updated */}
-
- {t(i18n.lastUpdated, { date: today })}
-
+ {t(i18n.lastUpdated, { date: today })}
>
)
diff --git a/src/components/Marketing/pages/ReceiveMoneyContent.tsx b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
index 7952f9821..ee37237f0 100644
--- a/src/components/Marketing/pages/ReceiveMoneyContent.tsx
+++ b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
@@ -104,7 +104,7 @@ export function ReceiveMoneyContent({ sourceCountry, locale }: ReceiveMoneyConte
key={destSlug}
href={localizedPath('send-money-from', locale, `${sourceCountry}/to/${destSlug}`)}
>
-
+
{destMapping?.flagCode && (
{/* Last updated */}
-
- {t(i18n.lastUpdated, { date: today })}
-
+ {t(i18n.lastUpdated, { date: today })}
>
)
diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts
index 7922b1fa6..b82de71d9 100644
--- a/src/constants/countryCurrencyMapping.ts
+++ b/src/constants/countryCurrencyMapping.ts
@@ -112,7 +112,5 @@ export function isUKCountry(countryIdentifier: string | undefined): boolean {
/** Find a currency mapping by country slug (e.g. 'argentina', 'united-kingdom'). */
export function findMappingBySlug(slug: string): CountryCurrencyMapping | undefined {
- return countryCurrencyMappings.find(
- (m) => m.path === slug || m.country.toLowerCase().replace(/ /g, '-') === slug
- )
+ return countryCurrencyMappings.find((m) => m.path === slug || m.country.toLowerCase().replace(/ /g, '-') === slug)
}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
index c9f77ef03..ef50e41c9 100644
--- a/src/lib/blog.ts
+++ b/src/lib/blog.ts
@@ -79,7 +79,7 @@ export async function getPostBySlug(
}
}
- const html = await marked(content, { renderer }) as string
+ const html = (await marked(content, { renderer })) as string
return { frontmatter: data as BlogPost['frontmatter'], html }
}
diff --git a/src/lib/content.ts b/src/lib/content.ts
index 05799058f..619dd7330 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -10,12 +10,19 @@ const CONTENT_ROOT = path.join(process.cwd(), 'src/content')
const yaml = matter.engines.yaml
-// --- Low-level readers ---
+// --- Low-level readers (cached per filepath for the lifetime of the process) ---
+
+const yamlCache = new Map()
+const mdCache = new Map()
function readYamlFile(filePath: string): T | null {
+ if (yamlCache.has(filePath)) return yamlCache.get(filePath) as T | null
try {
- return yaml.parse(fs.readFileSync(filePath, 'utf8')) as T
+ const result = yaml.parse(fs.readFileSync(filePath, 'utf8')) as T
+ yamlCache.set(filePath, result)
+ return result
} catch {
+ yamlCache.set(filePath, null)
return null
}
}
@@ -26,11 +33,15 @@ interface MarkdownContent> {
}
function readMarkdownFile>(filePath: string): MarkdownContent | null {
+ if (mdCache.has(filePath)) return mdCache.get(filePath) as MarkdownContent | null
try {
const raw = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(raw)
- return { frontmatter: data as T, body: content.trim() }
+ const result: MarkdownContent = { frontmatter: data as T, body: content.trim() }
+ mdCache.set(filePath, result)
+ return result
} catch {
+ mdCache.set(filePath, null)
return null
}
}
@@ -60,9 +71,7 @@ export function readEntityIndex(entityType: string): T | null {
export function listEntitySlugs(entityType: string, key: string): string[] {
const index = readEntityIndex>>(entityType)
if (!index?.[key]) return []
- return index[key]
- .filter((item) => (item.status ?? 'published') === 'published')
- .map((item) => item.slug)
+ return index[key].filter((item) => (item.status ?? 'published') === 'published').map((item) => item.slug)
}
/** Check if an entity is published (missing status = published) */
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 153394850..6056bd61b 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -726,19 +726,24 @@ input::placeholder {
}
15% {
opacity: 1;
- transform: translateY(calc(var(--aov-y, 20px) * -0.6)) translateX(calc(var(--aov-x, 0px) * -0.6)) rotate(var(--aov-rotate, 0deg));
+ transform: translateY(calc(var(--aov-y, 20px) * -0.6)) translateX(calc(var(--aov-x, 0px) * -0.6))
+ rotate(var(--aov-rotate, 0deg));
}
30% {
- transform: translateY(calc(var(--aov-y, 20px) * 0.35)) translateX(calc(var(--aov-x, 0px) * 0.35)) rotate(var(--aov-rotate, 0deg));
+ transform: translateY(calc(var(--aov-y, 20px) * 0.35)) translateX(calc(var(--aov-x, 0px) * 0.35))
+ rotate(var(--aov-rotate, 0deg));
}
45% {
- transform: translateY(calc(var(--aov-y, 20px) * -0.2)) translateX(calc(var(--aov-x, 0px) * -0.2)) rotate(var(--aov-rotate, 0deg));
+ transform: translateY(calc(var(--aov-y, 20px) * -0.2)) translateX(calc(var(--aov-x, 0px) * -0.2))
+ rotate(var(--aov-rotate, 0deg));
}
60% {
- transform: translateY(calc(var(--aov-y, 20px) * 0.1)) translateX(calc(var(--aov-x, 0px) * 0.1)) rotate(var(--aov-rotate, 0deg));
+ transform: translateY(calc(var(--aov-y, 20px) * 0.1)) translateX(calc(var(--aov-x, 0px) * 0.1))
+ rotate(var(--aov-rotate, 0deg));
}
75% {
- transform: translateY(calc(var(--aov-y, 20px) * -0.05)) translateX(calc(var(--aov-x, 0px) * -0.05)) rotate(var(--aov-rotate, 0deg));
+ transform: translateY(calc(var(--aov-y, 20px) * -0.05)) translateX(calc(var(--aov-x, 0px) * -0.05))
+ rotate(var(--aov-rotate, 0deg));
}
100% {
opacity: 1;
From 931a050fe20644bd993600d9c18717c80397d90c Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 18 Feb 2026 20:24:59 +0000
Subject: [PATCH 05/61] =?UTF-8?q?=E2=9C=A8=20V3=20transitivity=20UI=20+=20?=
=?UTF-8?q?binary=20cashback=20+=20points=20animations?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Use API contributedPoints instead of local TRANSITIVITY_MULTIPLIER calc
- Add formatPoints (thousands separators) and shortenPoints (K/M with pink suffix)
- Binary CashCard: "Lifetime cashback claimed" + status text
- Count-up animations on /points hero (localStorage memory across visits)
- Scroll-triggered count-up on invitee points badges
- Extract InviteePointsBadge to shared component (DRY)
- Remove TRANSITIVITY_MULTIPLIER constant (server-side only now)
---
src/app/(mobile-ui)/points/invites/page.tsx | 35 +++++---
src/app/(mobile-ui)/points/page.tsx | 41 ++++++---
src/components/Common/PointsCard.tsx | 3 +-
src/components/Points/CashCard.tsx | 32 +++----
src/components/Points/InviteePointsBadge.tsx | 21 +++++
.../TransactionDetailsReceipt.tsx | 3 +-
src/constants/points.consts.ts | 10 +--
src/hooks/useCountUp.ts | 86 +++++++++++++++++++
src/services/points.ts | 2 +-
src/utils/format.utils.ts | 23 +++++
10 files changed, 205 insertions(+), 51 deletions(-)
create mode 100644 src/components/Points/InviteePointsBadge.tsx
create mode 100644 src/hooks/useCountUp.ts
diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx
index ee1a36597..7c2e7a070 100644
--- a/src/app/(mobile-ui)/points/invites/page.tsx
+++ b/src/app/(mobile-ui)/points/invites/page.tsx
@@ -16,11 +16,17 @@ import Image from 'next/image'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { getInitialsFromName } from '@/utils/general.utils'
import { type PointsInvite } from '@/services/services.types'
-import { TRANSITIVITY_MULTIPLIER } from '@/constants/points.consts'
+import { formatPoints } from '@/utils/format.utils'
+import { useCountUp } from '@/hooks/useCountUp'
+import { useInView } from 'framer-motion'
+import { useRef } from 'react'
+import InviteePointsBadge from '@/components/Points/InviteePointsBadge'
const InvitesPage = () => {
const router = useRouter()
const { user } = useAuth()
+ const listRef = useRef(null)
+ const listInView = useInView(listRef, { once: true, margin: '-50px' })
const {
data: invites,
@@ -33,6 +39,17 @@ const InvitesPage = () => {
enabled: !!user?.user.userId,
})
+ const totalPointsEarned =
+ invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
+ return sum + invite.contributedPoints
+ }, 0) || 0
+
+ const animatedTotal = useCountUp(totalPointsEarned, {
+ storageKey: 'invites_total',
+ duration: 1.8,
+ enabled: !isLoading && !isError,
+ })
+
if (isLoading) {
return
}
@@ -46,12 +63,6 @@ const InvitesPage = () => {
)
}
- // Calculate total points earned (50% of each invitee's points)
- const totalPointsEarned =
- invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
- return sum + Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
- }, 0) || 0
-
return (
router.back()} />
@@ -63,7 +74,7 @@ const InvitesPage = () => {
- {totalPointsEarned} {totalPointsEarned === 1 ? 'Point' : 'Points'}
+ {formatPoints(animatedTotal)} {totalPointsEarned === 1 ? 'Point' : 'Points'}
@@ -71,12 +82,12 @@ const InvitesPage = () => {
People you invited
{/* Full list */}
-
+
{invites?.invitees?.map((invite: PointsInvite, i: number) => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
+ const pointsEarned = invite.contributedPoints
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
@@ -104,9 +115,7 @@ const InvitesPage = () => {
isVerified={isVerified}
/>
-
- +{pointsEarned} {pointsEarned === 1 ? 'pt' : 'pts'}
-
+
)
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 975957980..9e67acf25 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -19,17 +19,22 @@ import Image from 'next/image'
import { pointsApi } from '@/services/points'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { type PointsInvite } from '@/services/services.types'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import InvitesGraph from '@/components/Global/InvitesGraph'
import { CashCard } from '@/components/Points/CashCard'
-import { TRANSITIVITY_MULTIPLIER } from '@/constants/points.consts'
import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
+import { formatPoints, shortenPoints } from '@/utils/format.utils'
import { Button } from '@/components/0_Bruddle/Button'
+import { useCountUp } from '@/hooks/useCountUp'
+import { useInView } from 'framer-motion'
+import InviteePointsBadge from '@/components/Points/InviteePointsBadge'
const PointsPage = () => {
const router = useRouter()
const { user, fetchUser } = useAuth()
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
+ const inviteesRef = useRef(null)
+ const inviteesInView = useInView(inviteesRef, { once: true, margin: '-50px' })
const getTierBadge = (tier: number) => {
const badges = [TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE]
@@ -73,8 +78,15 @@ const PointsPage = () => {
const username = user?.user.username
+ // animated hero points — remembers last-seen value across visits
+ const animatedTotal = useCountUp(tierInfo?.data?.totalPoints ?? 0, {
+ storageKey: 'hero_total',
+ duration: 1.8,
+ enabled: !!tierInfo?.data,
+ })
+
useEffect(() => {
- // Re-fetch user to get the latest invitees list for showing heart Icon
+ // re-fetch user to get the latest invitees list for showing heart icon
fetchUser()
}, [])
@@ -103,7 +115,16 @@ const PointsPage = () => {
- {tierInfo.data.totalPoints} {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
+ {(() => {
+ const { number, suffix } = shortenPoints(animatedTotal)
+ return (
+ <>
+ {number}
+ {suffix && {suffix} }
+ >
+ )
+ })()}{' '}
+ {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
@@ -148,7 +169,7 @@ const PointsPage = () => {
{tierInfo?.data.currentTier < 2 && (
- {tierInfo.data.pointsToNextTier}{' '}
+ {formatPoints(tierInfo.data.pointsToNextTier)}{' '}
{tierInfo.data.pointsToNextTier === 1 ? 'point' : 'points'} to next tier
)}
@@ -156,7 +177,7 @@ const PointsPage = () => {
{/* cash section */}
{cashStatus?.success && cashStatus.data && (
-
+
)}
@@ -205,12 +226,12 @@ const PointsPage = () => {
-
+
{invites.invitees?.slice(0, 5).map((invite: PointsInvite, i: number) => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER)
+ const pointsEarned = invite.contributedPoints
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
@@ -238,9 +259,7 @@ const PointsPage = () => {
isVerified={isVerified}
/>
-
- +{pointsEarned} {pointsEarned === 1 ? 'pt' : 'pts'}
-
+
)
diff --git a/src/components/Common/PointsCard.tsx b/src/components/Common/PointsCard.tsx
index 5a39115cd..36b395260 100644
--- a/src/components/Common/PointsCard.tsx
+++ b/src/components/Common/PointsCard.tsx
@@ -1,12 +1,13 @@
import Card from '../Global/Card'
import InvitesIcon from '../Home/InvitesIcon'
+import { formatPoints } from '@/utils/format.utils'
const PointsCard = ({ points, pointsDivRef }: { points: number; pointsDivRef: React.RefObject }) => {
return (
- You've earned {points} {points === 1 ? 'point' : 'points'}!
+ You've earned {formatPoints(points)} {points === 1 ? 'point' : 'points'}!
)
diff --git a/src/components/Points/CashCard.tsx b/src/components/Points/CashCard.tsx
index 0f19fd97f..3f4f14bcf 100644
--- a/src/components/Points/CashCard.tsx
+++ b/src/components/Points/CashCard.tsx
@@ -4,28 +4,28 @@ import { Icon } from '@/components/Global/Icons/Icon'
import { Tooltip } from '@/components/Tooltip'
interface CashCardProps {
- cashbackAllowance: number | null
+ hasCashbackLeft: boolean
lifetimeEarned: number
}
-export const CashCard = ({ cashbackAllowance, lifetimeEarned }: CashCardProps) => {
+export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) => {
return (
- {/* cashback allowance display with tooltip */}
- {cashbackAllowance !== null && (
-
-
Cashback left: ${cashbackAllowance.toFixed(2)}
-
-
-
-
- )}
+
+
Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
+
+
+
+
- {/* lifetime earned - subtle */}
-
Lifetime earned: ${lifetimeEarned.toFixed(2)}
+ {hasCashbackLeft ? (
+
You have more cashback left! Make a payment to receive it.
+ ) : (
+
Invite friends to unlock more cashback.
+ )}
)
}
diff --git a/src/components/Points/InviteePointsBadge.tsx b/src/components/Points/InviteePointsBadge.tsx
new file mode 100644
index 000000000..bd0313651
--- /dev/null
+++ b/src/components/Points/InviteePointsBadge.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { useCountUp } from '@/hooks/useCountUp'
+import { formatPoints } from '@/utils/format.utils'
+
+interface InviteePointsBadgeProps {
+ points: number
+ inView: boolean
+}
+
+/** animated points badge for invitee rows — triggers when scrolled into view */
+const InviteePointsBadge = ({ points, inView }: InviteePointsBadgeProps) => {
+ const animated = useCountUp(points, { duration: 1.2, enabled: inView })
+ return (
+
+ +{formatPoints(animated)} {points === 1 ? 'pt' : 'pts'}
+
+ )
+}
+
+export default InviteePointsBadge
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
index a266ae8f9..03544ebc3 100644
--- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
+++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
@@ -18,6 +18,7 @@ import { useUserStore } from '@/redux/hooks'
import { chargesApi } from '@/services/charges'
import useClaimLink from '@/components/Claim/useClaimLink'
import { formatAmount, formatDate, isStableCoin, formatCurrency } from '@/utils/general.utils'
+import { formatPoints } from '@/utils/format.utils'
import { getAvatarUrl } from '@/utils/history.utils'
import {
formatIban,
@@ -1131,7 +1132,7 @@ export const TransactionDetailsReceipt = ({
value={
- {transaction.points}
+ {formatPoints(transaction.points)}
}
hideBottomBorder={shouldHideBorder('points')}
diff --git a/src/constants/points.consts.ts b/src/constants/points.consts.ts
index 431ba3b41..6a787ea54 100644
--- a/src/constants/points.consts.ts
+++ b/src/constants/points.consts.ts
@@ -1,16 +1,10 @@
/**
* Points System Constants
*
- * Shared constants for points display and calculations.
- * Should match backend values in peanut-api-ts/src/points/constants.ts
+ * Shared constants for points display.
+ * Transitivity multiplier is no longer hardcoded — use `contributedPoints` from API.
*/
-/**
- * Transitivity multiplier for referral points
- * Users earn this percentage of their invitees' points
- */
-export const TRANSITIVITY_MULTIPLIER = 0.5 // 50% of invitees' points
-
/**
* Tier thresholds for display purposes
* Note: Actual tier calculation happens on backend
diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts
new file mode 100644
index 000000000..28ab92b31
--- /dev/null
+++ b/src/hooks/useCountUp.ts
@@ -0,0 +1,86 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { animate } from 'framer-motion'
+
+const STORAGE_PREFIX = 'peanut_points_'
+
+interface UseCountUpOptions {
+ /** localStorage key suffix for remembering last-seen value across visits */
+ storageKey?: string
+ /** Animation duration in seconds (default: 1.5) */
+ duration?: number
+ /** Only start when true — use with intersection observer for scroll-triggered animations */
+ enabled?: boolean
+}
+
+/**
+ * Animates a number from a previous value to the current value.
+ *
+ * - If `storageKey` is provided, remembers the last-seen value in localStorage
+ * so returning to the page animates from the old value to the new one.
+ * - If `enabled` is false, waits to start (useful for scroll-into-view triggers).
+ * - Returns the current animated integer value.
+ */
+export function useCountUp(target: number, options: UseCountUpOptions = {}): number {
+ const { storageKey, duration = 1.5, enabled = true } = options
+
+ const [display, setDisplay] = useState(() => {
+ if (!storageKey) return target
+ if (typeof window === 'undefined') return target
+ const stored = localStorage.getItem(STORAGE_PREFIX + storageKey)
+ return stored ? parseInt(stored, 10) : target
+ })
+
+ const hasAnimated = useRef(false)
+ const controlsRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (!enabled || hasAnimated.current) return
+
+ const from = display
+ const to = target
+
+ // Nothing to animate
+ if (from === to) {
+ hasAnimated.current = true
+ if (storageKey) {
+ localStorage.setItem(STORAGE_PREFIX + storageKey, String(to))
+ }
+ return
+ }
+
+ hasAnimated.current = true
+
+ controlsRef.current = animate(from, to, {
+ duration,
+ ease: [0.25, 0.1, 0.25, 1], // cubic-bezier — fast start, smooth decel
+ onUpdate(value) {
+ setDisplay(Math.round(value))
+ },
+ onComplete() {
+ setDisplay(to)
+ if (storageKey) {
+ localStorage.setItem(STORAGE_PREFIX + storageKey, String(to))
+ }
+ },
+ })
+
+ return () => {
+ controlsRef.current?.stop()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- display intentionally excluded to avoid re-triggering
+ }, [enabled, target, duration, storageKey])
+
+ // if target changes after animation completed (e.g. refetch), update immediately
+ useEffect(() => {
+ if (hasAnimated.current && display !== target) {
+ setDisplay(target)
+ if (storageKey) {
+ localStorage.setItem(STORAGE_PREFIX + storageKey, String(target))
+ }
+ }
+ }, [target, display, storageKey])
+
+ return display
+}
diff --git a/src/services/points.ts b/src/services/points.ts
index 3e72d6b58..c0f1a99eb 100644
--- a/src/services/points.ts
+++ b/src/services/points.ts
@@ -341,7 +341,7 @@ export const pointsApi = {
getCashStatus: async (): Promise<{
success: boolean
data: {
- cashbackAllowance: number | null
+ hasCashbackLeft: boolean
lifetimeEarned: number
lifetimeBreakdown: {
cashback: number
diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts
index 7059a2143..c0f0b2742 100644
--- a/src/utils/format.utils.ts
+++ b/src/utils/format.utils.ts
@@ -1,3 +1,26 @@
+/**
+ * Format points for display with thousands separators (e.g. 564,554).
+ */
+export function formatPoints(points: number): string {
+ return points.toLocaleString('en-US')
+}
+
+/**
+ * Shorten large point values to compact form.
+ * Returns { number, suffix } so the suffix (K/M) can be styled separately.
+ */
+export function shortenPoints(points: number): { number: string; suffix: string } {
+ if (points >= 1_000_000) {
+ const m = points / 1_000_000
+ return { number: m >= 10 ? Math.round(m).toString() : m.toFixed(1).replace(/\.0$/, ''), suffix: 'M' }
+ }
+ if (points >= 1_000) {
+ const k = points / 1_000
+ return { number: k >= 10 ? Math.round(k).toString() : k.toFixed(1).replace(/\.0$/, ''), suffix: 'K' }
+ }
+ return { number: points.toString(), suffix: '' }
+}
+
export const sanitizeBankAccount = (value: string | undefined): string => {
if (!value) return ''
return value.replace(/[\s\-\._]/g, '').toLowerCase()
From 2e79309dd5f8832c9d3ecf3ce196baf880801ef2 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 18 Feb 2026 20:57:26 +0000
Subject: [PATCH 06/61] fix: useCountUp render loop causing flickering
animation
Remove display from second useEffect deps and add isAnimating guard
to prevent snap-to-target firing mid-animation.
---
src/hooks/useCountUp.ts | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts
index 28ab92b31..3498be3c9 100644
--- a/src/hooks/useCountUp.ts
+++ b/src/hooks/useCountUp.ts
@@ -33,7 +33,9 @@ export function useCountUp(target: number, options: UseCountUpOptions = {}): num
})
const hasAnimated = useRef(false)
+ const isAnimating = useRef(false)
const controlsRef = useRef | null>(null)
+ const prevTargetRef = useRef(target)
useEffect(() => {
if (!enabled || hasAnimated.current) return
@@ -41,7 +43,7 @@ export function useCountUp(target: number, options: UseCountUpOptions = {}): num
const from = display
const to = target
- // Nothing to animate
+ // nothing to animate
if (from === to) {
hasAnimated.current = true
if (storageKey) {
@@ -51,14 +53,16 @@ export function useCountUp(target: number, options: UseCountUpOptions = {}): num
}
hasAnimated.current = true
+ isAnimating.current = true
controlsRef.current = animate(from, to, {
duration,
- ease: [0.25, 0.1, 0.25, 1], // cubic-bezier — fast start, smooth decel
+ ease: [0.25, 0.1, 0.25, 1],
onUpdate(value) {
setDisplay(Math.round(value))
},
onComplete() {
+ isAnimating.current = false
setDisplay(to)
if (storageKey) {
localStorage.setItem(STORAGE_PREFIX + storageKey, String(to))
@@ -68,19 +72,21 @@ export function useCountUp(target: number, options: UseCountUpOptions = {}): num
return () => {
controlsRef.current?.stop()
+ isAnimating.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- display intentionally excluded to avoid re-triggering
}, [enabled, target, duration, storageKey])
- // if target changes after animation completed (e.g. refetch), update immediately
+ // if target changes after animation completed (e.g. refetch), snap to new value
useEffect(() => {
- if (hasAnimated.current && display !== target) {
+ if (prevTargetRef.current !== target && hasAnimated.current && !isAnimating.current) {
setDisplay(target)
if (storageKey) {
localStorage.setItem(STORAGE_PREFIX + storageKey, String(target))
}
}
- }, [target, display, storageKey])
+ prevTargetRef.current = target
+ }, [target, storageKey])
return display
}
From e8213978bad44874d18258e0ec49ae2636230f4b Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 18 Feb 2026 21:07:00 +0000
Subject: [PATCH 07/61] style: fix prettier formatting
---
src/app/(mobile-ui)/points/page.tsx | 19 ++++++++++++++++---
src/components/Points/CashCard.tsx | 4 +++-
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 9e67acf25..19d165a65 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -177,7 +177,10 @@ const PointsPage = () => {
{/* cash section */}
{cashStatus?.success && cashStatus.data && (
-
+
)}
@@ -213,7 +216,12 @@ const PointsPage = () => {
{/* if user has invites: show button above people list */}
{invites && invites?.invitees && invites.invitees.length > 0 ? (
<>
- setIsInviteModalOpen(true)} className="!mt-8 w-full">
+ setIsInviteModalOpen(true)}
+ className="!mt-8 w-full"
+ >
Share Invite link
@@ -278,7 +286,12 @@ const PointsPage = () => {
Send your invite link to start earning more rewards
- setIsInviteModalOpen(true)} className="w-full">
+ setIsInviteModalOpen(true)}
+ className="w-full"
+ >
Share Invite link
diff --git a/src/components/Points/CashCard.tsx b/src/components/Points/CashCard.tsx
index 3f4f14bcf..388b7dfa5 100644
--- a/src/components/Points/CashCard.tsx
+++ b/src/components/Points/CashCard.tsx
@@ -12,7 +12,9 @@ export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) =>
return (
-
Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
+
+ Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
+
Date: Wed, 18 Feb 2026 21:29:38 -0300
Subject: [PATCH 08/61] fix: kernel client stuck loading screen + retry logic
Cherry-picked from peanut-wallet-dev: - 71fe5d3: fix kernel client ready
state + transaction query key bug - f24d698: add retry logic before kernel
client logout - 79e5c6f: move state updates after primary client check to
prevent UI flicker
---
src/context/kernelClient.context.tsx | 18 +++++++++++--
src/hooks/useTransactionHistory.ts | 4 +--
src/utils/retry.utils.ts | 40 ++++++++++++++++++++++++++++
3 files changed, 57 insertions(+), 5 deletions(-)
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 66f8f145c..27a243771 100644
--- a/src/context/kernelClient.context.tsx
+++ b/src/context/kernelClient.context.tsx
@@ -16,6 +16,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useS
import { type Chain, http, type PublicClient, type Transport } from 'viem'
import type { Address } from 'viem'
import { captureException } from '@sentry/nextjs'
+import { retryAsync } from '@/utils/retry.utils'
import { PUBLIC_CLIENTS_BY_CHAIN } from '@/app/actions/clients'
interface KernelClientContextType {
@@ -231,16 +232,29 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => {
}
}
+ if (!newClientsByChain[PEANUT_WALLET_CHAIN.id]) {
+ throw new Error('Primary chain client failed to initialize')
+ }
+
+ // only update state after primary client check passes —
+ // avoids UI flicker (registering→not registering→registering) between retries
if (isMounted) {
- fetchUser()
setClientsByChain(newClientsByChain)
+ fetchUser()
dispatch(zerodevActions.setIsKernelClientReady(true))
dispatch(zerodevActions.setIsRegistering(false))
dispatch(zerodevActions.setIsLoggingIn(false))
}
}
- initializeClients()
+ retryAsync(initializeClients, { maxRetries: 2, baseDelay: 1000, maxDelay: 5000 }).catch(() => {
+ if (isMounted) {
+ console.error('[KernelClient] Primary chain client failed after retries — forcing logout')
+ dispatch(zerodevActions.setIsRegistering(false))
+ dispatch(zerodevActions.setIsLoggingIn(false))
+ logoutUser()
+ }
+ })
return () => {
isMounted = false
diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts
index dbf841f7b..1dde0a566 100644
--- a/src/hooks/useTransactionHistory.ts
+++ b/src/hooks/useTransactionHistory.ts
@@ -86,10 +86,8 @@ export function useTransactionHistory({
// Two-tier caching: TQ in-memory (30s) → SW disk cache (1 week) → Network
// Balance: Fresh enough for home page + reduces redundant SW cache hits
if (mode === 'latest') {
- // if filterMutualTxs is true, we need to add the username to the query key to invalidate the query when the username changes
- const queryKeyTxn = TRANSACTIONS + (filterMutualTxs ? username : '')
return useQuery({
- queryKey: [queryKeyTxn, 'latest', { limit }],
+ queryKey: [TRANSACTIONS, 'latest', { limit, targetUsername: filterMutualTxs ? username : undefined }],
queryFn: () => fetchHistory({ limit }),
enabled,
// 30s cache: Fresh enough for home page widget
diff --git a/src/utils/retry.utils.ts b/src/utils/retry.utils.ts
index fb9fd8309..983bf17dc 100644
--- a/src/utils/retry.utils.ts
+++ b/src/utils/retry.utils.ts
@@ -54,3 +54,43 @@ export const RETRY_STRATEGIES = {
retry: false,
},
} as const
+
+/**
+ * Generic async retry wrapper with exponential backoff.
+ * Use for imperative code outside of React Query (e.g. kernel client init).
+ *
+ * @param fn - Async function to retry. Return value is forwarded on success.
+ * @param options.maxRetries - Total retry attempts after the first failure (default: 2)
+ * @param options.baseDelay - Initial delay in ms before first retry (default: 1000)
+ * @param options.maxDelay - Cap on delay in ms (default: 5000)
+ * @param options.shouldRetry - Optional predicate; return false to bail early
+ * @returns The resolved value of `fn`
+ */
+export async function retryAsync(
+ fn: () => Promise,
+ {
+ maxRetries = 2,
+ baseDelay = 1000,
+ maxDelay = 5000,
+ shouldRetry,
+ }: {
+ maxRetries?: number
+ baseDelay?: number
+ maxDelay?: number
+ shouldRetry?: (error: unknown, attempt: number) => boolean
+ } = {}
+): Promise {
+ const backoff = createExponentialBackoff(baseDelay, maxDelay)
+ let lastError: unknown
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ return await fn()
+ } catch (error) {
+ lastError = error
+ if (attempt === maxRetries) break
+ if (shouldRetry && !shouldRetry(error, attempt)) break
+ await new Promise((r) => setTimeout(r, backoff(attempt)))
+ }
+ }
+ throw lastError
+}
From e6b966f55de3dfb2f1c32b2d447c6c696addb93f Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 19 Feb 2026 09:26:16 +0000
Subject: [PATCH 09/61] zerodev package update
---
package.json | 2 +-
pnpm-lock.yaml | 16 ++++++++--------
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/package.json b/package.json
index 4bfe240f7..75319f9b0 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"@vercel/analytics": "^1.4.1",
"@wagmi/core": "2.19.0",
"@zerodev/passkey-validator": "^5.6.0",
- "@zerodev/sdk": "5.5.0",
+ "@zerodev/sdk": "5.5.7",
"autoprefixer": "^10.4.20",
"canvas-confetti": "^1.9.3",
"classnames": "^2.5.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0df89f257..2e40bbc7c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -93,10 +93,10 @@ importers:
version: 2.19.0(@tanstack/query-core@5.8.3)(@types/react@18.3.27)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
'@zerodev/passkey-validator':
specifier: ^5.6.0
- version: 5.6.0(@zerodev/sdk@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
+ version: 5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
'@zerodev/sdk':
- specifier: 5.5.0
- version: 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
+ specifier: 5.5.7
+ version: 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.6)
@@ -3155,8 +3155,8 @@ packages:
'@zerodev/webauthn-key': ^5.4.2
viem: ^2.22.0
- '@zerodev/sdk@5.5.0':
- resolution: {integrity: sha512-S8m7u6QiSbhKpxv/mpxRODZFLtz35+PFY7FG5DSPsToTPH05BfWEgy9nSgrsgdAv6ZDhDfwCG3qiVmBQF0vt6Q==}
+ '@zerodev/sdk@5.5.7':
+ resolution: {integrity: sha512-Sf4G13yi131H8ujun64obvXIpk1UWn64GiGJjfvGx8aIKg+OWTRz9AZHgGKK+bE/evAmqIg4nchuSvKPhOau1w==}
peerDependencies:
viem: ^2.22.0
@@ -11112,15 +11112,15 @@ snapshots:
'@xtuc/long@4.2.2': {}
- '@zerodev/passkey-validator@5.6.0(@zerodev/sdk@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))':
+ '@zerodev/passkey-validator@5.6.0(@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(@zerodev/webauthn-key@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))':
dependencies:
'@noble/curves': 1.9.7
'@simplewebauthn/browser': 8.3.7
- '@zerodev/sdk': 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
+ '@zerodev/sdk': 5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
'@zerodev/webauthn-key': 5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))
viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
- '@zerodev/sdk@5.5.0(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))':
+ '@zerodev/sdk@5.5.7(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))':
dependencies:
semver: 7.7.3
viem: 2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
From 2dbe78fe2e644926b6336a741f2f1ab3c0b6f650 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 21 Feb 2026 23:13:46 +0000
Subject: [PATCH 10/61] feat: MDX content rendering pipeline with interactive
components
Replace i18n template-string page rendering with MDX-driven content.
LLM-generated markdown now embeds React components directly:
- Add next-mdx-remote + remark-gfm for server-side MDX compilation
- New MDX component library (src/components/Marketing/mdx/):
Hero, Steps, FAQ, CTA, Callout, RelatedPages, CountryGrid,
ExchangeWidget, ProseStars (animated stars on scroll)
- ContentPage wrapper with BreadcrumbList JSON-LD
- Rewrite send-money-to/[country] route to compile MDX content
- Refactor i18n: split es.json/pt.json into locale-specific files
(es-419, es-ar, es-es, pt-br) matching content locales
- Refactor data loaders to read from content submodule
- Improve CSS spring animation for bouncier star entrance
- Remove MarketingNav and duplicate marquee from layout
---
.gitmodules | 3 +
package.json | 2 +
pnpm-lock.yaml | 1124 ++++++++++++++++-
src/app/[locale]/(marketing)/layout.tsx | 12 +-
.../send-money-to/[country]/page.tsx | 43 +-
src/app/sitemap.ts | 7 +-
src/components/Marketing/ContentPage.tsx | 35 +
src/components/Marketing/DestinationGrid.tsx | 10 +-
src/components/Marketing/mdx/CTA.tsx | 81 ++
src/components/Marketing/mdx/Callout.tsx | 30 +
src/components/Marketing/mdx/CountryGrid.tsx | 37 +
.../Marketing/mdx/ExchangeWidget.tsx | 55 +
src/components/Marketing/mdx/FAQ.tsx | 83 ++
src/components/Marketing/mdx/Hero.tsx | 59 +
src/components/Marketing/mdx/ProseStars.tsx | 55 +
src/components/Marketing/mdx/RelatedPages.tsx | 70 +
src/components/Marketing/mdx/Stars.tsx | 36 +
src/components/Marketing/mdx/Steps.tsx | 90 ++
src/components/Marketing/mdx/components.tsx | 102 ++
src/components/Marketing/mdx/constants.ts | 7 +
.../Marketing/pages/HubPageContent.tsx | 12 -
src/content | 2 +-
src/data/seo/comparisons.ts | 176 ++-
src/data/seo/convert.ts | 85 +-
src/data/seo/corridors.ts | 256 +++-
src/data/seo/exchanges.ts | 170 ++-
src/data/seo/payment-methods.ts | 118 +-
src/i18n/config.ts | 13 +-
src/i18n/{es.json => es-419.json} | 0
src/i18n/es-ar.json | 63 +
src/i18n/es-es.json | 63 +
src/i18n/index.ts | 14 +-
src/i18n/{pt.json => pt-br.json} | 0
src/i18n/types.ts | 4 +-
src/lib/content.ts | 221 +++-
src/lib/mdx.ts | 29 +
src/styles/globals.css | 39 +-
37 files changed, 2904 insertions(+), 302 deletions(-)
create mode 100644 src/components/Marketing/ContentPage.tsx
create mode 100644 src/components/Marketing/mdx/CTA.tsx
create mode 100644 src/components/Marketing/mdx/Callout.tsx
create mode 100644 src/components/Marketing/mdx/CountryGrid.tsx
create mode 100644 src/components/Marketing/mdx/ExchangeWidget.tsx
create mode 100644 src/components/Marketing/mdx/FAQ.tsx
create mode 100644 src/components/Marketing/mdx/Hero.tsx
create mode 100644 src/components/Marketing/mdx/ProseStars.tsx
create mode 100644 src/components/Marketing/mdx/RelatedPages.tsx
create mode 100644 src/components/Marketing/mdx/Stars.tsx
create mode 100644 src/components/Marketing/mdx/Steps.tsx
create mode 100644 src/components/Marketing/mdx/components.tsx
create mode 100644 src/components/Marketing/mdx/constants.ts
mode change 120000 => 160000 src/content
rename src/i18n/{es.json => es-419.json} (100%)
create mode 100644 src/i18n/es-ar.json
create mode 100644 src/i18n/es-es.json
rename src/i18n/{pt.json => pt-br.json} (100%)
create mode 100644 src/lib/mdx.ts
diff --git a/.gitmodules b/.gitmodules
index 38ee7fcbe..3a9a0b59d 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
[submodule "src/assets/animations"]
path = src/assets/animations
url = https://github.com/peanutprotocol/peanut-animations.git
+[submodule "src/content"]
+ path = src/content
+ url = https://github.com/peanutprotocol/peanut-content.git
diff --git a/package.json b/package.json
index 5f0d7e502..05eff6347 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"jsqr": "^1.4.0",
"marked": "^17.0.2",
"next": "16.0.10",
+ "next-mdx-remote": "^6.0.0",
"nuqs": "^2.8.6",
"pix-utils": "^2.8.2",
"pulltorefreshjs": "^0.1.22",
@@ -81,6 +82,7 @@
"react-redux": "^9.2.0",
"react-tooltip": "^5.28.0",
"redux": "^5.0.1",
+ "remark-gfm": "^4.0.1",
"shiki": "^3.22.0",
"siwe": "^2.3.2",
"tailwind-merge": "^1.14.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 49bad01fc..f805968f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -30,7 +30,7 @@ importers:
version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@headlessui/tailwindcss':
specifier: ^0.2.1
- version: 0.2.2(tailwindcss@3.4.19(tsx@4.21.0))
+ version: 0.2.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
'@justaname.id/react':
specifier: 0.3.180
version: 0.3.180(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(react@19.2.4)(siwe@2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)))(typescript@5.9.3)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.16.3(@tanstack/query-core@5.8.3)(@tanstack/react-query@5.8.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@18.3.27)(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.45.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6)
@@ -139,6 +139,9 @@ importers:
next:
specifier: 16.0.10
version: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-mdx-remote:
+ specifier: ^6.0.0
+ version: 6.0.0(@types/react@18.3.27)(react@19.2.4)
nuqs:
specifier: ^2.8.6
version: 2.8.6(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
@@ -181,6 +184,9 @@ importers:
redux:
specifier: ^5.0.1
version: 5.0.1
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
shiki:
specifier: ^3.22.0
version: 3.22.0
@@ -192,7 +198,7 @@ importers:
version: 1.14.0
tailwind-scrollbar:
specifier: ^3.1.0
- version: 3.1.0(tailwindcss@3.4.19(tsx@4.21.0))
+ version: 3.1.0(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
use-haptic:
specifier: ^1.1.11
version: 1.1.13
@@ -283,7 +289,7 @@ importers:
version: 11.2.0
tailwindcss:
specifier: ^3.4.15
- version: 3.4.19(tsx@4.21.0)
+ version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
ts-jest:
specifier: ^29.1.2
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.9.3)
@@ -1345,6 +1351,15 @@ packages:
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
+ '@mdx-js/mdx@3.1.1':
+ resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
+
+ '@mdx-js/react@3.1.1':
+ resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
+ peerDependencies:
+ '@types/react': '>=16'
+ react: '>=16'
+
'@metamask/eth-json-rpc-provider@1.0.1':
resolution: {integrity: sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==}
engines: {node: '>=14.0.0'}
@@ -2834,6 +2849,9 @@ packages:
'@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2870,6 +2888,9 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdx@2.0.13':
+ resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
+
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -2925,6 +2946,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -3240,6 +3264,11 @@ packages:
peerDependencies:
acorn: ^8.14.0
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
@@ -3334,6 +3363,10 @@ packages:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
+ astring@1.9.0:
+ resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
+ hasBin: true
+
async-mutex@0.2.6:
resolution: {integrity: sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw==}
@@ -3395,6 +3428,9 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -3576,6 +3612,12 @@ packages:
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -3629,6 +3671,9 @@ packages:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
+ collapse-white-space@2.1.0:
+ resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
+
collect-v8-coverage@1.0.3:
resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==}
@@ -3848,6 +3893,9 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
@@ -4047,6 +4095,12 @@ packages:
es-toolkit@1.33.0:
resolution: {integrity: sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg==}
+ esast-util-from-estree@2.0.0:
+ resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
+
+ esast-util-from-js@2.0.1:
+ resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
+
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@@ -4064,6 +4118,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
escodegen@2.1.0:
resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
engines: {node: '>=6.0'}
@@ -4095,9 +4153,30 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-util-attach-comments@3.0.0:
+ resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==}
+
+ estree-util-build-jsx@3.0.1:
+ resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==}
+
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
+ estree-util-scope@1.0.0:
+ resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==}
+
+ estree-util-to-js@2.0.0:
+ resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==}
+
+ estree-util-visit@2.0.0:
+ resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==}
+
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -4154,6 +4233,9 @@ packages:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
extension-port-stream@3.0.0:
resolution: {integrity: sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw==}
engines: {node: '>=12.0.0'}
@@ -4423,9 +4505,15 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hast-util-to-estree@3.1.3:
+ resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
+
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -4525,6 +4613,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
@@ -4536,6 +4627,12 @@ packages:
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
is-arguments@1.2.0:
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
engines: {node: '>= 0.4'}
@@ -4555,6 +4652,9 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
@@ -4579,10 +4679,17 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
@@ -4940,6 +5047,9 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -4979,6 +5089,13 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
+ markdown-extensions@2.0.0:
+ resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
+ engines: {node: '>=16'}
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
marked@17.0.2:
resolution: {integrity: sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==}
engines: {node: '>= 20'}
@@ -4988,9 +5105,54 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.2:
+ resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdx@3.0.0:
+ resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -5001,21 +5163,111 @@ packages:
micro-ftch@0.3.1:
resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==}
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-extension-mdx-expression@3.0.1:
+ resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==}
+
+ micromark-extension-mdx-jsx@3.0.2:
+ resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==}
+
+ micromark-extension-mdx-md@2.0.0:
+ resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==}
+
+ micromark-extension-mdxjs-esm@3.0.0:
+ resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==}
+
+ micromark-extension-mdxjs@3.0.0:
+ resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-mdx-expression@2.0.3:
+ resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+ micromark-util-events-to-acorn@2.0.3:
+ resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -5120,6 +5372,12 @@ packages:
resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==}
engines: {node: '>= 0.4.0'}
+ next-mdx-remote@6.0.0:
+ resolution: {integrity: sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==}
+ engines: {node: '>=14', npm: '>=7'}
+ peerDependencies:
+ react: '>=16'
+
next@16.0.10:
resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
engines: {node: '>=20.9.0'}
@@ -5296,6 +5554,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
@@ -5778,6 +6039,20 @@ packages:
resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==}
engines: {node: '>= 12.13.0'}
+ recma-build-jsx@1.0.0:
+ resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
+
+ recma-jsx@1.0.1:
+ resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ recma-parse@1.0.0:
+ resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==}
+
+ recma-stringify@1.0.0:
+ resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -5799,6 +6074,24 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+ rehype-recma@1.0.0:
+ resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-mdx@3.1.1:
+ resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -6038,6 +6331,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ source-map@0.7.6:
+ resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+ engines: {node: '>= 12'}
+
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
@@ -6143,6 +6440,12 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -6287,6 +6590,9 @@ packages:
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -6376,12 +6682,21 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+ unist-util-position-from-estree@2.0.0:
+ resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==}
+
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+ unist-util-remove@4.0.0:
+ resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==}
+
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -6557,6 +6872,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ vfile-matter@5.0.1:
+ resolution: {integrity: sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==}
+
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -6765,6 +7083,11 @@ packages:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
+ yaml@2.8.2:
+ resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
@@ -7804,9 +8127,9 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
- '@headlessui/tailwindcss@0.2.2(tailwindcss@3.4.19(tsx@4.21.0))':
+ '@headlessui/tailwindcss@0.2.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
- tailwindcss: 3.4.19(tsx@4.21.0)
+ tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
'@img/colour@1.0.0':
optional: true
@@ -8152,6 +8475,42 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.1
+ '@mdx-js/mdx@3.1.1':
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdx': 2.0.13
+ acorn: 8.15.0
+ collapse-white-space: 2.1.0
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ estree-util-scope: 1.0.0
+ estree-walker: 3.0.3
+ hast-util-to-jsx-runtime: 2.3.6
+ markdown-extensions: 2.0.0
+ recma-build-jsx: 1.0.0
+ recma-jsx: 1.0.1(acorn@8.15.0)
+ recma-stringify: 1.0.0
+ rehype-recma: 1.0.0
+ remark-mdx: 3.1.1
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ source-map: 0.7.6
+ unified: 11.0.5
+ unist-util-position-from-estree: 2.0.0
+ unist-util-stringify-position: 4.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@mdx-js/react@3.1.1(@types/react@18.3.27)(react@19.2.4)':
+ dependencies:
+ '@types/mdx': 2.0.13
+ '@types/react': 18.3.27
+ react: 19.2.4
+
'@metamask/eth-json-rpc-provider@1.0.1':
dependencies:
'@metamask/json-rpc-engine': 7.3.3
@@ -10337,6 +10696,10 @@ snapshots:
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
'@types/estree@1.0.8': {}
'@types/graceful-fs@4.1.9':
@@ -10378,6 +10741,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/mdx@2.0.13': {}
+
'@types/ms@2.1.0': {}
'@types/mysql@2.15.26':
@@ -10429,6 +10794,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
+ '@types/unist@2.0.11': {}
+
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@@ -11371,6 +11738,10 @@ snapshots:
dependencies:
acorn: 8.15.0
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
@@ -11450,6 +11821,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ astring@1.9.0: {}
+
async-mutex@0.2.6:
dependencies:
tslib: 2.8.1
@@ -11542,6 +11915,8 @@ snapshots:
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6)
+ bail@2.0.2: {}
+
balanced-match@1.0.2: {}
bare-events@2.8.2: {}
@@ -11704,6 +12079,10 @@ snapshots:
character-entities-legacy@3.0.0: {}
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -11758,6 +12137,8 @@ snapshots:
co@4.6.0: {}
+ collapse-white-space@2.1.0: {}
+
collect-v8-coverage@1.0.3: {}
color-convert@2.0.1:
@@ -11964,6 +12345,10 @@ snapshots:
decimal.js@10.6.0: {}
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
decode-uri-component@0.2.2: {}
dedent@1.7.1(babel-plugin-macros@3.1.0):
@@ -12158,6 +12543,20 @@ snapshots:
es-toolkit@1.33.0: {}
+ esast-util-from-estree@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ devlop: 1.1.0
+ estree-util-visit: 2.0.0
+ unist-util-position-from-estree: 2.0.0
+
+ esast-util-from-js@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ acorn: 8.15.0
+ esast-util-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@@ -12193,6 +12592,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escape-string-regexp@5.0.0: {}
+
escodegen@2.1.0:
dependencies:
esprima: 4.0.1
@@ -12231,8 +12632,41 @@ snapshots:
estraverse@5.3.0: {}
+ estree-util-attach-comments@3.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ estree-util-build-jsx@3.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ estree-walker: 3.0.3
+
+ estree-util-is-identifier-name@3.0.0: {}
+
+ estree-util-scope@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+
+ estree-util-to-js@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ astring: 1.9.0
+ source-map: 0.7.6
+
+ estree-util-visit@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/unist': 3.0.3
+
estree-walker@2.0.2: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
esutils@2.0.3: {}
eth-block-tracker@7.1.0:
@@ -12345,6 +12779,8 @@ snapshots:
dependencies:
is-extendable: 0.1.1
+ extend@3.0.2: {}
+
extension-port-stream@3.0.0:
dependencies:
readable-stream: 3.6.2
@@ -12648,6 +13084,27 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-to-estree@3.1.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-attach-comments: 3.0.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ zwitch: 2.0.4
+ transitivePeerDependencies:
+ - supports-color
+
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
@@ -12662,6 +13119,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -12769,12 +13246,21 @@ snapshots:
inherits@2.0.4: {}
+ inline-style-parser@0.2.7: {}
+
internmap@2.0.3: {}
ip-address@10.1.0: {}
iron-webcrypto@1.2.1: {}
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
is-arguments@1.2.0:
dependencies:
call-bound: 1.0.4
@@ -12792,6 +13278,8 @@ snapshots:
dependencies:
hasown: 2.0.2
+ is-decimal@2.0.1: {}
+
is-extendable@0.1.1: {}
is-extglob@2.1.1: {}
@@ -12812,8 +13300,12 @@ snapshots:
dependencies:
is-extglob: 2.1.1
+ is-hexadecimal@2.0.1: {}
+
is-number@7.0.0: {}
+ is-plain-obj@4.1.0: {}
+
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -13379,6 +13871,8 @@ snapshots:
lodash@4.17.23: {}
+ longest-streak@3.1.0: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -13413,57 +13907,459 @@ snapshots:
dependencies:
tmpl: 1.0.5
+ markdown-extensions@2.0.0: {}
+
+ markdown-table@3.0.4: {}
+
marked@17.0.2: {}
math-intrinsics@1.1.0: {}
- mdast-util-to-hast@13.2.1:
+ mdast-util-find-and-replace@3.0.2:
dependencies:
- '@types/hast': 3.0.4
'@types/mdast': 4.0.4
- '@ungap/structured-clone': 1.3.0
- devlop: 1.1.0
- micromark-util-sanitize-uri: 2.0.1
- trim-lines: 3.0.1
- unist-util-position: 5.0.0
- unist-util-visit: 5.1.0
- vfile: 6.0.3
-
- merge-stream@2.0.0: {}
-
- merge2@1.4.1: {}
-
- micro-ftch@0.3.1: {}
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
- micromark-util-character@2.1.1:
+ mdast-util-from-markdown@2.0.2:
dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
- micromark-util-encode@2.0.1: {}
-
- micromark-util-sanitize-uri@2.0.1:
+ mdast-util-gfm-autolink-literal@2.0.1:
dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
- micromark-util-encode: 2.0.1
- micromark-util-symbol: 2.0.1
-
- micromark-util-symbol@2.0.1: {}
-
- micromark-util-types@2.0.2: {}
- micromatch@4.0.8:
+ mdast-util-gfm-footnote@2.1.0:
dependencies:
- braces: 3.0.3
- picomatch: 2.3.1
-
- mime-db@1.52.0: {}
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
- mime-types@2.1.35:
+ mdast-util-gfm-strikethrough@2.0.0:
dependencies:
- mime-db: 1.52.0
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
- mimic-fn@2.1.0: {}
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx@3.0.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ merge-stream@2.0.0: {}
+
+ merge2@1.4.1: {}
+
+ micro-ftch@0.3.1: {}
+
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdx-expression@3.0.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-factory-mdx-expression: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdx-jsx@3.0.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ micromark-factory-mdx-expression: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ vfile-message: 4.0.3
+
+ micromark-extension-mdx-md@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdxjs-esm@3.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-position-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
+ micromark-extension-mdxjs@3.0.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ micromark-extension-mdx-expression: 3.0.1
+ micromark-extension-mdx-jsx: 3.0.2
+ micromark-extension-mdx-md: 2.0.0
+ micromark-extension-mdxjs-esm: 3.0.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-mdx-expression@2.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-position-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-events-to-acorn@2.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ estree-util-visit: 2.0.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ vfile-message: 4.0.3
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.12
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ mimic-fn@2.1.0: {}
min-indent@1.0.1: {}
@@ -13529,6 +14425,20 @@ snapshots:
netmask@2.0.2: {}
+ next-mdx-remote@6.0.0(@types/react@18.3.27)(react@19.2.4):
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@mdx-js/mdx': 3.1.1
+ '@mdx-js/react': 3.1.1(@types/react@18.3.27)(react@19.2.4)
+ react: 19.2.4
+ unist-util-remove: 4.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ vfile-matter: 5.0.1
+ transitivePeerDependencies:
+ - '@types/react'
+ - supports-color
+
next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.0.10
@@ -13734,6 +14644,16 @@ snapshots:
dependencies:
callsites: 3.1.0
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.28.6
@@ -13854,13 +14774,14 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.6
- postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.6
tsx: 4.21.0
+ yaml: 2.8.2
postcss-nested@6.2.0(postcss@8.5.6):
dependencies:
@@ -14156,6 +15077,35 @@ snapshots:
real-require@0.1.0: {}
+ recma-build-jsx@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-util-build-jsx: 3.0.1
+ vfile: 6.0.3
+
+ recma-jsx@1.0.1(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ estree-util-to-js: 2.0.0
+ recma-parse: 1.0.0
+ recma-stringify: 1.0.0
+ unified: 11.0.5
+
+ recma-parse@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ esast-util-from-js: 2.0.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ recma-stringify@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-util-to-js: 2.0.0
+ unified: 11.0.5
+ vfile: 6.0.3
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -14177,6 +15127,55 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
+ rehype-recma@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ hast-util-to-estree: 3.1.3
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-mdx@3.1.1:
+ dependencies:
+ mdast-util-mdx: 3.0.0
+ micromark-extension-mdxjs: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -14463,6 +15462,8 @@ snapshots:
source-map@0.6.1: {}
+ source-map@0.7.6: {}
+
source-map@0.8.0-beta.0:
dependencies:
whatwg-url: 7.1.0
@@ -14567,6 +15568,14 @@ snapshots:
strip-json-comments@5.0.3: {}
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
styled-jsx@5.1.6(@babel/core@7.28.6)(babel-plugin-macros@3.1.0)(react@19.2.4):
dependencies:
client-only: 0.0.1
@@ -14605,11 +15614,11 @@ snapshots:
tailwind-merge@1.14.0: {}
- tailwind-scrollbar@3.1.0(tailwindcss@3.4.19(tsx@4.21.0)):
+ tailwind-scrollbar@3.1.0(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
- tailwindcss: 3.4.19(tsx@4.21.0)
+ tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
- tailwindcss@3.4.19(tsx@4.21.0):
+ tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -14628,7 +15637,7 @@ snapshots:
postcss: 8.5.6
postcss-import: 15.1.0(postcss@8.5.6)
postcss-js: 4.1.0(postcss@8.5.6)
- postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
@@ -14740,6 +15749,8 @@ snapshots:
trim-lines@3.0.1: {}
+ trough@2.2.0: {}
+
ts-interface-checker@0.1.13: {}
ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.6))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.4.2)(babel-plugin-macros@3.1.0))(typescript@5.9.3):
@@ -14808,14 +15819,34 @@ snapshots:
uncrypto@0.1.3: {}
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
+ unist-util-position-from-estree@2.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
unist-util-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
+ unist-util-remove@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -14946,6 +15977,11 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ vfile-matter@5.0.1:
+ dependencies:
+ vfile: 6.0.3
+ yaml: 2.8.2
+
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -15210,6 +16246,8 @@ snapshots:
yaml@1.10.2: {}
+ yaml@2.8.2: {}
+
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
diff --git a/src/app/[locale]/(marketing)/layout.tsx b/src/app/[locale]/(marketing)/layout.tsx
index 2c362170e..a232087b7 100644
--- a/src/app/[locale]/(marketing)/layout.tsx
+++ b/src/app/[locale]/(marketing)/layout.tsx
@@ -1,9 +1,7 @@
import { notFound } from 'next/navigation'
-import { isValidLocale, SUPPORTED_LOCALES } from '@/i18n/config'
-import { MarketingNav } from '@/components/Marketing/MarketingNav'
+import { SUPPORTED_LOCALES } from '@/i18n/types'
+import { isValidLocale } from '@/i18n/config'
import Footer from '@/components/LandingPage/Footer'
-import { MarqueeComp } from '@/components/Global/MarqueeWrapper'
-import { HandThumbsUp } from '@/assets'
interface LayoutProps {
children: React.ReactNode
@@ -24,13 +22,7 @@ export default async function LocalizedMarketingLayout({ children, params }: Lay
return (
-
{children}
-
)
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
index 39c828d55..382b9b416 100644
--- a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -2,10 +2,13 @@ import { notFound } from 'next/navigation'
import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
-import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale, localizedPath } from '@/i18n/config'
import type { Locale } from '@/i18n/types'
import { getTranslations, t } from '@/i18n'
import { CorridorPageContent } from '@/components/Marketing/pages/CorridorPageContent'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
interface PageProps {
@@ -25,6 +28,23 @@ export async function generateMetadata({ params }: PageProps): Promise
const seo = COUNTRIES_SEO[country]
if (!seo) return {}
+ // Try MDX content frontmatter first
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>('send-to', country, locale)
+ if (mdxContent && mdxContent.frontmatter.published !== false) {
+ return {
+ ...metadataHelper({
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
+ canonical: `/${locale}/send-money-to/${country}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/send-money-to/${country}`,
+ languages: getAlternates('send-money-to', country),
+ },
+ }
+ }
+
+ // Fallback: i18n-based metadata
const i18n = getTranslations(locale as Locale)
const countryName = getCountryName(country, locale as Locale)
const mapping = countryCurrencyMappings.find(
@@ -51,5 +71,26 @@ export default async function SendMoneyToCountryPageLocalized({ params }: PagePr
const { locale, country } = await params
if (!isValidLocale(locale)) notFound()
+ // Try MDX content first
+ const mdxSource = readPageContentLocalized('send-to', 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}
+
+ )
+ }
+
+ // Fallback: old i18n-based page content
return
}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 22e2dad20..979147220 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,6 +1,6 @@
import { type MetadataRoute } from 'next'
import { BASE_URL } from '@/constants/general.consts'
-import { COUNTRIES_SEO, CORRIDORS, CONVERT_PAIRS, COMPETITORS, EXCHANGES } from '@/data/seo'
+import { COUNTRIES_SEO, CORRIDORS, CONVERT_PAIRS, COMPETITORS, EXCHANGES, PAYMENT_METHOD_SLUGS } from '@/data/seo'
import { getAllPosts } from '@/lib/blog'
import { SUPPORTED_LOCALES } from '@/i18n/config'
import type { Locale } from '@/i18n/types'
@@ -11,9 +11,6 @@ import type { Locale } from '@/i18n/types'
// TODO (infra): Add peanut.me to Google Search Console and submit this sitemap
// TODO (GA4): Create data filter to exclude trafficheap.com referral traffic
-/** Payment methods with dedicated pages */
-const PAYMENT_METHODS = ['pix', 'mercadopago', 'spei', 'bank-transfer'] as const
-
async function generateSitemap(): Promise {
type SitemapEntry = {
path: string
@@ -100,7 +97,7 @@ async function generateSitemap(): Promise {
}
// Pay-with pages
- for (const method of PAYMENT_METHODS) {
+ for (const method of PAYMENT_METHOD_SLUGS) {
pages.push({
path: `/${locale}/pay-with/${method}`,
priority: 0.7 * basePriority,
diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx
new file mode 100644
index 000000000..effa1c112
--- /dev/null
+++ b/src/components/Marketing/ContentPage.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from 'react'
+import { JsonLd } from './JsonLd'
+import { BASE_URL } from '@/constants/general.consts'
+
+interface ContentPageProps {
+ /** Compiled MDX content element */
+ children: ReactNode
+ /** Breadcrumb items: [{name, href}] */
+ breadcrumbs: Array<{ name: string; href: string }>
+}
+
+/**
+ * Universal wrapper for MDX-rendered marketing pages.
+ * Handles BreadcrumbList JSON-LD only — the MDX body owns all layout
+ * (Hero is full-bleed, prose sections are contained, Steps/FAQ break out).
+ */
+export function ContentPage({ children, breadcrumbs }: ContentPageProps) {
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: breadcrumbs.map((crumb, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: crumb.name,
+ item: crumb.href.startsWith('http') ? crumb.href : `${BASE_URL}${crumb.href}`,
+ })),
+ }
+
+ return (
+ <>
+
+ {children}
+ >
+ )
+}
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
index 879ea7f22..121f38ce7 100644
--- a/src/components/Marketing/DestinationGrid.tsx
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -3,17 +3,21 @@ import { Card } from '@/components/0_Bruddle/Card'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
import { localizedPath } from '@/i18n/config'
+import { CARD_HOVER } from '@/components/Marketing/mdx/constants'
import type { Locale } from '@/i18n/types'
interface DestinationGridProps {
/** If provided, only show these country slugs */
countries?: string[]
+ /** Country slug to exclude from the grid */
+ exclude?: string
title?: string
locale?: Locale
}
-export function DestinationGrid({ countries, title = 'Send money to', locale = 'en' }: DestinationGridProps) {
- const slugs = countries ?? Object.keys(COUNTRIES_SEO)
+export function DestinationGrid({ countries, exclude, title = 'Send money to', locale = 'en' }: DestinationGridProps) {
+ let slugs = countries ?? Object.keys(COUNTRIES_SEO)
+ if (exclude) slugs = slugs.filter((s) => s !== exclude)
return (
@@ -32,7 +36,7 @@ export function DestinationGrid({ countries, title = 'Send money to', locale = '
{flagCode && (
+
+ {text} →
+
+
+ )
+ }
+
+ if (variant === 'card') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/mdx/Callout.tsx b/src/components/Marketing/mdx/Callout.tsx
new file mode 100644
index 000000000..98062d22c
--- /dev/null
+++ b/src/components/Marketing/mdx/Callout.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from 'react'
+import { Card } from '@/components/0_Bruddle/Card'
+import { PROSE_WIDTH } from './constants'
+
+interface CalloutProps {
+ type?: 'info' | 'tip' | 'warning'
+ children: ReactNode
+}
+
+const STYLES: Record
= {
+ info: { bg: 'bg-primary-3/20', border: 'border-primary-3', label: 'Info' },
+ tip: { bg: 'bg-green-50', border: 'border-green-300', label: 'Tip' },
+ warning: { bg: 'bg-yellow-50', border: 'border-yellow-300', label: 'Important' },
+}
+
+/** Highlighted callout box for tips, warnings, or important info. */
+export function Callout({ type = 'info', children }: CalloutProps) {
+ const style = STYLES[type] ?? STYLES.info
+
+ return (
+
+
+
+ {style.label}
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/Marketing/mdx/CountryGrid.tsx b/src/components/Marketing/mdx/CountryGrid.tsx
new file mode 100644
index 000000000..f25de0836
--- /dev/null
+++ b/src/components/Marketing/mdx/CountryGrid.tsx
@@ -0,0 +1,37 @@
+import { DestinationGrid } from '@/components/Marketing/DestinationGrid'
+
+interface CountryGridProps {
+ /** Comma-separated country slugs to show. If omitted, shows all countries. */
+ countries?: string
+ /** Country slug to exclude (typically the current page's country). */
+ exclude?: string
+ title?: string
+}
+
+/**
+ * MDX wrapper for DestinationGrid. Renders a flag+name grid of country links.
+ * Like Wise's "Send money to other countries" section.
+ *
+ * Usage in MDX:
+ *
+ *
+ */
+export function CountryGrid({ countries, exclude, title = 'Send money to other countries' }: CountryGridProps) {
+ let slugs: string[] | undefined
+
+ if (countries) {
+ slugs = countries.split(',').map((s) => s.trim())
+ }
+
+ if (exclude && slugs) {
+ slugs = slugs.filter((s) => s !== exclude)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/mdx/ExchangeWidget.tsx b/src/components/Marketing/mdx/ExchangeWidget.tsx
new file mode 100644
index 000000000..10ab03de4
--- /dev/null
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import { Suspense, useEffect } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
+import ExchangeRateWidget from '@/components/Global/ExchangeRateWidget'
+
+interface ExchangeWidgetProps {
+ destinationCurrency?: string
+}
+
+function ExchangeWidgetInner({ destinationCurrency }: ExchangeWidgetProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ // Set initial destination currency in URL if not already set
+ useEffect(() => {
+ if (destinationCurrency && !searchParams.get('to')) {
+ const params = new URLSearchParams(searchParams.toString())
+ params.set('to', destinationCurrency)
+ if (!params.get('from')) params.set('from', 'USD')
+ router.replace(`?${params.toString()}`, { scroll: false })
+ }
+ }, [destinationCurrency, searchParams, router])
+
+ return (
+
+
+ {
+ router.push(`/send?from=${from}&to=${to}`)
+ }}
+ />
+
+
+ )
+}
+
+/** Embeddable exchange rate calculator for MDX content pages. */
+export function ExchangeWidget({ destinationCurrency }: ExchangeWidgetProps) {
+ return (
+
+
+
+ }
+ >
+
+
+ )
+}
diff --git a/src/components/Marketing/mdx/FAQ.tsx b/src/components/Marketing/mdx/FAQ.tsx
new file mode 100644
index 000000000..e9b1a11b7
--- /dev/null
+++ b/src/components/Marketing/mdx/FAQ.tsx
@@ -0,0 +1,83 @@
+import { Children, isValidElement, type ReactNode } from 'react'
+import { FAQsPanel } from '@/components/Global/FAQs'
+import { PeanutsBG } from '@/assets'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+
+interface FAQItemProps {
+ question: string
+ children: ReactNode
+}
+
+/** Individual FAQ item. Used as a child of . */
+export function FAQItem({ question, children }: FAQItemProps) {
+ // FAQItem doesn't render on its own — FAQ collects these via children.
+ // This exists for type safety and readability in MDX content.
+ return (
+
+ {children}
+
+ )
+}
+
+interface FAQProps {
+ title?: string
+ children: ReactNode
+}
+
+/** Extract text content from React nodes for JSON-LD plain text */
+function extractText(node: ReactNode): string {
+ if (typeof node === 'string') return node
+ if (typeof node === 'number') return String(node)
+ if (!node) return ''
+ if (Array.isArray(node)) return node.map(extractText).join('')
+ if (isValidElement(node)) return extractText(node.props.children)
+ return ''
+}
+
+/**
+ * MDX FAQ component. Purple section with peanut pattern overlay,
+ * animated accordion, and FAQPage JSON-LD. Matches LP styling exactly.
+ */
+export function FAQ({ title = 'FAQ', children }: FAQProps) {
+ // Collect FAQItem children into question/answer pairs
+ const questions: Array<{ id: string; question: string; answer: string }> = []
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return
+ if (child.type === FAQItem || child.props?.question) {
+ const id = `faq-${questions.length}`
+ questions.push({
+ id,
+ question: child.props.question,
+ answer: extractText(child.props.children),
+ })
+ }
+ })
+
+ if (questions.length === 0) return null
+
+ const faqSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: questions.map((q) => ({
+ '@type': 'Question',
+ name: q.question,
+ acceptedAnswer: { '@type': 'Answer', text: q.answer },
+ })),
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/mdx/Hero.tsx b/src/components/Marketing/mdx/Hero.tsx
new file mode 100644
index 000000000..e6fba9a81
--- /dev/null
+++ b/src/components/Marketing/mdx/Hero.tsx
@@ -0,0 +1,59 @@
+import Title from '@/components/0_Bruddle/Title'
+import Link from 'next/link'
+import { CloudsCss } from '@/components/LandingPage/CloudsCss'
+import { MarqueeComp } from '@/components/Global/MarqueeWrapper'
+import { HandThumbsUp } from '@/assets'
+import { ExchangeWidget } from './ExchangeWidget'
+
+const marketingClouds = [
+ { top: '15%', width: 160, speed: '45s', direction: 'ltr' as const },
+ { top: '55%', width: 180, speed: '50s', direction: 'rtl' as const },
+ { top: '85%', width: 150, speed: '48s', direction: 'ltr' as const, delay: '8s' },
+]
+
+interface HeroProps {
+ title: string
+ subtitle: string
+ cta?: string
+ ctaHref?: string
+ /** Default destination currency for the exchange widget (e.g. "ARS", "BRL") */
+ currency?: string
+}
+
+/**
+ * MDX Hero — large bubble title (knerd font, tight stacked lines),
+ * Roboto Flex bold subtitle, white CTA button on pink background, with optional exchange widget.
+ */
+export function Hero({ title, subtitle, cta, ctaHref, currency }: HeroProps) {
+ return (
+ <>
+
+
+
+
+
+
+
+ {subtitle}
+
+ {cta && ctaHref && (
+
+
+ {cta}
+
+
+ )}
+
+
+
+ {currency && }
+ >
+ )
+}
diff --git a/src/components/Marketing/mdx/ProseStars.tsx b/src/components/Marketing/mdx/ProseStars.tsx
new file mode 100644
index 000000000..035e3838f
--- /dev/null
+++ b/src/components/Marketing/mdx/ProseStars.tsx
@@ -0,0 +1,55 @@
+import { Star } from '@/assets'
+import { AnimateOnView } from '@/components/Global/AnimateOnView'
+
+interface StarPlacement {
+ className: string
+ width: number
+ height: number
+ delay: string
+ x: string
+ rotate: string
+}
+
+/**
+ * Pre-defined star placement sets. Each h2 cycles through these
+ * via a module-level counter so stars appear in varied positions.
+ */
+const placements: StarPlacement[][] = [
+ [
+ { className: 'absolute -right-4 -top-2 md:right-8', width: 40, height: 40, delay: '0.15s', x: '5px', rotate: '22deg' },
+ ],
+ [
+ { className: 'absolute -left-4 top-0 md:left-8', width: 35, height: 35, delay: '0.25s', x: '-5px', rotate: '-15deg' },
+ ],
+ [
+ { className: 'absolute -right-2 -top-4 md:right-16', width: 32, height: 32, delay: '0.1s', x: '3px', rotate: '45deg' },
+ { className: 'absolute -left-6 top-4 md:left-4 hidden md:block', width: 28, height: 28, delay: '0.5s', x: '-4px', rotate: '-10deg' },
+ ],
+ [
+ { className: 'absolute -left-2 -top-2 md:left-12', width: 38, height: 38, delay: '0.2s', x: '-3px', rotate: '12deg' },
+ ],
+]
+
+let counter = 0
+
+/** Decorative stars placed in the margins around prose h2 headings. */
+export function ProseStars() {
+ const set = placements[counter % placements.length]
+ counter++
+
+ return (
+ <>
+ {set.map((star, i) => (
+
+
+
+ ))}
+ >
+ )
+}
diff --git a/src/components/Marketing/mdx/RelatedPages.tsx b/src/components/Marketing/mdx/RelatedPages.tsx
new file mode 100644
index 000000000..0f792afbe
--- /dev/null
+++ b/src/components/Marketing/mdx/RelatedPages.tsx
@@ -0,0 +1,70 @@
+import { Children, isValidElement, type ReactNode } from 'react'
+import Link from 'next/link'
+import { Card } from '@/components/0_Bruddle/Card'
+import { PROSE_WIDTH, CARD_HOVER } from './constants'
+
+interface RelatedLinkProps {
+ href: string
+ children: ReactNode
+}
+
+/** Individual related page link. Used as a child of . */
+export function RelatedLink({ href, children }: RelatedLinkProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+interface RelatedPagesProps {
+ title?: string
+ children: ReactNode
+}
+
+/**
+ * MDX Related Pages component. Renders a grid of internal link cards
+ * at the bottom of content pages for SEO internal linking.
+ *
+ * Usage in MDX:
+ *
+ * Pay with Mercado Pago
+ * Peanut vs Wise
+ *
+ */
+export function RelatedPages({ title = 'Related Pages', children }: RelatedPagesProps) {
+ const links: Array<{ href: string; text: string }> = []
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return
+ if (child.type === RelatedLink || child.props?.href) {
+ links.push({
+ href: child.props.href,
+ text: typeof child.props.children === 'string'
+ ? child.props.children
+ : String(child.props.children ?? ''),
+ })
+ }
+ })
+
+ if (links.length === 0) return null
+
+ return (
+
+ {title}
+
+ {links.map((link) => (
+
+
+ {link.text}
+ →
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/Marketing/mdx/Stars.tsx b/src/components/Marketing/mdx/Stars.tsx
new file mode 100644
index 000000000..e7903dc87
--- /dev/null
+++ b/src/components/Marketing/mdx/Stars.tsx
@@ -0,0 +1,36 @@
+import { Star } from '@/assets'
+import { AnimateOnView } from '@/components/Global/AnimateOnView'
+
+interface StarConfig {
+ className: string
+ width: number
+ height: number
+ delay: string
+ x: string
+ rotate: string
+}
+
+const defaultStars: StarConfig[] = [
+ { className: 'absolute right-6 top-6 md:right-12 md:top-10', width: 40, height: 40, delay: '0.2s', x: '5px', rotate: '22deg' },
+ { className: 'absolute left-8 bottom-8 md:left-16', width: 35, height: 35, delay: '0.5s', x: '-5px', rotate: '-15deg' },
+ { className: 'absolute right-1/4 bottom-12 hidden md:block', width: 30, height: 30, delay: '0.8s', x: '3px', rotate: '45deg' },
+]
+
+/** Decorative animated stars. Sprinkle on sections for visual interest. */
+export function Stars({ configs = defaultStars }: { configs?: StarConfig[] }) {
+ return (
+ <>
+ {configs.map((config, i) => (
+
+
+
+ ))}
+ >
+ )
+}
diff --git a/src/components/Marketing/mdx/Steps.tsx b/src/components/Marketing/mdx/Steps.tsx
new file mode 100644
index 000000000..190111f76
--- /dev/null
+++ b/src/components/Marketing/mdx/Steps.tsx
@@ -0,0 +1,90 @@
+import { Children, isValidElement, type ReactNode } from 'react'
+import { Steps as StepsCards } from '@/components/Marketing/Steps'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { CloudsCss } from '@/components/LandingPage/CloudsCss'
+import { Stars } from './Stars'
+
+interface StepProps {
+ title: string
+ children: ReactNode
+}
+
+/** Individual step. Used as a child of . */
+export function Step({ title, children }: StepProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+interface StepsProps {
+ title?: string
+ children: ReactNode
+}
+
+/** Extract text content from React nodes for descriptions and JSON-LD */
+function extractText(node: ReactNode): string {
+ if (typeof node === 'string') return node
+ if (typeof node === 'number') return String(node)
+ if (!node) return ''
+ if (Array.isArray(node)) return node.map(extractText).join('')
+ if (isValidElement(node)) return extractText(node.props.children)
+ return ''
+}
+
+const stepsClouds = [
+ { top: '15%', width: 160, speed: '40s', direction: 'ltr' as const },
+ { top: '60%', width: 140, speed: '34s', direction: 'rtl' as const },
+ { top: '85%', width: 120, speed: '46s', direction: 'ltr' as const, delay: '6s' },
+]
+
+/**
+ * MDX Steps component. Full-bleed yellow section with numbered step cards,
+ * clouds, and HowTo JSON-LD. Matches LP styling.
+ *
+ * Usage in MDX:
+ *
+ * Create a Peanut account...
+ * Send stablecoins or bank transfer.
+ *
+ */
+export function Steps({ title = 'How It Works', children }: StepsProps) {
+ const steps: Array<{ title: string; description: string }> = []
+
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) return
+ if (child.type === Step || child.props?.title) {
+ steps.push({
+ title: child.props.title,
+ description: extractText(child.props.children),
+ })
+ }
+ })
+
+ if (steps.length === 0) return null
+
+ const howToSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name: title,
+ step: steps.map((step, i) => ({
+ '@type': 'HowToStep',
+ position: i + 1,
+ name: step.title,
+ text: step.description || step.title,
+ })),
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/Marketing/mdx/components.tsx b/src/components/Marketing/mdx/components.tsx
new file mode 100644
index 000000000..e8315cbe9
--- /dev/null
+++ b/src/components/Marketing/mdx/components.tsx
@@ -0,0 +1,102 @@
+import Link from 'next/link'
+import { Hero } from './Hero'
+import { Steps, Step } from './Steps'
+import { FAQ, FAQItem } from './FAQ'
+import { CTA } from './CTA'
+import { Callout } from './Callout'
+import { ExchangeWidget } from './ExchangeWidget'
+import { RelatedPages, RelatedLink } from './RelatedPages'
+import { CountryGrid } from './CountryGrid'
+import { ProseStars } from './ProseStars'
+import { PROSE_WIDTH } from './constants'
+
+/**
+ * Component map for MDX content rendering.
+ * These components are available in .md/.mdx files without imports.
+ *
+ * Prose column: PROSE_WIDTH (~Wise's 600px content width)
+ * Text color: text-grey-1 (#5F646D) for body, text-n-1 for headings
+ * Line-height: leading-[1.75] for generous readability
+ * Paragraph spacing: mb-6 (24px) matching Wise
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const mdxComponents: Record> = {
+ // Custom components
+ Hero,
+ Steps,
+ Step,
+ FAQ,
+ FAQItem,
+ CTA,
+ Callout,
+ ExchangeWidget,
+ RelatedPages,
+ RelatedLink,
+ CountryGrid,
+
+ // Element overrides — prose styling
+ h2: (props: React.HTMLAttributes) => (
+
+ ),
+ h3: (props: React.HTMLAttributes) => (
+
+ ),
+ p: (props: React.HTMLAttributes) => (
+
+ ),
+ a: ({ href = '', ...props }: React.AnchorHTMLAttributes) => (
+
+ ),
+ ul: (props: React.HTMLAttributes) => (
+
+ ),
+ ol: (props: React.HTMLAttributes) => (
+
+ ),
+ li: (props: React.HTMLAttributes) => (
+
+ ),
+ strong: (props: React.HTMLAttributes) => (
+
+ ),
+ table: (props: React.HTMLAttributes) => (
+
+ ),
+ th: (props: React.HTMLAttributes) => (
+
+ ),
+ td: (props: React.HTMLAttributes) => (
+
+ ),
+ blockquote: (props: React.HTMLAttributes) => (
+
+ ),
+ hr: (props: React.HTMLAttributes) => (
+
+ ),
+}
diff --git a/src/components/Marketing/mdx/constants.ts b/src/components/Marketing/mdx/constants.ts
new file mode 100644
index 000000000..b33fbbfe4
--- /dev/null
+++ b/src/components/Marketing/mdx/constants.ts
@@ -0,0 +1,7 @@
+/** Prose content column width class. Matches Wise's ~600px content width for readability. */
+export const PROSE_WIDTH = 'max-w-[640px]'
+
+/** Standard hover/active classes for interactive cards with Bruddle shadow.
+ * Hover: card lifts up-left, shadow grows to compensate (appears stationary).
+ * Active: card presses into shadow. */
+export const CARD_HOVER = 'transition-all duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[6px_6px_0_#000] active:translate-x-[3px] active:translate-y-[4px] active:shadow-none'
diff --git a/src/components/Marketing/pages/HubPageContent.tsx b/src/components/Marketing/pages/HubPageContent.tsx
index 6a138ad13..4b8162c65 100644
--- a/src/components/Marketing/pages/HubPageContent.tsx
+++ b/src/components/Marketing/pages/HubPageContent.tsx
@@ -2,7 +2,6 @@ import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
import { COUNTRIES_SEO, getLocalizedSEO, getCountryName, CORRIDORS, COMPETITORS, EXCHANGES } from '@/data/seo'
-import { readEntitySeo } from '@/lib/content'
import { getTranslations, t, localizedPath } from '@/i18n'
import type { Locale } from '@/i18n/types'
import { MarketingHero } from '@/components/Marketing/MarketingHero'
@@ -18,14 +17,6 @@ interface HubPageContentProps {
locale: Locale
}
-interface CountrySeoJson {
- region: string
- instantPayment: string | null
- payMerchants: boolean
- corridorsFrom: string[]
- corridorsTo: string[]
-}
-
interface HubLink {
title: string
description: string
@@ -44,9 +35,6 @@ export function HubPageContent({ country, locale }: HubPageContentProps) {
const currencyCode = mapping?.currencyCode ?? ''
const flagCode = mapping?.flagCode
- // Load structured data for corridor relationships
- const countrySeo = readEntitySeo('countries', country)
-
// Build hub spoke links
const links: HubLink[] = []
diff --git a/src/content b/src/content
deleted file mode 120000
index f17844c77..000000000
--- a/src/content
+++ /dev/null
@@ -1 +0,0 @@
-/home/hugo/Projects/Peanut/peanut-content
\ No newline at end of file
diff --git a/src/content b/src/content
new file mode 160000
index 000000000..4fb4f6085
--- /dev/null
+++ b/src/content
@@ -0,0 +1 @@
+Subproject commit 4fb4f60856671cb426b6f108496f052c63708ae7
diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts
index 2bcbdebc4..fdf463d96 100644
--- a/src/data/seo/comparisons.ts
+++ b/src/data/seo/comparisons.ts
@@ -1,8 +1,39 @@
// Typed wrappers for competitor comparison data.
-// Reads from per-competitor directories: peanut-content/competitors//
-// Public API unchanged from the previous monolithic JSON version.
+// Reads from peanut-content: input/data/competitors/ + content/compare/
+// Public API unchanged from previous version.
-import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
+import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs, isPublished } from '@/lib/content'
+
+// --- Entity frontmatter (input/data/competitors/{slug}.md) ---
+
+interface CompetitorEntityFrontmatter {
+ slug: string
+ name: string
+ type: string
+ fee_model: string
+ speed: string
+ rate_type: string
+ supports_mercadopago: boolean
+ supports_pix: boolean
+ local_spending_argentina: boolean
+ local_spending_brazil: boolean
+ global_availability: boolean
+}
+
+// --- Content frontmatter (content/compare/{slug}/{lang}.md) ---
+
+interface CompareContentFrontmatter {
+ title: string
+ description: string
+ slug: string
+ lang: string
+ published: boolean
+ competitor: string
+ schema_types: string[]
+ alternates?: Record
+}
+
+// --- Public types (unchanged) ---
export interface Competitor {
name: string
@@ -15,47 +46,128 @@ export interface Competitor {
image?: string
}
-interface CompetitorSeoJson {
- name: string
- tagline: string
- rows: Array<{ feature: string; peanut: string; competitor: string }>
- prosCompetitor: string[]
- consCompetitor: string[]
- verdict: string
+// --- Loader ---
+
+function loadCompetitors(): Record {
+ const result: Record = {}
+
+ // Get competitor slugs from content directory (content/compare/)
+ const contentSlugs = listContentSlugs('compare')
+ // Also check entity data for completeness
+ const entitySlugs = listEntitySlugs('competitors')
+ const allSlugs = [...new Set([...contentSlugs, ...entitySlugs])]
+
+ for (const slug of allSlugs) {
+ const entity = readEntityData('competitors', slug)
+ if (!entity) continue
+
+ const content = readPageContent('compare', slug, 'en')
+
+ // During transition: include if entity exists and content exists (even if unpublished)
+ if (!content) continue
+
+ const fm = entity.frontmatter
+ const body = content.body
+
+ // Extract structured data from entity + generated content
+ result[slug] = {
+ name: fm.name,
+ tagline: buildTagline(fm),
+ rows: buildComparisonRows(fm),
+ prosCompetitor: buildPros(fm),
+ consCompetitor: buildCons(fm),
+ verdict: buildVerdict(fm),
+ faqs: extractFaqs(body),
+ }
+ }
+
+ return result
}
-interface CompetitorFrontmatter {
- title: string
- description: string
- image?: string
- faqs: Array<{ q: string; a: string }>
+function buildTagline(fm: CompetitorEntityFrontmatter): string {
+ return `Compare Peanut with ${fm.name} for sending money to Latin America.`
}
-interface CompetitorIndex {
- competitors: Array<{ slug: string; name: string; status?: string; locales: string[] }>
+function buildComparisonRows(
+ fm: CompetitorEntityFrontmatter
+): Array<{ feature: string; peanut: string; competitor: string }> {
+ return [
+ { feature: 'Fee Model', peanut: 'Free deposits & payments', competitor: fm.fee_model },
+ { feature: 'Speed', peanut: 'Instant local payments', competitor: fm.speed },
+ { feature: 'Rate Type', peanut: 'Cripto dólar / market rate', competitor: fm.rate_type },
+ {
+ feature: 'Mercado Pago',
+ peanut: 'Yes',
+ competitor: fm.supports_mercadopago ? 'Yes' : 'No',
+ },
+ { feature: 'Pix', peanut: 'Yes', competitor: fm.supports_pix ? 'Yes' : 'No' },
+ {
+ feature: 'Local Spending (Argentina)',
+ peanut: 'Yes — QR + ATM',
+ competitor: fm.local_spending_argentina ? 'Yes' : 'No',
+ },
+ {
+ feature: 'Local Spending (Brazil)',
+ peanut: 'Yes — Pix QR',
+ competitor: fm.local_spending_brazil ? 'Yes' : 'No',
+ },
+ ]
}
-function loadCompetitors(): Record {
- const index = readEntityIndex('competitors')
- if (!index) return {}
+function buildPros(fm: CompetitorEntityFrontmatter): string[] {
+ const pros: string[] = []
+ if (fm.global_availability) pros.push('Available globally')
+ if (fm.speed.includes('instant') || fm.speed.includes('Instant')) pros.push('Fast transfers')
+ pros.push('Well-known brand')
+ return pros
+}
- const result: Record = {}
+function buildCons(fm: CompetitorEntityFrontmatter): string[] {
+ const cons: string[] = []
+ if (!fm.supports_mercadopago) cons.push('No Mercado Pago support')
+ if (!fm.supports_pix) cons.push('No Pix support')
+ if (!fm.local_spending_argentina) cons.push('No local spending in Argentina')
+ if (!fm.local_spending_brazil) cons.push('No local spending in Brazil')
+ if (fm.rate_type !== 'cripto-dolar') cons.push('Uses less favorable exchange rate')
+ return cons
+}
+
+function buildVerdict(fm: CompetitorEntityFrontmatter): string {
+ if (!fm.supports_mercadopago && !fm.supports_pix) {
+ return `${fm.name} is a solid choice for international transfers, but if you need to pay locally in Argentina or Brazil, Peanut offers better rates and direct local payment access.`
+ }
+ return `Both services have their strengths. Peanut excels for local payments in Latin America with better exchange rates.`
+}
- for (const entry of index.competitors) {
- if (!isPublished(entry)) continue
- const { slug } = entry
- const seo = readEntitySeo('competitors', slug)
- const content = readEntityContent('competitors', slug, 'en')
- if (!seo || !content) continue
+/** Extract FAQ items from markdown body (## FAQ or ## Frequently Asked Questions section) */
+function extractFaqs(body: string): Array<{ q: string; a: string }> {
+ const faqs: Array<{ q: string; a: string }> = []
- result[slug] = {
- ...seo,
- faqs: content.frontmatter.faqs ?? [],
- image: content.frontmatter.image,
+ // Look for ### headings after a FAQ section header
+ const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!faqSection) return faqs
+
+ const lines = faqSection[1].split('\n')
+ let currentQ = ''
+ let currentA = ''
+
+ for (const line of lines) {
+ if (line.startsWith('### ')) {
+ if (currentQ && currentA.trim()) {
+ faqs.push({ q: currentQ, a: currentA.trim() })
+ }
+ currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
+ currentA = ''
+ } else if (currentQ) {
+ currentA += line + '\n'
}
}
- return result
+ if (currentQ && currentA.trim()) {
+ faqs.push({ q: currentQ, a: currentA.trim() })
+ }
+
+ return faqs
}
export const COMPETITORS: Record = loadCompetitors()
diff --git a/src/data/seo/convert.ts b/src/data/seo/convert.ts
index 9616b583b..bc5784350 100644
--- a/src/data/seo/convert.ts
+++ b/src/data/seo/convert.ts
@@ -1,23 +1,82 @@
// Typed re-exports for currency conversion data.
-// Raw data lives in peanut-content (YAML). Types and logic live here.
+// Reads from peanut-content: input/data/currencies/
+// Builds convert pairs from available currency entities.
-import fs from 'fs'
-import path from 'path'
-import matter from 'gray-matter'
+import { readEntityData, listEntitySlugs } from '@/lib/content'
-const yaml = matter.engines.yaml
+// --- Entity frontmatter (input/data/currencies/{slug}.md) ---
-interface ConvertData {
- pairs: string[]
- currencyDisplay: Record
+interface CurrencyEntityFrontmatter {
+ slug: string
+ name: string
+ type: 'fiat' | 'stablecoin'
+ symbol: string
+ iso_code?: string
+ countries?: string[]
}
-const convertData = yaml.parse(
- fs.readFileSync(path.join(process.cwd(), 'src/content/convert/pairs.yaml'), 'utf8')
-) as ConvertData
+// --- Build currency display data and pairs ---
-export const CONVERT_PAIRS: readonly string[] = convertData.pairs
-export const CURRENCY_DISPLAY: Record = convertData.currencyDisplay
+function loadCurrencyData() {
+ const slugs = listEntitySlugs('currencies')
+ const display: Record = {}
+ const stablecoins: string[] = []
+ const fiats: string[] = []
+
+ for (const slug of slugs) {
+ const entity = readEntityData('currencies', slug)
+ if (!entity) continue
+
+ const fm = entity.frontmatter
+ display[slug] = { name: fm.name, symbol: fm.symbol }
+
+ if (fm.type === 'stablecoin') {
+ stablecoins.push(slug)
+ } else {
+ fiats.push(slug)
+ }
+ }
+
+ // Build convert pairs: each stablecoin ↔ each fiat
+ // Plus usd ↔ each non-USD fiat
+ const pairs: string[] = []
+
+ for (const stable of stablecoins) {
+ for (const fiat of fiats) {
+ pairs.push(`${stable}-to-${fiat}`)
+ pairs.push(`${fiat}-to-${stable}`)
+ }
+ }
+
+ // USD to/from major fiats
+ if (fiats.includes('usd')) {
+ for (const fiat of fiats) {
+ if (fiat === 'usd') continue
+ const pair1 = `usd-to-${fiat}`
+ const pair2 = `${fiat}-to-usd`
+ if (!pairs.includes(pair1)) pairs.push(pair1)
+ if (!pairs.includes(pair2)) pairs.push(pair2)
+ }
+ }
+
+ // EUR to/from major fiats
+ if (fiats.includes('eur')) {
+ for (const fiat of fiats) {
+ if (fiat === 'eur') continue
+ const pair1 = `eur-to-${fiat}`
+ const pair2 = `${fiat}-to-eur`
+ if (!pairs.includes(pair1)) pairs.push(pair1)
+ if (!pairs.includes(pair2)) pairs.push(pair2)
+ }
+ }
+
+ return { pairs, display }
+}
+
+const _loaded = loadCurrencyData()
+
+export const CONVERT_PAIRS: readonly string[] = _loaded.pairs
+export const CURRENCY_DISPLAY: Record = _loaded.display
/** Parse a convert pair slug into from/to currencies: 'usd-to-ars' → { from: 'usd', to: 'ars' } */
export function parseConvertPair(pair: string): { from: string; to: string } | null {
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
index fabb73afb..904e31d07 100644
--- a/src/data/seo/corridors.ts
+++ b/src/data/seo/corridors.ts
@@ -1,24 +1,68 @@
// Typed wrappers for corridor/country SEO data.
-// Reads from per-country directories: peanut-content/countries//
-// Public API unchanged from the previous monolithic JSON version.
+// Reads from peanut-content: input/data/countries/ + content/countries/ + content/send-to/
+// Public API unchanged from previous version.
-import fs from 'fs'
-import path from 'path'
-import matter from 'gray-matter'
-import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
+import {
+ readEntityData,
+ readPageContent,
+ readPageContentLocalized,
+ listEntitySlugs,
+ listContentSlugs,
+ listCorridorOrigins,
+ isPublished,
+} from '@/lib/content'
import type { Locale } from '@/i18n/types'
-const yaml = matter.engines.yaml
-const countryNamesData = yaml.parse(
- fs.readFileSync(path.join(process.cwd(), 'src/content/i18n/country-names.yaml'), 'utf8')
-) as Record>
+// --- Entity frontmatter schema (input/data/countries/{slug}.md) ---
+
+interface CountryEntityFrontmatter {
+ slug: string
+ name: string
+ currency: string
+ local_id: string
+ local_payment_methods: string[]
+ corridors: Array<{
+ origin: string
+ priority: 'high' | 'medium' | 'low'
+ common_use_cases: string[]
+ }>
+}
+
+// --- Content frontmatter schema (content/countries/{slug}/{lang}.md) ---
+
+interface CountryContentFrontmatter {
+ title: string
+ description: string
+ slug: string
+ lang: string
+ published: boolean
+ schema_types: string[]
+ alternates?: Record
+}
+
+// --- Spending method entity frontmatter ---
+
+interface SpendingMethodFrontmatter {
+ slug: string
+ name: string
+ type: string
+}
+
+// --- Public types (matches fields consumed by page components) ---
export interface CountrySEO {
- region: 'latam' | 'north-america' | 'europe' | 'asia-oceania'
+ name: string
+ region: string
+ currency: string
+ localPaymentMethods: string[]
context: string
instantPayment?: string
payMerchants: boolean
faqs: Array<{ q: string; a: string }>
+ corridors: Array<{
+ origin: string
+ priority: 'high' | 'medium' | 'low'
+ }>
}
export interface Corridor {
@@ -26,53 +70,159 @@ export interface Corridor {
to: string
}
-interface CountrySeoJson {
- region: 'latam' | 'north-america' | 'europe' | 'asia-oceania'
- instantPayment: string | null
- payMerchants: boolean
- corridorsFrom: string[]
- corridorsTo: string[]
-}
+// --- Helpers ---
-interface CountryFrontmatter {
- title: string
- description: string
- faqs: Array<{ q: string; a: string }>
-}
+/** Extract FAQ items from markdown body (## FAQ section with ### question headings) */
+function extractFaqsFromBody(body: string): Array<{ q: string; a: string }> {
+ const faqs: Array<{ q: string; a: string }> = []
+ const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!faqSection) return faqs
-interface CountryIndex {
- countries: Array<{ slug: string; region: string; status?: string; locales: string[] }>
- corridors: Corridor[]
+ const lines = faqSection[1].split('\n')
+ let currentQ = ''
+ let currentA = ''
+
+ for (const line of lines) {
+ if (line.startsWith('### ')) {
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+ currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
+ currentA = ''
+ } else if (currentQ) {
+ currentA += line + '\n'
+ }
+ }
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+
+ return faqs
}
-function loadAll() {
- const index = readEntityIndex('countries')
- if (!index) return { countries: {} as Record, corridors: [] as Corridor[] }
+// --- Loader ---
+function loadAll() {
+ const countrySlugs = listEntitySlugs('countries')
const countries: Record = {}
+ const corridors: Corridor[] = []
+ const publishedCountries = new Set()
+
+ // First pass: determine which countries have published content pages
+ const contentSlugs = listContentSlugs('countries')
+ for (const slug of contentSlugs) {
+ const content = readPageContent('countries', slug, 'en')
+ if (content && isPublished(content)) {
+ publishedCountries.add(slug)
+ }
+ }
+
+ // If no published content yet, treat all countries with entity data + content as available
+ // This allows the site to work during the transition period when published: false
+ if (publishedCountries.size === 0) {
+ for (const slug of contentSlugs) {
+ const content = readPageContent('countries', slug, 'en')
+ if (content) publishedCountries.add(slug)
+ }
+ }
+
+ for (const slug of countrySlugs) {
+ if (!publishedCountries.has(slug)) continue
+
+ const entity = readEntityData('countries', slug)
+ if (!entity) continue
+
+ const content = readPageContent('countries', slug, 'en')
+ const fm = entity.frontmatter
+
+ // Resolve the first local payment method name for instantPayment display
+ const paymentMethods = fm.local_payment_methods ?? []
+ let instantPayment: string | undefined
+ let payMerchants = false
- const publishedSlugs = new Set(index.countries.filter(isPublished).map((c) => c.slug))
+ if (paymentMethods.length > 0) {
+ const methodEntity = readEntityData('spending-methods', paymentMethods[0])
+ instantPayment = methodEntity?.frontmatter.name ?? paymentMethods[0]
+ // QR-type methods support merchant payments
+ payMerchants = methodEntity?.frontmatter.type === 'qr'
+ }
- for (const entry of index.countries) {
- if (!isPublished(entry)) continue
- const { slug } = entry
- const seo = readEntitySeo('countries', slug)
- const content = readEntityContent('countries', slug, 'en')
- if (!seo || !content) continue
+ // Extract FAQs from the content body
+ const faqs = content ? extractFaqsFromBody(content.body) : []
countries[slug] = {
- region: seo.region,
- context: content.body,
- instantPayment: seo.instantPayment ?? undefined,
- payMerchants: seo.payMerchants,
- faqs: content.frontmatter.faqs ?? [],
+ name: fm.name,
+ region: inferRegion(slug),
+ currency: fm.currency,
+ localPaymentMethods: paymentMethods,
+ context: content?.body ?? '',
+ instantPayment,
+ payMerchants,
+ faqs,
+ corridors: fm.corridors?.map((c) => ({ origin: c.origin, priority: c.priority })) ?? [],
+ }
+
+ // Build corridors from entity data
+ if (fm.corridors) {
+ for (const corridor of fm.corridors) {
+ corridors.push({ from: corridor.origin, to: slug })
+ }
+ }
+ }
+
+ // Also add corridors discovered from content/send-to/{dest}/from/{origin}/
+ for (const dest of listContentSlugs('send-to')) {
+ for (const origin of listCorridorOrigins(dest)) {
+ if (!corridors.some((c) => c.from === origin && c.to === dest)) {
+ corridors.push({ from: origin, to: dest })
+ }
}
}
- // Only include corridors where both endpoints are published
- const corridors = index.corridors.filter((c) => publishedSlugs.has(c.from) && publishedSlugs.has(c.to))
+ // Deduplicate corridors
+ const seen = new Set()
+ const uniqueCorridors = corridors.filter((c) => {
+ const key = `${c.from}→${c.to}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
- return { countries, corridors }
+ return { countries, corridors: uniqueCorridors }
+}
+
+/** Infer region from slug — simple heuristic based on known country lists */
+function inferRegion(slug: string): string {
+ const latam = [
+ 'argentina',
+ 'brazil',
+ 'mexico',
+ 'colombia',
+ 'chile',
+ 'peru',
+ 'costa-rica',
+ 'panama',
+ 'bolivia',
+ 'guatemala',
+ ]
+ const northAmerica = ['united-states', 'canada']
+ const asiaOceania = [
+ 'australia',
+ 'philippines',
+ 'japan',
+ 'india',
+ 'indonesia',
+ 'malaysia',
+ 'singapore',
+ 'thailand',
+ 'vietnam',
+ 'pakistan',
+ 'saudi-arabia',
+ 'united-arab-emirates',
+ ]
+ const africa = ['kenya', 'nigeria', 'south-africa', 'tanzania']
+
+ if (latam.includes(slug)) return 'latam'
+ if (northAmerica.includes(slug)) return 'north-america'
+ if (asiaOceania.includes(slug)) return 'asia-oceania'
+ if (africa.includes(slug)) return 'africa'
+ return 'europe'
}
const _loaded = loadAll()
@@ -80,24 +230,30 @@ const _loaded = loadAll()
export const COUNTRIES_SEO: Record = _loaded.countries
export const CORRIDORS: Corridor[] = _loaded.corridors
-/** Get country SEO data with locale-specific content (falls back to English) */
+/** Get country SEO data with locale-specific content (falls back via chain) */
export function getLocalizedSEO(country: string, locale: Locale): CountrySEO | null {
const base = COUNTRIES_SEO[country]
if (!base) return null
if (locale === 'en') return base
- const localized = readEntityContent('countries', country, locale)
+ const localized = readPageContentLocalized('countries', country, locale)
if (!localized) return base
+ const localizedFaqs = extractFaqsFromBody(localized.body)
+
return {
...base,
context: localized.body,
- faqs: localized.frontmatter.faqs ?? base.faqs,
+ faqs: localizedFaqs.length > 0 ? localizedFaqs : base.faqs,
}
}
/** Get localized country display name */
-export function getCountryName(slug: string, locale: Locale): string {
- const names = countryNamesData[slug]
- return names?.[locale] ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+export function getCountryName(slug: string, _locale: Locale): string {
+ // Read name from entity data
+ const entity = readEntityData('countries', slug)
+ if (entity) return entity.frontmatter.name
+
+ // Fallback: title-case the slug
+ return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
index e5a5d8815..2704ff13f 100644
--- a/src/data/seo/exchanges.ts
+++ b/src/data/seo/exchanges.ts
@@ -1,8 +1,36 @@
// Typed wrappers for exchange deposit data.
-// Reads from per-exchange directories: peanut-content/exchanges//
-// Public API unchanged from the previous monolithic JSON version.
+// Reads from peanut-content: input/data/exchanges/ + content/deposit/
+// Public API unchanged from previous version.
-import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
+import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs } from '@/lib/content'
+
+// --- Entity frontmatter (input/data/exchanges/{slug}.md) ---
+
+interface ExchangeEntityFrontmatter {
+ slug: string
+ name: string
+ type: string
+ supported_networks: string[]
+ supported_stablecoins: string[]
+ withdrawal_fee_usdc: string
+ min_withdrawal: string
+ kyc_required: boolean
+ geo_restrictions: string
+}
+
+// --- Content frontmatter (content/deposit/{slug}/{lang}.md) ---
+
+interface DepositContentFrontmatter {
+ title: string
+ description: string
+ slug: string
+ deposit_source?: string
+ lang: string
+ published: boolean
+ schema_types: string[]
+}
+
+// --- Public types (unchanged) ---
export interface Exchange {
name: string
@@ -17,51 +45,121 @@ export interface Exchange {
image?: string
}
-interface ExchangeSeoJson {
- name: string
- recommendedNetwork: string
- alternativeNetworks: string[]
- withdrawalFee: string
- processingTime: string
- networkFee: string
+// --- Loader ---
+
+function loadExchanges(): Record {
+ const result: Record = {}
+ const entitySlugs = listEntitySlugs('exchanges')
+
+ for (const slug of entitySlugs) {
+ const entity = readEntityData('exchanges', slug)
+ if (!entity) continue
+
+ const fm = entity.frontmatter
+
+ // Extract steps from entity body (numbered list under ## Deposit to Peanut Flow)
+ const steps = extractSteps(entity.body)
+ const troubleshooting = extractTroubleshooting(entity.body)
+ const faqs = extractFaqs(entity.body)
+
+ // Determine recommended network (first in list, or common fast ones)
+ const networks = fm.supported_networks ?? []
+ const recommended = pickRecommendedNetwork(networks)
+
+ result[slug] = {
+ name: fm.name,
+ recommendedNetwork: recommended,
+ alternativeNetworks: networks.filter((n) => n !== recommended),
+ withdrawalFee: fm.withdrawal_fee_usdc ?? 'Varies',
+ processingTime: estimateProcessingTime(recommended),
+ networkFee: 'Covered by Peanut',
+ steps,
+ troubleshooting,
+ faqs,
+ }
+ }
+
+ return result
}
-interface ExchangeFrontmatter {
- title: string
- description: string
- image?: string
- steps: string[]
- troubleshooting: Array<{ issue: string; fix: string }>
- faqs: Array<{ q: string; a: string }>
+function pickRecommendedNetwork(networks: string[]): string {
+ // Prefer fast/cheap networks
+ const preference = ['polygon', 'arbitrum', 'base', 'solana', 'tron', 'avalanche', 'ethereum']
+ for (const pref of preference) {
+ if (networks.includes(pref)) return pref
+ }
+ return networks[0] ?? 'polygon'
}
-interface ExchangeIndex {
- exchanges: Array<{ slug: string; name: string; status?: string; locales: string[] }>
+function estimateProcessingTime(network: string): string {
+ const times: Record = {
+ polygon: '~2 minutes',
+ arbitrum: '~2 minutes',
+ base: '~2 minutes',
+ solana: '~1 minute',
+ tron: '~3 minutes',
+ avalanche: '~2 minutes',
+ ethereum: '~5 minutes',
+ }
+ return times[network] ?? '1-10 minutes'
}
-function loadExchanges(): Record {
- const index = readEntityIndex('exchanges')
- if (!index) return {}
+/** Extract numbered steps from markdown body */
+function extractSteps(body: string): string[] {
+ const steps: string[] = []
+ const stepSection = body.match(
+ /## (?:Deposit to Peanut Flow|Step-by-Step|How to Deposit)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i
+ )
+ if (!stepSection) return steps
- const result: Record = {}
+ const lines = stepSection[1].split('\n')
+ for (const line of lines) {
+ const match = line.match(/^\d+\.\s+(.+)/)
+ if (match) {
+ steps.push(match[1].replace(/\*\*/g, '').trim())
+ }
+ }
+ return steps
+}
- for (const entry of index.exchanges) {
- if (!isPublished(entry)) continue
- const { slug } = entry
- const seo = readEntitySeo('exchanges', slug)
- const content = readEntityContent('exchanges', slug, 'en')
- if (!seo || !content) continue
+/** Extract troubleshooting items from markdown body */
+function extractTroubleshooting(body: string): Array<{ issue: string; fix: string }> {
+ const items: Array<{ issue: string; fix: string }> = []
+ const section = body.match(/## (?:Troubleshooting|Common Issues)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!section) return items
- result[slug] = {
- ...seo,
- steps: content.frontmatter.steps ?? [],
- troubleshooting: content.frontmatter.troubleshooting ?? [],
- faqs: content.frontmatter.faqs ?? [],
- image: content.frontmatter.image,
+ const lines = section[1].split('\n')
+ for (const line of lines) {
+ const match = line.match(/^[-*]\s+\*\*(.+?)\*\*[:\s]+(.+)/)
+ if (match) {
+ items.push({ issue: match[1], fix: match[2].trim() })
+ }
+ }
+ return items
+}
+
+/** Extract FAQ items from markdown body */
+function extractFaqs(body: string): Array<{ q: string; a: string }> {
+ const faqs: Array<{ q: string; a: string }> = []
+ const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!faqSection) return faqs
+
+ const lines = faqSection[1].split('\n')
+ let currentQ = ''
+ let currentA = ''
+
+ for (const line of lines) {
+ if (line.startsWith('### ')) {
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+ currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
+ currentA = ''
+ } else if (currentQ) {
+ currentA += line + '\n'
}
}
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
- return result
+ return faqs
}
export const EXCHANGES: Record = loadExchanges()
diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts
index 133e330b9..31480fd00 100644
--- a/src/data/seo/payment-methods.ts
+++ b/src/data/seo/payment-methods.ts
@@ -1,58 +1,122 @@
// Typed wrapper for payment method data.
-// Raw data lives in peanut-content/payment-methods/{slug}/. Types and logic live here.
+// Reads from peanut-content: input/data/spending-methods/ + content/pay-with/
+// Note: "payment-methods" → "spending-methods" in new repo.
+// Public API unchanged from previous version.
-import { readEntitySeo, readEntityContent, readEntityIndex, isPublished } from '@/lib/content'
+import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs } from '@/lib/content'
-export interface PaymentMethod {
+// --- Entity frontmatter (input/data/spending-methods/{slug}.md) ---
+
+interface SpendingMethodEntityFrontmatter {
slug: string
name: string
+ type: string
countries: string[]
+ user_base?: string
+ transaction_types?: string[]
+ availability?: string
+ speed?: string
+}
+
+// --- Content frontmatter (content/pay-with/{slug}/{lang}.md) ---
+
+interface PayWithContentFrontmatter {
+ title: string
description: string
- steps: string[]
- faqs: Array<{ q: string; a: string }>
+ slug: string
+ lang: string
+ published: boolean
+ schema_types: string[]
+ alternates?: Record
}
-interface PaymentMethodDataJson {
+// --- Public types (unchanged) ---
+
+export interface PaymentMethod {
+ slug: string
name: string
countries: string[]
-}
-
-interface PaymentMethodFrontmatter {
- title: string
description: string
steps: string[]
faqs: Array<{ q: string; a: string }>
}
-interface PaymentMethodIndex {
- methods: Array<{ slug: string; name: string; status?: string; locales: string[] }>
-}
+// --- Loader ---
function loadPaymentMethods(): Record {
- const index = readEntityIndex('payment-methods')
- if (!index) return {}
-
const result: Record = {}
- for (const entry of index.methods) {
- if (!isPublished(entry)) continue
- const data = readEntitySeo('payment-methods', entry.slug)
- const content = readEntityContent('payment-methods', entry.slug, 'en')
+ // Get methods that have both entity data and content pages
+ const contentSlugs = new Set(listContentSlugs('pay-with'))
+ const entitySlugs = listEntitySlugs('spending-methods')
+
+ for (const slug of entitySlugs) {
+ // Only include methods that have a pay-with content page
+ if (!contentSlugs.has(slug)) continue
+
+ const entity = readEntityData('spending-methods', slug)
+ if (!entity) continue
+
+ const content = readPageContent('pay-with', slug, 'en')
+ if (!content) continue
- if (!data || !content) continue
+ const fm = entity.frontmatter
- result[entry.slug] = {
- slug: entry.slug,
- name: data.name,
- countries: data.countries,
+ result[slug] = {
+ slug,
+ name: fm.name,
+ countries: fm.countries ?? [],
description: content.body,
- steps: content.frontmatter.steps ?? [],
- faqs: content.frontmatter.faqs ?? [],
+ steps: extractSteps(content.body),
+ faqs: extractFaqs(content.body),
}
}
return result
}
+/** Extract numbered steps from markdown body */
+function extractSteps(body: string): string[] {
+ const steps: string[] = []
+ // Look for numbered lists in "How to" or step sections
+ const section = body.match(
+ /###?\s+(?:Merchant QR Payments|How to Pay|Steps|How It Works)\s*\n([\s\S]*?)(?=\n###?\s|$)/i
+ )
+ if (!section) return steps
+
+ const lines = section[1].split('\n')
+ for (const line of lines) {
+ const match = line.match(/^\d+\.\s+\*\*(.+?)\*\*/)
+ if (match) {
+ steps.push(match[1].trim())
+ }
+ }
+ return steps
+}
+
+/** Extract FAQ items from markdown body */
+function extractFaqs(body: string): Array<{ q: string; a: string }> {
+ const faqs: Array<{ q: string; a: string }> = []
+ const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!faqSection) return faqs
+
+ const lines = faqSection[1].split('\n')
+ let currentQ = ''
+ let currentA = ''
+
+ for (const line of lines) {
+ if (line.startsWith('### ')) {
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+ currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
+ currentA = ''
+ } else if (currentQ) {
+ currentA += line + '\n'
+ }
+ }
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+
+ return faqs
+}
+
export const PAYMENT_METHODS = loadPaymentMethods()
export const PAYMENT_METHOD_SLUGS = Object.keys(PAYMENT_METHODS)
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index adf324437..0a580ec1e 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -15,6 +15,15 @@ export const ROUTE_SLUGS = [
export type RouteSlug = (typeof ROUTE_SLUGS)[number]
+/** Map locale codes to hreflang values */
+const HREFLANG_MAP: Record = {
+ en: 'en',
+ 'es-419': 'es-419',
+ 'es-ar': 'es-AR',
+ 'es-es': 'es-ES',
+ 'pt-br': 'pt-BR',
+}
+
/** Build a localized path: all locales get /{locale}/ prefix */
export function localizedPath(route: RouteSlug, locale: Locale, ...segments: string[]): string {
const suffix = segments.length > 0 ? `/${segments.join('/')}` : ''
@@ -31,7 +40,7 @@ export function localizedBarePath(locale: Locale, ...segments: string[]): string
export function getAlternates(route: RouteSlug, ...segments: string[]): Record {
const alternates: Record = {}
for (const locale of SUPPORTED_LOCALES) {
- const langCode = locale === 'en' ? 'x-default' : locale
+ const langCode = locale === 'en' ? 'x-default' : HREFLANG_MAP[locale]
alternates[langCode] = `https://peanut.me${localizedPath(route, locale, ...segments)}`
}
// Also add 'en' explicitly alongside x-default
@@ -43,7 +52,7 @@ export function getAlternates(route: RouteSlug, ...segments: string[]): Record {
const alternates: Record = {}
for (const locale of SUPPORTED_LOCALES) {
- const langCode = locale === 'en' ? 'x-default' : locale
+ const langCode = locale === 'en' ? 'x-default' : HREFLANG_MAP[locale]
alternates[langCode] = `https://peanut.me${localizedBarePath(locale, ...segments)}`
}
alternates['en'] = `https://peanut.me${localizedBarePath('en', ...segments)}`
diff --git a/src/i18n/es.json b/src/i18n/es-419.json
similarity index 100%
rename from src/i18n/es.json
rename to src/i18n/es-419.json
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
new file mode 100644
index 000000000..f261cb1db
--- /dev/null
+++ b/src/i18n/es-ar.json
@@ -0,0 +1,63 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Empezar",
+ "createAccount": "Creá tu cuenta Peanut",
+ "howItWorks": "Cómo Funciona",
+ "paymentMethods": "Métodos de Pago",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar plata a otros países",
+ "sendingMoneyTo": "Enviar Dinero a {country}",
+ "stepCreateAccount": "Creá tu cuenta Peanut",
+ "stepCreateAccountDesc": "Registrate en menos de 2 minutos con tu email o wallet.",
+ "stepDepositFunds": "Depositá fondos",
+ "stepDepositFundsDesc": "Agregá plata por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
+ "stepSendTo": "Enviá a {country}",
+ "stepSendToDesc": "Ingresá los datos del destinatario y confirmá. Reciben {currency} en minutos vía {method}.",
+ "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
+ "qrPayments": "Usá tu saldo en millones de comercios con pagos QR.",
+ "stablecoins": "Stablecoins (USDC / USDT)",
+ "stablecoinsDesc": "Depositá stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
+ "bankTransfer": "Transferencia Bancaria",
+ "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
+ "readMore": "Leer más",
+ "allArticles": "Todos los artículos",
+ "blog": "Blog",
+ "postedOn": "Publicado el {date}",
+ "feature": "Característica",
+ "verdict": "Veredicto",
+ "home": "Inicio",
+ "sendMoney": "Enviar Plata",
+ "convertTitle": "Convertir {from} a {to}",
+ "amount": "Monto",
+ "liveRate": "Cotización en Vivo",
+ "depositFrom": "Depositar desde {exchange}",
+ "recommendedNetwork": "Red Recomendada",
+ "withdrawalFee": "Comisión de Retiro",
+ "processingTime": "Tiempo de Procesamiento",
+ "troubleshooting": "Solución de Problemas",
+ "hubTitle": "Peanut en {country}",
+ "hubSubtitle": "Todo lo que necesitás para enviar, recibir y gastar plata en {country}.",
+ "hubSendMoney": "Enviar Plata a {country}",
+ "hubSendMoneyDesc": "Transferí plata a {country} con tasas competitivas.",
+ "hubConvert": "Convertir a {currency}",
+ "hubConvertDesc": "Consultá cotizaciones en vivo y convertí divisas.",
+ "hubDeposit": "Fondeá Tu Cuenta",
+ "hubDepositDesc": "Agregá plata desde exchanges y wallets populares.",
+ "hubCompare": "Comparar Servicios",
+ "hubCompareDesc": "Compará Peanut con otras opciones de transferencia.",
+ "hubExploreCountries": "Explorar otros países",
+ "hubInboundCorridors": "Enviá plata a {country} desde estos países:",
+ "hubSendMoneyFrom": "Enviar Plata desde {country}",
+ "sendMoneyFromTo": "Enviar Plata de {from} a {to}",
+ "sendMoneyFromToDesc": "Transferí plata de {from} a {to}. Rápido, económico y seguro.",
+ "fromToContext": "Peanut facilita enviar plata de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
+ "receiveMoneyFrom": "Recibir Plata de {country}",
+ "receiveMoneyFromDesc": "Recibí plata enviada desde {country}. Rápido y seguro.",
+ "payWith": "Pagar con {method}",
+ "payWithDesc": "Usá {method} para enviar y recibir plata en Peanut.",
+ "teamTitle": "Nuestro Equipo",
+ "teamSubtitle": "Las personas detrás de Peanut.",
+ "lastUpdated": "Última actualización: {date}",
+ "relatedPages": "Páginas Relacionadas"
+}
diff --git a/src/i18n/es-es.json b/src/i18n/es-es.json
new file mode 100644
index 000000000..5dff81d63
--- /dev/null
+++ b/src/i18n/es-es.json
@@ -0,0 +1,63 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Comenzar",
+ "createAccount": "Crea tu cuenta Peanut",
+ "howItWorks": "Cómo Funciona",
+ "paymentMethods": "Métodos de Pago",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar dinero a otros países",
+ "sendingMoneyTo": "Enviar Dinero a {country}",
+ "stepCreateAccount": "Crea tu cuenta Peanut",
+ "stepCreateAccountDesc": "Regístrate en menos de 2 minutos con tu email o wallet.",
+ "stepDepositFunds": "Deposita fondos",
+ "stepDepositFundsDesc": "Agrega dinero por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
+ "stepSendTo": "Envía a {country}",
+ "stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
+ "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
+ "qrPayments": "Usa tu saldo en millones de comercios con pagos QR.",
+ "stablecoins": "Stablecoins (USDC / USDT)",
+ "stablecoinsDesc": "Deposita stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
+ "bankTransfer": "Transferencia Bancaria",
+ "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
+ "readMore": "Leer más",
+ "allArticles": "Todos los artículos",
+ "blog": "Blog",
+ "postedOn": "Publicado el {date}",
+ "feature": "Característica",
+ "verdict": "Veredicto",
+ "home": "Inicio",
+ "sendMoney": "Enviar Dinero",
+ "convertTitle": "Convertir {from} a {to}",
+ "amount": "Monto",
+ "liveRate": "Tasa en Vivo",
+ "depositFrom": "Depositar desde {exchange}",
+ "recommendedNetwork": "Red Recomendada",
+ "withdrawalFee": "Comisión de Retiro",
+ "processingTime": "Tiempo de Procesamiento",
+ "troubleshooting": "Solución de Problemas",
+ "hubTitle": "Peanut en {country}",
+ "hubSubtitle": "Todo lo que necesitas para enviar, recibir y gastar dinero en {country}.",
+ "hubSendMoney": "Enviar Dinero a {country}",
+ "hubSendMoneyDesc": "Transfiere dinero a {country} con tasas competitivas.",
+ "hubConvert": "Convertir a {currency}",
+ "hubConvertDesc": "Consulta tasas de cambio en vivo y convierte divisas.",
+ "hubDeposit": "Fondea Tu Cuenta",
+ "hubDepositDesc": "Agrega dinero desde exchanges y wallets populares.",
+ "hubCompare": "Comparar Servicios",
+ "hubCompareDesc": "Compara Peanut con otras opciones de transferencia.",
+ "hubExploreCountries": "Explorar otros países",
+ "hubInboundCorridors": "Envía dinero a {country} desde estos países:",
+ "hubSendMoneyFrom": "Enviar Dinero desde {country}",
+ "sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
+ "sendMoneyFromToDesc": "Transfiere dinero de {from} a {to}. Rápido, económico y seguro.",
+ "fromToContext": "Peanut facilita enviar dinero de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
+ "receiveMoneyFrom": "Recibir Dinero de {country}",
+ "receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
+ "payWith": "Pagar con {method}",
+ "payWithDesc": "Usa {method} para enviar y recibir dinero en Peanut.",
+ "teamTitle": "Nuestro Equipo",
+ "teamSubtitle": "Las personas detrás de Peanut.",
+ "lastUpdated": "Última actualización: {date}",
+ "relatedPages": "Páginas Relacionadas"
+}
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index aa3b93664..86f9005a3 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -1,15 +1,19 @@
import type { Locale, Translations } from './types'
import en from './en.json'
-import es from './es.json'
-import pt from './pt.json'
+import es419 from './es-419.json'
+import esAr from './es-ar.json'
+import esEs from './es-es.json'
+import ptBr from './pt-br.json'
const messages: Record = {
en: en as Translations,
- es: es as Translations,
- pt: pt as Translations,
+ 'es-419': es419 as Translations,
+ 'es-ar': esAr as Translations,
+ 'es-es': esEs as Translations,
+ 'pt-br': ptBr as Translations,
}
-/** Get translations for a locale */
+/** Get translations for a locale (falls back to English) */
export function getTranslations(locale: Locale): Translations {
return messages[locale] ?? messages.en
}
diff --git a/src/i18n/pt.json b/src/i18n/pt-br.json
similarity index 100%
rename from src/i18n/pt.json
rename to src/i18n/pt-br.json
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index 3b7829175..fd1297e76 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -1,6 +1,6 @@
-export type Locale = 'en' | 'es' | 'pt'
+export type Locale = 'en' | 'es-419' | 'es-ar' | 'es-es' | 'pt-br'
-export const SUPPORTED_LOCALES: Locale[] = ['en', 'es', 'pt']
+export const SUPPORTED_LOCALES: Locale[] = ['en', 'es-419', 'es-ar', 'es-es', 'pt-br']
export const DEFAULT_LOCALE: Locale = 'en'
export interface Translations {
diff --git a/src/lib/content.ts b/src/lib/content.ts
index 619dd7330..c82de6dd1 100644
--- a/src/lib/content.ts
+++ b/src/lib/content.ts
@@ -1,6 +1,11 @@
-// Unified content loader for per-entity content directories.
-// Reads from peanut-content/{countries,exchanges,competitors}// structure.
-// Parses YAML for data files and YAML frontmatter + Markdown body from .md files.
+// Unified content loader for peanutprotocol/peanut-content.
+//
+// Two read paths:
+// readEntityData(category, slug) → input/data/{category}/{slug}.md (frontmatter only)
+// readPageContent(intent, slug, lang) → content/{intent}/{slug}/{lang}.md (frontmatter + body)
+//
+// Discovers entities by scanning directories. No _index.yaml dependency.
+// Implements locale fallback chains per BCP 47 codes.
import fs from 'fs'
import path from 'path'
@@ -8,78 +13,198 @@ import matter from 'gray-matter'
const CONTENT_ROOT = path.join(process.cwd(), 'src/content')
-const yaml = matter.engines.yaml
+// --- Locale fallback chains ---
+// es-ar → es-419 → en
+// es-es → en
+// pt-br → en
+// es-419 → en
-// --- Low-level readers (cached per filepath for the lifetime of the process) ---
-
-const yamlCache = new Map()
-const mdCache = new Map()
+const FALLBACK_CHAINS: Record = {
+ en: [],
+ 'es-419': ['en'],
+ 'es-ar': ['es-419', 'en'],
+ 'es-es': ['en'],
+ 'pt-br': ['en'],
+}
-function readYamlFile(filePath: string): T | null {
- if (yamlCache.has(filePath)) return yamlCache.get(filePath) as T | null
- try {
- const result = yaml.parse(fs.readFileSync(filePath, 'utf8')) as T
- yamlCache.set(filePath, result)
- return result
- } catch {
- yamlCache.set(filePath, null)
- return null
- }
+/** Get ordered list of locales to try (requested locale first, then fallbacks) */
+export function getLocaleFallbacks(locale: string): string[] {
+ return [locale, ...(FALLBACK_CHAINS[locale] ?? ['en'])]
}
-interface MarkdownContent> {
+// --- Caches ---
+// In development, skip caching so content changes are picked up without restart.
+
+const isDev = process.env.NODE_ENV === 'development'
+
+const entityCache = new Map()
+const pageCache = new Map()
+
+// --- Core types ---
+
+export interface MarkdownContent> {
frontmatter: T
body: string
}
-function readMarkdownFile>(filePath: string): MarkdownContent | null {
- if (mdCache.has(filePath)) return mdCache.get(filePath) as MarkdownContent | null
+// --- Low-level readers ---
+
+function parseMarkdownFile>(filePath: string): MarkdownContent | null {
try {
const raw = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(raw)
- const result: MarkdownContent = { frontmatter: data as T, body: content.trim() }
- mdCache.set(filePath, result)
- return result
+ return { frontmatter: data as T, body: content.trim() }
} catch {
- mdCache.set(filePath, null)
return null
}
}
-// --- Entity directory readers ---
+// --- Entity data readers (input/data/{category}/{slug}.md) ---
+
+/** Read structured entity data from input/data/{category}/{slug}.md */
+export function readEntityData>(category: string, slug: string): MarkdownContent | null {
+ const key = `entity:${category}/${slug}`
+ if (!isDev && entityCache.has(key)) return entityCache.get(key) as MarkdownContent | null
-/** Read data.yaml from an entity directory */
-export function readEntitySeo(entityType: string, slug: string): T | null {
- return readYamlFile(path.join(CONTENT_ROOT, entityType, slug, 'data.yaml'))
+ const filePath = path.join(CONTENT_ROOT, 'input/data', category, `${slug}.md`)
+ const result = parseMarkdownFile(filePath)
+ entityCache.set(key, result)
+ return result
}
-/** Read a locale .md file from an entity directory */
-export function readEntityContent>(
- entityType: string,
+// --- Page content readers (content/{intent}/{slug}/{lang}.md) ---
+
+/** Read generated page content from content/{intent}/{slug}/{lang}.md */
+export function readPageContent>(
+ intent: string,
slug: string,
- locale: string
+ lang: string
): MarkdownContent | null {
- return readMarkdownFile(path.join(CONTENT_ROOT, entityType, slug, `${locale}.md`))
+ const key = `page:${intent}/${slug}/${lang}`
+ if (!isDev && pageCache.has(key)) return pageCache.get(key) as MarkdownContent | null
+
+ const filePath = path.join(CONTENT_ROOT, 'content', intent, slug, `${lang}.md`)
+ const result = parseMarkdownFile(filePath)
+ pageCache.set(key, result)
+ return result
+}
+
+/** Read page content with locale fallback */
+export function readPageContentLocalized>(
+ intent: string,
+ slug: string,
+ lang: string
+): MarkdownContent | null {
+ for (const locale of getLocaleFallbacks(lang)) {
+ const content = readPageContent(intent, slug, locale)
+ if (content) return content
+ }
+ return null
}
-/** Read the _index.yaml manifest for an entity type */
-export function readEntityIndex(entityType: string): T | null {
- return readYamlFile(path.join(CONTENT_ROOT, entityType, '_index.yaml'))
+/** Read corridor content: content/send-to/{destination}/from/{origin}/{lang}.md */
+export function readCorridorContent>(
+ destination: string,
+ origin: string,
+ lang: string
+): MarkdownContent | null {
+ const key = `corridor:${destination}/from/${origin}/${lang}`
+ if (!isDev && pageCache.has(key)) return pageCache.get(key) as MarkdownContent | null
+
+ const filePath = path.join(CONTENT_ROOT, 'content/send-to', destination, 'from', origin, `${lang}.md`)
+ const result = parseMarkdownFile(filePath)
+ pageCache.set(key, result)
+ return result
+}
+
+/** Read corridor content with locale fallback */
+export function readCorridorContentLocalized>(
+ destination: string,
+ origin: string,
+ lang: string
+): MarkdownContent | null {
+ for (const locale of getLocaleFallbacks(lang)) {
+ const content = readCorridorContent(destination, origin, locale)
+ if (content) return content
+ }
+ return null
}
-/** List all entity slugs by reading _index.yaml (published only) */
-export function listEntitySlugs(entityType: string, key: string): string[] {
- const index = readEntityIndex>>(entityType)
- if (!index?.[key]) return []
- return index[key].filter((item) => (item.status ?? 'published') === 'published').map((item) => item.slug)
+// --- Directory scanners (replaces _index.yaml) ---
+
+/** List all entity slugs in a category by scanning input/data/{category}/ */
+export function listEntitySlugs(category: string): string[] {
+ const dir = path.join(CONTENT_ROOT, 'input/data', category)
+ try {
+ return fs
+ .readdirSync(dir)
+ .filter((f) => f.endsWith('.md') && f !== 'README.md')
+ .map((f) => f.replace('.md', ''))
+ } catch {
+ return []
+ }
+}
+
+/** List all content slugs for an intent by scanning content/{intent}/ */
+export function listContentSlugs(intent: string): string[] {
+ const dir = path.join(CONTENT_ROOT, 'content', intent)
+ try {
+ return fs.readdirSync(dir).filter((f) => {
+ const stat = fs.statSync(path.join(dir, f))
+ return stat.isDirectory()
+ })
+ } catch {
+ return []
+ }
+}
+
+/** List corridor origins for a destination: content/send-to/{destination}/from/ */
+export function listCorridorOrigins(destination: string): string[] {
+ const dir = path.join(CONTENT_ROOT, 'content/send-to', destination, 'from')
+ try {
+ return fs.readdirSync(dir).filter((f) => {
+ const stat = fs.statSync(path.join(dir, f))
+ return stat.isDirectory()
+ })
+ } catch {
+ return []
+ }
+}
+
+/** List available locales for a content page */
+export function listPageLocales(intent: string, slug: string): string[] {
+ const dir = path.join(CONTENT_ROOT, 'content', intent, slug)
+ try {
+ return fs
+ .readdirSync(dir)
+ .filter((f) => f.endsWith('.md'))
+ .map((f) => f.replace('.md', ''))
+ } catch {
+ return []
+ }
+}
+
+/** Check if a page content file exists for the given locale (no fallback) */
+export function pageLocaleExists(intent: string, slug: string, locale: string): boolean {
+ return fs.existsSync(path.join(CONTENT_ROOT, 'content', intent, slug, `${locale}.md`))
+}
+
+// --- Publication status ---
+
+interface PublishableContent {
+ published?: boolean
}
-/** Check if an entity is published (missing status = published) */
-export function isPublished(entry: { status?: string }): boolean {
- return (entry.status ?? 'published') === 'published'
+/** Check if content is published (defaults to false if field missing) */
+export function isPublished(content: MarkdownContent | null): boolean {
+ if (!content) return false
+ return content.frontmatter.published === true
}
-/** Check if a locale file exists for an entity */
-export function entityLocaleExists(entityType: string, slug: string, locale: string): boolean {
- return fs.existsSync(path.join(CONTENT_ROOT, entityType, slug, `${locale}.md`))
+/** List published content slugs for an intent */
+export function listPublishedSlugs(intent: string): string[] {
+ return listContentSlugs(intent).filter((slug) => {
+ const content = readPageContent(intent, slug, 'en')
+ return isPublished(content)
+ })
}
diff --git a/src/lib/mdx.ts b/src/lib/mdx.ts
new file mode 100644
index 000000000..2686462ea
--- /dev/null
+++ b/src/lib/mdx.ts
@@ -0,0 +1,29 @@
+import { compileMDX } from 'next-mdx-remote/rsc'
+import remarkGfm from 'remark-gfm'
+import { mdxComponents } from '@/components/Marketing/mdx/components'
+
+/**
+ * Compile markdown/MDX content into a React element with registered components.
+ * Uses next-mdx-remote/rsc for server-side rendering (zero client JS).
+ *
+ * Note: frontmatter is already stripped by content.ts (gray-matter).
+ * The source passed here is body-only — no parseFrontmatter needed.
+ *
+ * format: 'mdx' — enables JSX component tags in content.
+ * remarkGfm — enables GFM tables, strikethrough, autolinks, etc.
+ *
+ * Limitation: next-mdx-remote/rsc strips JSX expression props ({...}).
+ * Components that need structured data accept JSON strings instead.
+ */
+export async function renderContent(source: string) {
+ return compileMDX>({
+ source,
+ components: mdxComponents,
+ options: {
+ mdxOptions: {
+ format: 'mdx',
+ remarkPlugins: [remarkGfm],
+ },
+ },
+ })
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 6056bd61b..c851dcfb7 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -717,32 +717,36 @@ input::placeholder {
/* ── Landing page: entrance animations (replaces framer-motion whileInView) ── */
/* Spring animation approximating framer-motion { type: 'spring', damping: 5, stiffness: 100 }
- damping:5 is heavily underdamped — large overshoot, multiple visible oscillations.
+ damping:5 is heavily underdamped — large overshoot with visible bounces.
Sampled from spring physics: e^(-ζωt) * cos(ωd*t) with ζ=0.25, ω=10 */
@keyframes fade-in-up-spring {
0% {
opacity: 0;
transform: translateY(var(--aov-y, 20px)) translateX(var(--aov-x, 0px)) rotate(var(--aov-rotate, 0deg));
}
- 15% {
+ 12% {
opacity: 1;
- transform: translateY(calc(var(--aov-y, 20px) * -0.6)) translateX(calc(var(--aov-x, 0px) * -0.6))
+ transform: translateY(calc(var(--aov-y, 20px) * -1.2)) translateX(calc(var(--aov-x, 0px) * -1.2))
rotate(var(--aov-rotate, 0deg));
}
- 30% {
- transform: translateY(calc(var(--aov-y, 20px) * 0.35)) translateX(calc(var(--aov-x, 0px) * 0.35))
+ 24% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.6)) translateX(calc(var(--aov-x, 0px) * 0.6))
rotate(var(--aov-rotate, 0deg));
}
- 45% {
- transform: translateY(calc(var(--aov-y, 20px) * -0.2)) translateX(calc(var(--aov-x, 0px) * -0.2))
+ 38% {
+ transform: translateY(calc(var(--aov-y, 20px) * -0.35)) translateX(calc(var(--aov-x, 0px) * -0.35))
rotate(var(--aov-rotate, 0deg));
}
- 60% {
- transform: translateY(calc(var(--aov-y, 20px) * 0.1)) translateX(calc(var(--aov-x, 0px) * 0.1))
+ 52% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.18)) translateX(calc(var(--aov-x, 0px) * 0.18))
rotate(var(--aov-rotate, 0deg));
}
- 75% {
- transform: translateY(calc(var(--aov-y, 20px) * -0.05)) translateX(calc(var(--aov-x, 0px) * -0.05))
+ 68% {
+ transform: translateY(calc(var(--aov-y, 20px) * -0.08)) translateX(calc(var(--aov-x, 0px) * -0.08))
+ rotate(var(--aov-rotate, 0deg));
+ }
+ 82% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.03)) translateX(calc(var(--aov-x, 0px) * 0.03))
rotate(var(--aov-rotate, 0deg));
}
100% {
@@ -756,6 +760,17 @@ input::placeholder {
}
.animate-on-view.in-view {
- animation: fade-in-up-spring 1.4s linear forwards;
+ animation: fade-in-up-spring 1.8s linear forwards;
animation-delay: var(--aov-delay, 0s);
}
+
+/* ── Marketing content pages ── */
+
+/*
+ * Prose styling is handled via MDX element mappings in components.tsx.
+ * Only structural and zebra-stripe styles remain here (can't be done via components).
+ */
+
+.content-page tbody tr:nth-child(even) {
+ @apply bg-primary-3/30;
+}
From ac8534eeb784d8de0c3164292818cb753550e8d3 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sun, 22 Feb 2026 10:43:24 +0000
Subject: [PATCH 11/61] fix: CI auth for private content submodule, publish
argentina
---
.github/workflows/tests.yml | 1 +
src/content | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 18aad0ce8..723d717c4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -11,6 +11,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: true
+ token: ${{ secrets.SUBMODULE_TOKEN }}
- uses: actions/setup-node@v4
with:
diff --git a/src/content b/src/content
index 4fb4f6085..7b5e8543e 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 4fb4f60856671cb426b6f108496f052c63708ae7
+Subproject commit 7b5e8543ebd90ad9cc4a8496259cc8ec8a6d0c97
From bd0e575078e9bbb911c847c239357807a295186f Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sun, 22 Feb 2026 17:41:14 +0000
Subject: [PATCH 12/61] feat: MDX-first rendering for all marketing pages +
cleanup
Phase 1: All 7 marketing page routes now render MDX content first
with React fallback. Pages without MDX content return 404 or use
remaining fallback components.
Phase 4 cleanup:
- Delete 4 fallback React components (CorridorPageContent,
HubPageContent, FromToCorridorContent, PayWithContent)
- Remove fallback blocks from send-money-to, country hub,
send-money-from, and pay-with page routes
- Remove 26 unused i18n keys from all 5 locale JSONs + types
- Keep ReceiveMoneyContent (no MDX content yet) and compare/deposit
fallbacks as safety nets
Fix: Add spacer after Hero marquee for consistent prose spacing
on pages without ExchangeWidget (e.g. compare pages).
Update content submodule to include Phase 3 template updates.
---
.../[locale]/(marketing)/[country]/page.tsx | 37 ++-
.../(marketing)/compare/[slug]/page.tsx | 43 +++
.../(marketing)/deposit/[exchange]/page.tsx | 42 +++
.../(marketing)/pay-with/[method]/page.tsx | 35 ++-
.../receive-money-from/[country]/page.tsx | 22 ++
.../send-money-from/[from]/to/[to]/page.tsx | 44 ++-
.../send-money-to/[country]/page.tsx | 67 ++---
src/components/Marketing/mdx/Hero.tsx | 2 +
.../Marketing/pages/CorridorPageContent.tsx | 171 -----------
.../Marketing/pages/FromToCorridorContent.tsx | 266 ------------------
.../Marketing/pages/HubPageContent.tsx | 257 -----------------
.../Marketing/pages/PayWithContent.tsx | 106 -------
src/content | 2 +-
src/i18n/en.json | 26 --
src/i18n/es-419.json | 26 --
src/i18n/es-ar.json | 26 --
src/i18n/es-es.json | 26 --
src/i18n/pt-br.json | 26 --
src/i18n/types.ts | 30 --
19 files changed, 216 insertions(+), 1038 deletions(-)
delete mode 100644 src/components/Marketing/pages/CorridorPageContent.tsx
delete mode 100644 src/components/Marketing/pages/FromToCorridorContent.tsx
delete mode 100644 src/components/Marketing/pages/HubPageContent.tsx
delete mode 100644 src/components/Marketing/pages/PayWithContent.tsx
diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx
index a5a2692b5..a2daeda93 100644
--- a/src/app/[locale]/(marketing)/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/[country]/page.tsx
@@ -3,9 +3,10 @@ import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
import { SUPPORTED_LOCALES, isValidLocale, getBareAlternates } from '@/i18n/config'
-import type { Locale } from '@/i18n/types'
-import { getTranslations, t } from '@/i18n'
-import { HubPageContent } from '@/components/Marketing/pages/HubPageContent'
+import { getTranslations } from '@/i18n'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ locale: string; country: string }>
@@ -24,13 +25,17 @@ export async function generateMetadata({ params }: PageProps): Promise
const seo = COUNTRIES_SEO[country]
if (!seo) return {}
- const i18n = getTranslations(locale as Locale)
- const countryName = getCountryName(country, locale as Locale)
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'countries',
+ country,
+ locale
+ )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
return {
...metadataHelper({
- title: `${t(i18n.hubTitle, { country: countryName })} | Peanut`,
- description: t(i18n.hubSubtitle, { country: countryName }),
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
canonical: `/${locale}/${country}`,
}),
alternates: {
@@ -45,5 +50,21 @@ export default async function CountryHubPage({ params }: PageProps) {
if (!isValidLocale(locale)) notFound()
if (!COUNTRIES_SEO[country]) notFound()
- return
+ const mdxSource = readPageContentLocalized('countries', country, locale)
+ if (!mdxSource || mdxSource.frontmatter.published === false) notFound()
+
+ const { content } = await renderContent(mdxSource.body)
+ const i18n = getTranslations(locale)
+ const countryName = getCountryName(country, locale)
+
+ return (
+
+ {content}
+
+ )
}
diff --git a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
index 765f42a1b..b595c1199 100644
--- a/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
@@ -12,6 +12,9 @@ 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 { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ locale: string; slug: string }>
@@ -37,6 +40,28 @@ export async function generateMetadata({ params }: PageProps): Promise
if (!slug) return {}
const competitor = COMPETITORS[slug]
if (!competitor) return {}
+
+ // Try MDX content frontmatter first
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ '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}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/compare/peanut-vs-${slug}`,
+ languages: getAlternates('compare', `peanut-vs-${slug}`),
+ },
+ }
+ }
+
+ // Fallback: i18n-based metadata
const year = new Date().getFullYear()
return {
@@ -61,6 +86,24 @@ 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)
+ return (
+
+ {content}
+
+ )
+ }
+
+ // Fallback: old React-driven page
const i18n = getTranslations(locale as Locale)
const year = new Date().getFullYear()
diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
index 39e50c9dc..36d004e36 100644
--- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -13,6 +13,9 @@ 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 { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ locale: string; exchange: string }>
@@ -41,6 +44,27 @@ export async function generateMetadata({ params }: PageProps): Promise
const ex = EXCHANGES[exchange]
if (!ex) return {}
+ // Try MDX content frontmatter first
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'deposit',
+ exchange,
+ locale
+ )
+ if (mdxContent && mdxContent.frontmatter.published !== false) {
+ return {
+ ...metadataHelper({
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
+ canonical: `/${locale}/deposit/from-${exchange}`,
+ }),
+ alternates: {
+ canonical: `/${locale}/deposit/from-${exchange}`,
+ languages: getAlternates('deposit', `from-${exchange}`),
+ },
+ }
+ }
+
+ // Fallback: i18n-based metadata
const i18n = getTranslations(locale as Locale)
return {
@@ -65,6 +89,24 @@ 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)
+ return (
+
+ {content}
+
+ )
+ }
+
+ // Fallback: old React-driven page
const i18n = getTranslations(locale as Locale)
const steps = ex.steps.map((step, i) => ({
diff --git a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
index 556a7fe5e..9fec9dd8a 100644
--- a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
+++ b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
@@ -3,9 +3,10 @@ import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
import { PAYMENT_METHODS, PAYMENT_METHOD_SLUGS } from '@/data/seo'
import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
-import type { Locale } from '@/i18n/types'
-import { getTranslations, t } from '@/i18n'
-import { PayWithContent } from '@/components/Marketing/pages/PayWithContent'
+import { getTranslations } from '@/i18n'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ locale: string; method: string }>
@@ -23,12 +24,17 @@ export async function generateMetadata({ params }: PageProps): Promise
const pm = PAYMENT_METHODS[method]
if (!pm) return {}
- const i18n = getTranslations(locale as Locale)
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'pay-with',
+ method,
+ locale
+ )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
return {
...metadataHelper({
- title: `${t(i18n.payWith, { method: pm.name })} | Peanut`,
- description: t(i18n.payWithDesc, { method: pm.name }),
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
canonical: `/${locale}/pay-with/${method}`,
}),
alternates: {
@@ -45,5 +51,20 @@ export default async function PayWithPage({ params }: PageProps) {
const pm = PAYMENT_METHODS[method]
if (!pm) notFound()
- return
+ const mdxSource = readPageContentLocalized('pay-with', method, locale)
+ if (!mdxSource || mdxSource.frontmatter.published === false) notFound()
+
+ const { content } = await renderContent(mdxSource.body)
+ const i18n = getTranslations(locale)
+
+ return (
+
+ {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 f8c0752d9..e650c1652 100644
--- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
@@ -6,6 +6,9 @@ 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'
interface PageProps {
params: Promise<{ locale: string; country: string }>
@@ -48,5 +51,24 @@ 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}
+
+ )
+ }
+
+ // Fallback: old React-driven page
return
}
diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
index dd2bef1d9..45faf9e8f 100644
--- a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -1,12 +1,12 @@
import { notFound } from 'next/navigation'
import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
-import { COUNTRIES_SEO, CORRIDORS, getCountryName } from '@/data/seo'
+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 { FromToCorridorContent } from '@/components/Marketing/pages/FromToCorridorContent'
-import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
+import { getTranslations } from '@/i18n'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readCorridorContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
interface PageProps {
params: Promise<{ locale: string; from: string; to: string }>
@@ -23,18 +23,16 @@ export async function generateMetadata({ params }: PageProps): Promise
if (!CORRIDORS.some((c) => c.from === from && c.to === to)) return {}
- const i18n = getTranslations(locale as Locale)
- const fromName = getCountryName(from, locale as Locale)
- const toName = getCountryName(to, locale as Locale)
+ const mdxContent = readCorridorContentLocalized(to, from, locale)
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
- const toMapping = countryCurrencyMappings.find(
- (m) => m.path === to || m.country.toLowerCase().replace(/ /g, '-') === to
- )
+ const fm = mdxContent.frontmatter as { title?: string; description?: string }
+ if (!fm.title || !fm.description) return {}
return {
...metadataHelper({
- title: `${t(i18n.sendMoneyFromTo, { from: fromName, to: toName })} | Peanut`,
- description: t(i18n.sendMoneyFromToDesc, { from: fromName, to: toName }),
+ title: fm.title,
+ description: fm.description,
canonical: `/${locale}/send-money-from/${from}/to/${to}`,
}),
alternates: {
@@ -49,5 +47,23 @@ export default async function FromToCorridorPage({ params }: PageProps) {
if (!isValidLocale(locale)) notFound()
if (!CORRIDORS.some((c) => c.from === from && c.to === to)) notFound()
- return
+ const mdxSource = readCorridorContentLocalized(to, from, locale)
+ if (!mdxSource || mdxSource.frontmatter.published === false) notFound()
+
+ const { content } = await renderContent(mdxSource.body)
+ const i18n = getTranslations(locale)
+ const fromName = getCountryName(from, locale)
+ const toName = getCountryName(to, locale)
+
+ return (
+
+ {content}
+
+ )
}
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
index 382b9b416..18d7c7221 100644
--- a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -4,12 +4,10 @@ import { generateMetadata as metadataHelper } from '@/app/metadata'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
import { SUPPORTED_LOCALES, getAlternates, isValidLocale, localizedPath } from '@/i18n/config'
import type { Locale } from '@/i18n/types'
-import { getTranslations, t } from '@/i18n'
-import { CorridorPageContent } from '@/components/Marketing/pages/CorridorPageContent'
+import { getTranslations } from '@/i18n'
import { ContentPage } from '@/components/Marketing/ContentPage'
import { readPageContentLocalized } from '@/lib/content'
import { renderContent } from '@/lib/mdx'
-import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
interface PageProps {
params: Promise<{ locale: string; country: string }>
@@ -28,36 +26,13 @@ export async function generateMetadata({ params }: PageProps): Promise
const seo = COUNTRIES_SEO[country]
if (!seo) return {}
- // Try MDX content frontmatter first
const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>('send-to', country, locale)
- if (mdxContent && mdxContent.frontmatter.published !== false) {
- return {
- ...metadataHelper({
- title: mdxContent.frontmatter.title,
- description: mdxContent.frontmatter.description,
- canonical: `/${locale}/send-money-to/${country}`,
- }),
- alternates: {
- canonical: `/${locale}/send-money-to/${country}`,
- languages: getAlternates('send-money-to', country),
- },
- }
- }
-
- // Fallback: i18n-based metadata
- const i18n = getTranslations(locale as Locale)
- const countryName = getCountryName(country, locale as Locale)
- const mapping = countryCurrencyMappings.find(
- (m) => m.path === country || m.country.toLowerCase().replace(/ /g, '-') === country
- )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
return {
...metadataHelper({
- title: `${t(i18n.sendMoneyTo, { country: countryName })} | Peanut`,
- description: t(i18n.sendMoneyToSubtitle, {
- country: countryName,
- currency: mapping?.currencyCode ?? '',
- }),
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
canonical: `/${locale}/send-money-to/${country}`,
}),
alternates: {
@@ -71,26 +46,22 @@ export default async function SendMoneyToCountryPageLocalized({ params }: PagePr
const { locale, country } = await params
if (!isValidLocale(locale)) notFound()
- // Try MDX content first
const mdxSource = readPageContentLocalized('send-to', country, locale)
- if (mdxSource && mdxSource.frontmatter.published !== false) {
- const { content } = await renderContent(mdxSource.body)
- const i18n = getTranslations(locale)
- const countryName = getCountryName(country, locale)
+ if (!mdxSource || mdxSource.frontmatter.published === false) notFound()
- return (
-
- {content}
-
- )
- }
+ const { content } = await renderContent(mdxSource.body)
+ const i18n = getTranslations(locale)
+ const countryName = getCountryName(country, locale)
- // Fallback: old i18n-based page content
- return
+ return (
+
+ {content}
+
+ )
}
diff --git a/src/components/Marketing/mdx/Hero.tsx b/src/components/Marketing/mdx/Hero.tsx
index e6fba9a81..4230a9123 100644
--- a/src/components/Marketing/mdx/Hero.tsx
+++ b/src/components/Marketing/mdx/Hero.tsx
@@ -54,6 +54,8 @@ export function Hero({ title, subtitle, cta, ctaHref, currency }: HeroProps) {
backgroundColor="bg-secondary-1"
/>
{currency && }
+ {/* Spacer ensures consistent gap between Hero block and prose content */}
+
>
)
}
diff --git a/src/components/Marketing/pages/CorridorPageContent.tsx b/src/components/Marketing/pages/CorridorPageContent.tsx
deleted file mode 100644
index 3e7d69335..000000000
--- a/src/components/Marketing/pages/CorridorPageContent.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { notFound } from 'next/navigation'
-import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
-import { COUNTRIES_SEO, getLocalizedSEO, getCountryName } from '@/data/seo'
-import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
-import type { Locale } from '@/i18n/types'
-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 { DestinationGrid } from '@/components/Marketing/DestinationGrid'
-import { JsonLd } from '@/components/Marketing/JsonLd'
-import { RelatedPages } from '@/components/Marketing/RelatedPages'
-
-interface CorridorPageContentProps {
- country: string
- locale: Locale
-}
-
-export function CorridorPageContent({ country, locale }: CorridorPageContentProps) {
- const seo = getLocalizedSEO(country, locale)
- if (!seo) notFound()
-
- const i18n = getTranslations(locale)
- const countryName = getCountryName(country, locale)
-
- const mapping = findMappingBySlug(country)
- const currencyCode = mapping?.currencyCode ?? ''
- const flagCode = mapping?.flagCode
-
- const howToSteps = [
- {
- title: t(i18n.stepCreateAccount),
- description: t(i18n.stepCreateAccountDesc),
- },
- {
- title: t(i18n.stepDepositFunds),
- description: t(i18n.stepDepositFundsDesc, { method: seo.instantPayment ?? '' }),
- },
- {
- title: t(i18n.stepSendTo, { country: countryName }),
- description: t(i18n.stepSendToDesc, {
- currency: currencyCode || 'local currency',
- method: seo.instantPayment ?? 'bank transfer',
- }),
- },
- ]
-
- const baseUrl = 'https://peanut.me'
- const canonicalPath = localizedPath('send-money-to', locale, country)
-
- const howToSchema = {
- '@context': 'https://schema.org',
- '@type': 'HowTo',
- name: t(i18n.sendMoneyTo, { country: countryName }),
- description: t(i18n.sendMoneyToSubtitle, { country: countryName, currency: currencyCode }),
- inLanguage: locale,
- step: howToSteps.map((step, i) => ({
- '@type': 'HowToStep',
- position: i + 1,
- name: step.title,
- text: step.description,
- })),
- }
-
- const breadcrumbSchema = {
- '@context': 'https://schema.org',
- '@type': 'BreadcrumbList',
- itemListElement: [
- { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
- {
- '@type': 'ListItem',
- position: 2,
- name: i18n.sendMoney,
- item: `${baseUrl}${localizedPath('send-money-to', locale)}`,
- },
- { '@type': 'ListItem', position: 3, name: countryName, item: `${baseUrl}${canonicalPath}` },
- ],
- }
-
- const otherCountries = Object.keys(COUNTRIES_SEO).filter((c) => c !== country)
-
- return (
- <>
-
-
-
-
-
-
-
-
- {flagCode && (
-
- )}
-
{seo.context}
-
-
-
-
-
- {seo.instantPayment && (
-
-
-
-
{seo.instantPayment}
-
- {t(i18n.instantDeposits, { method: seo.instantPayment, country: countryName })}
- {seo.payMerchants && ` ${i18n.qrPayments}`}
-
-
-
-
{i18n.stablecoins}
-
- {t(i18n.stablecoinsDesc, { currency: currencyCode || 'local currency' })}
-
-
-
-
{i18n.bankTransfer}
-
{i18n.bankTransferDesc}
-
-
-
- )}
-
- {seo.faqs.length > 0 && }
-
- {/* Related pages */}
-
-
-
-
- {/* Last updated */}
-
- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })}
-
-
- >
- )
-}
diff --git a/src/components/Marketing/pages/FromToCorridorContent.tsx b/src/components/Marketing/pages/FromToCorridorContent.tsx
deleted file mode 100644
index 66857bbec..000000000
--- a/src/components/Marketing/pages/FromToCorridorContent.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-import Link from 'next/link'
-import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
-import { COUNTRIES_SEO, getLocalizedSEO, getCountryName, CORRIDORS } from '@/data/seo'
-import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
-import type { Locale } from '@/i18n/types'
-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 { RelatedPages } from '@/components/Marketing/RelatedPages'
-import { Card } from '@/components/0_Bruddle/Card'
-
-interface FromToCorridorContentProps {
- from: string
- to: string
- locale: Locale
-}
-
-export function FromToCorridorContent({ from, to, locale }: FromToCorridorContentProps) {
- const i18n = getTranslations(locale)
- const fromName = getCountryName(from, locale)
- const toName = getCountryName(to, locale)
-
- const toSeo = getLocalizedSEO(to, locale)
- const fromSeo = getLocalizedSEO(from, locale)
-
- const fromMapping = findMappingBySlug(from)
- const toMapping = findMappingBySlug(to)
-
- const fromCurrency = fromMapping?.currencyCode ?? ''
- const toCurrency = toMapping?.currencyCode ?? ''
-
- const howToSteps = [
- {
- title: t(i18n.stepCreateAccount),
- description: t(i18n.stepCreateAccountDesc),
- },
- {
- title: t(i18n.stepDepositFunds),
- description: t(i18n.stepDepositFundsDesc, { method: fromSeo?.instantPayment ?? '' }),
- },
- {
- title: t(i18n.stepSendTo, { country: toName }),
- description: t(i18n.stepSendToDesc, {
- currency: toCurrency || 'local currency',
- method: toSeo?.instantPayment ?? 'bank transfer',
- }),
- },
- ]
-
- const baseUrl = 'https://peanut.me'
- const canonicalPath = `/${locale}/send-money-from/${from}/to/${to}`
-
- const howToSchema = {
- '@context': 'https://schema.org',
- '@type': 'HowTo',
- name: t(i18n.sendMoneyFromTo, { from: fromName, to: toName }),
- description: t(i18n.sendMoneyFromToDesc, { from: fromName, to: toName }),
- inLanguage: locale,
- step: howToSteps.map((step, i) => ({
- '@type': 'HowToStep',
- position: i + 1,
- name: step.title,
- text: step.description,
- })),
- }
-
- const breadcrumbSchema = {
- '@context': 'https://schema.org',
- '@type': 'BreadcrumbList',
- itemListElement: [
- { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
- {
- '@type': 'ListItem',
- position: 2,
- name: fromName,
- item: `${baseUrl}${localizedBarePath(locale, from)}`,
- },
- {
- '@type': 'ListItem',
- position: 3,
- name: t(i18n.sendMoneyFromTo, { from: fromName, to: toName }),
- item: `${baseUrl}${canonicalPath}`,
- },
- ],
- }
-
- // Build FAQ from destination country FAQs
- const faqs = toSeo?.faqs ?? []
-
- // Related corridors from the same origin
- const relatedFromSame = CORRIDORS.filter((c) => c.from === from && c.to !== to).slice(0, 6)
-
- // Related pages for internal linking
- const relatedPages = [
- {
- title: t(i18n.hubTitle, { country: fromName }),
- href: localizedBarePath(locale, from),
- },
- {
- title: t(i18n.hubTitle, { country: toName }),
- href: localizedBarePath(locale, to),
- },
- {
- title: t(i18n.sendMoneyTo, { country: toName }),
- href: localizedPath('send-money-to', locale, to),
- },
- ]
-
- if (toCurrency) {
- relatedPages.push({
- title: t(i18n.convertTitle, { from: fromCurrency || 'USD', to: toCurrency }),
- href: localizedPath(
- 'convert',
- locale,
- `${(fromCurrency || 'usd').toLowerCase()}-to-${toCurrency.toLowerCase()}`
- ),
- })
- }
-
- const today = new Date().toISOString().split('T')[0]
-
- return (
- <>
-
-
-
-
-
-
- {/* Route summary card */}
-
-
-
- {fromMapping?.flagCode && (
-
- )}
-
-
{i18n.sendMoney}
-
{fromName}
- {fromCurrency &&
{fromCurrency} }
-
-
- →
-
- {toMapping?.flagCode && (
-
- )}
-
-
- {t(i18n.receiveMoneyFrom, { country: '' }).trim()}
-
-
{toName}
- {toCurrency &&
{toCurrency} }
-
-
-
-
-
- {/* Context paragraph */}
-
- {t(i18n.fromToContext, { from: fromName, to: toName })}
- {toSeo?.context && {toSeo.context}
}
-
-
- {/* How it works */}
-
-
- {/* Payment methods */}
- {(toSeo?.instantPayment || fromSeo?.instantPayment) && (
-
-
- {fromSeo?.instantPayment && (
-
-
- {fromSeo.instantPayment} ({fromName})
-
-
- {t(i18n.instantDeposits, { method: fromSeo.instantPayment, country: fromName })}
-
-
- )}
- {toSeo?.instantPayment && (
-
-
- {toSeo.instantPayment} ({toName})
-
-
- {t(i18n.instantDeposits, { method: toSeo.instantPayment, country: toName })}
-
-
- )}
-
- {i18n.stablecoins}
-
- {t(i18n.stablecoinsDesc, { currency: toCurrency || 'local currency' })}
-
-
-
-
- )}
-
- {/* Other corridors from same origin */}
- {relatedFromSame.length > 0 && (
-
-
- {relatedFromSame.map((c) => {
- const destName = getCountryName(c.to, locale)
- const destMapping = findMappingBySlug(c.to)
- return (
-
-
- {destMapping?.flagCode && (
-
- )}
-
- {fromName} → {destName}
-
-
-
- )
- })}
-
-
- )}
-
- {/* FAQs */}
- {faqs.length > 0 && }
-
- {/* Related pages */}
-
-
- {/* Last updated */}
- {t(i18n.lastUpdated, { date: today })}
-
- >
- )
-}
diff --git a/src/components/Marketing/pages/HubPageContent.tsx b/src/components/Marketing/pages/HubPageContent.tsx
deleted file mode 100644
index 4b8162c65..000000000
--- a/src/components/Marketing/pages/HubPageContent.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-import { notFound } from 'next/navigation'
-import Link from 'next/link'
-import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
-import { COUNTRIES_SEO, getLocalizedSEO, getCountryName, CORRIDORS, COMPETITORS, EXCHANGES } from '@/data/seo'
-import { getTranslations, t, localizedPath } from '@/i18n'
-import type { Locale } from '@/i18n/types'
-import { MarketingHero } from '@/components/Marketing/MarketingHero'
-import { MarketingShell } from '@/components/Marketing/MarketingShell'
-import { Section } from '@/components/Marketing/Section'
-import { FAQSection } from '@/components/Marketing/FAQSection'
-import { DestinationGrid } from '@/components/Marketing/DestinationGrid'
-import { JsonLd } from '@/components/Marketing/JsonLd'
-import { Card } from '@/components/0_Bruddle/Card'
-
-interface HubPageContentProps {
- country: string
- locale: Locale
-}
-
-interface HubLink {
- title: string
- description: string
- href: string
- emoji: string
-}
-
-export function HubPageContent({ country, locale }: HubPageContentProps) {
- const seo = getLocalizedSEO(country, locale)
- if (!seo) notFound()
-
- const i18n = getTranslations(locale)
- const countryName = getCountryName(country, locale)
-
- const mapping = findMappingBySlug(country)
- const currencyCode = mapping?.currencyCode ?? ''
- const flagCode = mapping?.flagCode
-
- // Build hub spoke links
- const links: HubLink[] = []
-
- // 1. Send money corridor
- links.push({
- title: t(i18n.hubSendMoney, { country: countryName }),
- description: t(i18n.hubSendMoneyDesc, { country: countryName }),
- href: localizedPath('send-money-to', locale, country),
- emoji: '💸',
- })
-
- // 2. Convert pages (relevant currency pairs)
- if (currencyCode) {
- const lowerCurrency = currencyCode.toLowerCase()
- links.push({
- title: t(i18n.hubConvert, { currency: currencyCode }),
- description: t(i18n.hubConvertDesc),
- href: localizedPath('convert', locale, `usd-to-${lowerCurrency}`),
- emoji: '💱',
- })
- }
-
- // 3. Deposit pages (related exchanges from country seo)
- const relatedExchanges = Object.keys(EXCHANGES).slice(0, 3) // Top 3 exchanges
- if (relatedExchanges.length > 0) {
- links.push({
- title: t(i18n.hubDeposit),
- description: t(i18n.hubDepositDesc),
- href: localizedPath('deposit', locale, `from-${relatedExchanges[0]}`),
- emoji: '🏦',
- })
- }
-
- // 4. Compare pages (if relevant competitors exist)
- const competitorSlugs = Object.keys(COMPETITORS).filter(
- (slug) => !['mercado-pago', 'pix', 'dolar-mep', 'cueva'].includes(slug)
- )
- if (competitorSlugs.length > 0) {
- links.push({
- title: t(i18n.hubCompare),
- description: t(i18n.hubCompareDesc),
- href: localizedPath('compare', locale, `peanut-vs-${competitorSlugs[0]}`),
- emoji: '⚖️',
- })
- }
-
- // Inbound corridors: countries that send money TO this country
- const inboundCorridors = CORRIDORS.filter((c) => c.to === country).map((c) => c.from)
-
- // Outbound corridors: countries this country sends money TO
- const outboundCorridors = CORRIDORS.filter((c) => c.from === country).map((c) => c.to)
-
- // Other countries for the grid
- const otherCountries = Object.keys(COUNTRIES_SEO).filter((c) => c !== country)
-
- const baseUrl = 'https://peanut.me'
-
- const breadcrumbSchema = {
- '@context': 'https://schema.org',
- '@type': 'BreadcrumbList',
- itemListElement: [
- { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
- {
- '@type': 'ListItem',
- position: 2,
- name: countryName,
- item: `${baseUrl}/${locale}/${country}`,
- },
- ],
- }
-
- const webPageSchema = {
- '@context': 'https://schema.org',
- '@type': 'WebPage',
- name: t(i18n.hubTitle, { country: countryName }),
- description: t(i18n.hubSubtitle, { country: countryName }),
- inLanguage: locale,
- url: `${baseUrl}/${locale}/${country}`,
- }
-
- return (
- <>
-
-
-
-
-
-
- {/* Country context */}
-
-
- {flagCode && (
-
- )}
-
{seo.context}
-
-
-
- {/* Hub spoke links grid */}
-
-
- {links.map((link) => (
-
-
- {link.emoji}
- {link.title}
- {link.description}
-
-
- ))}
-
-
-
- {/* Inbound corridors */}
- {inboundCorridors.length > 0 && (
-
-
- {t(i18n.hubInboundCorridors, { country: countryName })}
-
-
- {inboundCorridors.map((fromSlug) => {
- const fromName = getCountryName(fromSlug, locale)
- const fromMapping = findMappingBySlug(fromSlug)
- return (
-
-
- {fromMapping?.flagCode && (
-
- )}
-
- {fromName} → {countryName}
-
-
-
- )
- })}
-
-
- )}
-
- {/* Outbound corridors */}
- {outboundCorridors.length > 0 && (
-
-
- {outboundCorridors.map((toSlug) => {
- const toName = getCountryName(toSlug, locale)
- const toMapping = findMappingBySlug(toSlug)
- return (
-
-
- {toMapping?.flagCode && (
-
- )}
-
- {countryName} → {toName}
-
-
-
- )
- })}
-
-
- )}
-
- {/* Instant payment highlight */}
- {seo.instantPayment && (
-
-
-
- {t(i18n.instantDeposits, {
- method: seo.instantPayment,
- country: countryName,
- })}
-
- {seo.payMerchants && {i18n.qrPayments}
}
-
-
- )}
-
- {/* FAQs */}
- {seo.faqs.length > 0 && }
-
- {/* Other countries grid */}
-
-
- {/* Last updated */}
-
- {t(i18n.lastUpdated, { date: new Date().toISOString().split('T')[0] })}
-
-
- >
- )
-}
diff --git a/src/components/Marketing/pages/PayWithContent.tsx b/src/components/Marketing/pages/PayWithContent.tsx
deleted file mode 100644
index b6dbb633d..000000000
--- a/src/components/Marketing/pages/PayWithContent.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { PAYMENT_METHODS, getCountryName } from '@/data/seo'
-import { getTranslations, t, localizedPath, localizedBarePath } from '@/i18n'
-import type { Locale } from '@/i18n/types'
-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 { RelatedPages } from '@/components/Marketing/RelatedPages'
-
-interface PayWithContentProps {
- method: string
- locale: Locale
-}
-
-export function PayWithContent({ method, locale }: PayWithContentProps) {
- const pm = PAYMENT_METHODS[method]
- if (!pm) return null
-
- const i18n = getTranslations(locale)
-
- const steps = pm.steps.map((step, i) => ({
- title: `${i + 1}`,
- description: step,
- }))
-
- const baseUrl = 'https://peanut.me'
-
- const howToSchema = {
- '@context': 'https://schema.org',
- '@type': 'HowTo',
- name: t(i18n.payWith, { method: pm.name }),
- description: pm.description,
- inLanguage: locale,
- step: steps.map((step, i) => ({
- '@type': 'HowToStep',
- position: i + 1,
- name: step.title,
- text: step.description,
- })),
- }
-
- const breadcrumbSchema = {
- '@context': 'https://schema.org',
- '@type': 'BreadcrumbList',
- itemListElement: [
- { '@type': 'ListItem', position: 1, name: i18n.home, item: baseUrl },
- {
- '@type': 'ListItem',
- position: 2,
- name: t(i18n.payWith, { method: pm.name }),
- item: `${baseUrl}/${locale}/pay-with/${method}`,
- },
- ],
- }
-
- // Related pages: hub pages for countries where this method is available
- const relatedPages = pm.countries.map((countrySlug) => ({
- title: t(i18n.hubTitle, { country: getCountryName(countrySlug, locale) }),
- href: localizedBarePath(locale, countrySlug),
- }))
-
- // Add send-money-to pages for related countries
- for (const countrySlug of pm.countries.slice(0, 3)) {
- relatedPages.push({
- title: t(i18n.sendMoneyTo, { country: getCountryName(countrySlug, locale) }),
- href: localizedPath('send-money-to', locale, countrySlug),
- })
- }
-
- const today = new Date().toISOString().split('T')[0]
-
- return (
- <>
-
-
-
-
-
-
- {/* Description */}
-
-
- {/* Steps */}
-
-
- {/* FAQs */}
- {pm.faqs.length > 0 && }
-
- {/* Related pages */}
-
-
- {/* Last updated */}
- {t(i18n.lastUpdated, { date: today })}
-
- >
- )
-}
diff --git a/src/content b/src/content
index 7b5e8543e..dc2cc11e1 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 7b5e8543ebd90ad9cc4a8496259cc8ec8a6d0c97
+Subproject commit dc2cc11e1adda35c7358f8d0d222c4593441a24c
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4fb9de91a..3a9a5e225 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2,24 +2,14 @@
"sendMoneyTo": "Send Money to {country}",
"sendMoneyToSubtitle": "Fast, affordable transfers to {country} in {currency}. Better rates than banks.",
"getStarted": "Get Started",
- "createAccount": "Create your Peanut account",
"howItWorks": "How It Works",
- "paymentMethods": "Payment Methods",
"frequentlyAskedQuestions": "Frequently Asked Questions",
"sendMoneyToOtherCountries": "Send money to other countries",
- "sendingMoneyTo": "Sending Money to {country}",
"stepCreateAccount": "Create your Peanut account",
"stepCreateAccountDesc": "Sign up in under 2 minutes with your email or wallet.",
"stepDepositFunds": "Deposit funds",
"stepDepositFundsDesc": "Add money via bank transfer, {method}, or stablecoins (USDC/USDT).",
- "stepSendTo": "Send to {country}",
"stepSendToDesc": "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}.",
- "instantDeposits": "Instant deposits and payments via {method} in {country}.",
- "qrPayments": "Use your balance at millions of merchants via QR code payments.",
- "stablecoins": "Stablecoins (USDC / USDT)",
- "stablecoinsDesc": "Deposit stablecoins from any wallet or exchange. Converted to {currency} at market rates.",
- "bankTransfer": "Bank Transfer",
- "bankTransferDesc": "Traditional bank wire or local transfer. Settlement times vary by region.",
"readMore": "Read more",
"allArticles": "All articles",
"blog": "Blog",
@@ -37,25 +27,9 @@
"processingTime": "Processing Time",
"troubleshooting": "Troubleshooting",
"hubTitle": "Peanut in {country}",
- "hubSubtitle": "Everything you need to send, receive, and spend money in {country}.",
- "hubSendMoney": "Send Money to {country}",
- "hubSendMoneyDesc": "Transfer money to {country} with competitive rates.",
- "hubConvert": "Convert to {currency}",
- "hubConvertDesc": "See live exchange rates and convert currencies.",
- "hubDeposit": "Fund Your Account",
- "hubDepositDesc": "Add money from popular exchanges and wallets.",
- "hubCompare": "Compare Services",
- "hubCompareDesc": "See how Peanut compares to other transfer options.",
- "hubExploreCountries": "Explore other countries",
- "hubInboundCorridors": "Send money to {country} from these countries:",
- "hubSendMoneyFrom": "Send Money from {country}",
"sendMoneyFromTo": "Send Money from {from} to {to}",
- "sendMoneyFromToDesc": "Transfer money from {from} to {to}. Fast, affordable, and secure.",
- "fromToContext": "Peanut makes it easy to send money from {from} to {to}. Get competitive exchange rates, low fees, and fast delivery.",
"receiveMoneyFrom": "Receive Money from {country}",
"receiveMoneyFromDesc": "Get money sent to you from {country}. Fast and secure.",
- "payWith": "Pay with {method}",
- "payWithDesc": "Use {method} to send and receive money on Peanut.",
"teamTitle": "Our Team",
"teamSubtitle": "The people behind Peanut.",
"lastUpdated": "Last updated: {date}",
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
index 5dff81d63..fdac76bdc 100644
--- a/src/i18n/es-419.json
+++ b/src/i18n/es-419.json
@@ -2,24 +2,14 @@
"sendMoneyTo": "Enviar Dinero a {country}",
"sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
"getStarted": "Comenzar",
- "createAccount": "Crea tu cuenta Peanut",
"howItWorks": "Cómo Funciona",
- "paymentMethods": "Métodos de Pago",
"frequentlyAskedQuestions": "Preguntas Frecuentes",
"sendMoneyToOtherCountries": "Enviar dinero a otros países",
- "sendingMoneyTo": "Enviar Dinero a {country}",
"stepCreateAccount": "Crea tu cuenta Peanut",
"stepCreateAccountDesc": "Regístrate en menos de 2 minutos con tu email o wallet.",
"stepDepositFunds": "Deposita fondos",
"stepDepositFundsDesc": "Agrega dinero por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
- "stepSendTo": "Envía a {country}",
"stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
- "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
- "qrPayments": "Usa tu saldo en millones de comercios con pagos QR.",
- "stablecoins": "Stablecoins (USDC / USDT)",
- "stablecoinsDesc": "Deposita stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
- "bankTransfer": "Transferencia Bancaria",
- "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
"readMore": "Leer más",
"allArticles": "Todos los artículos",
"blog": "Blog",
@@ -37,25 +27,9 @@
"processingTime": "Tiempo de Procesamiento",
"troubleshooting": "Solución de Problemas",
"hubTitle": "Peanut en {country}",
- "hubSubtitle": "Todo lo que necesitas para enviar, recibir y gastar dinero en {country}.",
- "hubSendMoney": "Enviar Dinero a {country}",
- "hubSendMoneyDesc": "Transfiere dinero a {country} con tasas competitivas.",
- "hubConvert": "Convertir a {currency}",
- "hubConvertDesc": "Consulta tasas de cambio en vivo y convierte divisas.",
- "hubDeposit": "Fondea Tu Cuenta",
- "hubDepositDesc": "Agrega dinero desde exchanges y wallets populares.",
- "hubCompare": "Comparar Servicios",
- "hubCompareDesc": "Compara Peanut con otras opciones de transferencia.",
- "hubExploreCountries": "Explorar otros países",
- "hubInboundCorridors": "Envía dinero a {country} desde estos países:",
- "hubSendMoneyFrom": "Enviar Dinero desde {country}",
"sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
- "sendMoneyFromToDesc": "Transfiere dinero de {from} a {to}. Rápido, económico y seguro.",
- "fromToContext": "Peanut facilita enviar dinero de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
"receiveMoneyFrom": "Recibir Dinero de {country}",
"receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
- "payWith": "Pagar con {method}",
- "payWithDesc": "Usa {method} para enviar y recibir dinero en Peanut.",
"teamTitle": "Nuestro Equipo",
"teamSubtitle": "Las personas detrás de Peanut.",
"lastUpdated": "Última actualización: {date}",
diff --git a/src/i18n/es-ar.json b/src/i18n/es-ar.json
index f261cb1db..06b1370ea 100644
--- a/src/i18n/es-ar.json
+++ b/src/i18n/es-ar.json
@@ -2,24 +2,14 @@
"sendMoneyTo": "Enviar Dinero a {country}",
"sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
"getStarted": "Empezar",
- "createAccount": "Creá tu cuenta Peanut",
"howItWorks": "Cómo Funciona",
- "paymentMethods": "Métodos de Pago",
"frequentlyAskedQuestions": "Preguntas Frecuentes",
"sendMoneyToOtherCountries": "Enviar plata a otros países",
- "sendingMoneyTo": "Enviar Dinero a {country}",
"stepCreateAccount": "Creá tu cuenta Peanut",
"stepCreateAccountDesc": "Registrate en menos de 2 minutos con tu email o wallet.",
"stepDepositFunds": "Depositá fondos",
"stepDepositFundsDesc": "Agregá plata por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
- "stepSendTo": "Enviá a {country}",
"stepSendToDesc": "Ingresá los datos del destinatario y confirmá. Reciben {currency} en minutos vía {method}.",
- "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
- "qrPayments": "Usá tu saldo en millones de comercios con pagos QR.",
- "stablecoins": "Stablecoins (USDC / USDT)",
- "stablecoinsDesc": "Depositá stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
- "bankTransfer": "Transferencia Bancaria",
- "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
"readMore": "Leer más",
"allArticles": "Todos los artículos",
"blog": "Blog",
@@ -37,25 +27,9 @@
"processingTime": "Tiempo de Procesamiento",
"troubleshooting": "Solución de Problemas",
"hubTitle": "Peanut en {country}",
- "hubSubtitle": "Todo lo que necesitás para enviar, recibir y gastar plata en {country}.",
- "hubSendMoney": "Enviar Plata a {country}",
- "hubSendMoneyDesc": "Transferí plata a {country} con tasas competitivas.",
- "hubConvert": "Convertir a {currency}",
- "hubConvertDesc": "Consultá cotizaciones en vivo y convertí divisas.",
- "hubDeposit": "Fondeá Tu Cuenta",
- "hubDepositDesc": "Agregá plata desde exchanges y wallets populares.",
- "hubCompare": "Comparar Servicios",
- "hubCompareDesc": "Compará Peanut con otras opciones de transferencia.",
- "hubExploreCountries": "Explorar otros países",
- "hubInboundCorridors": "Enviá plata a {country} desde estos países:",
- "hubSendMoneyFrom": "Enviar Plata desde {country}",
"sendMoneyFromTo": "Enviar Plata de {from} a {to}",
- "sendMoneyFromToDesc": "Transferí plata de {from} a {to}. Rápido, económico y seguro.",
- "fromToContext": "Peanut facilita enviar plata de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
"receiveMoneyFrom": "Recibir Plata de {country}",
"receiveMoneyFromDesc": "Recibí plata enviada desde {country}. Rápido y seguro.",
- "payWith": "Pagar con {method}",
- "payWithDesc": "Usá {method} para enviar y recibir plata en Peanut.",
"teamTitle": "Nuestro Equipo",
"teamSubtitle": "Las personas detrás de Peanut.",
"lastUpdated": "Última actualización: {date}",
diff --git a/src/i18n/es-es.json b/src/i18n/es-es.json
index 5dff81d63..fdac76bdc 100644
--- a/src/i18n/es-es.json
+++ b/src/i18n/es-es.json
@@ -2,24 +2,14 @@
"sendMoneyTo": "Enviar Dinero a {country}",
"sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
"getStarted": "Comenzar",
- "createAccount": "Crea tu cuenta Peanut",
"howItWorks": "Cómo Funciona",
- "paymentMethods": "Métodos de Pago",
"frequentlyAskedQuestions": "Preguntas Frecuentes",
"sendMoneyToOtherCountries": "Enviar dinero a otros países",
- "sendingMoneyTo": "Enviar Dinero a {country}",
"stepCreateAccount": "Crea tu cuenta Peanut",
"stepCreateAccountDesc": "Regístrate en menos de 2 minutos con tu email o wallet.",
"stepDepositFunds": "Deposita fondos",
"stepDepositFundsDesc": "Agrega dinero por transferencia bancaria, {method}, o stablecoins (USDC/USDT).",
- "stepSendTo": "Envía a {country}",
"stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
- "instantDeposits": "Depósitos y pagos instantáneos vía {method} en {country}.",
- "qrPayments": "Usa tu saldo en millones de comercios con pagos QR.",
- "stablecoins": "Stablecoins (USDC / USDT)",
- "stablecoinsDesc": "Deposita stablecoins desde cualquier wallet o exchange. Convertidos a {currency} a tasas de mercado.",
- "bankTransfer": "Transferencia Bancaria",
- "bankTransferDesc": "Transferencia bancaria tradicional o local. Los tiempos varían según la región.",
"readMore": "Leer más",
"allArticles": "Todos los artículos",
"blog": "Blog",
@@ -37,25 +27,9 @@
"processingTime": "Tiempo de Procesamiento",
"troubleshooting": "Solución de Problemas",
"hubTitle": "Peanut en {country}",
- "hubSubtitle": "Todo lo que necesitas para enviar, recibir y gastar dinero en {country}.",
- "hubSendMoney": "Enviar Dinero a {country}",
- "hubSendMoneyDesc": "Transfiere dinero a {country} con tasas competitivas.",
- "hubConvert": "Convertir a {currency}",
- "hubConvertDesc": "Consulta tasas de cambio en vivo y convierte divisas.",
- "hubDeposit": "Fondea Tu Cuenta",
- "hubDepositDesc": "Agrega dinero desde exchanges y wallets populares.",
- "hubCompare": "Comparar Servicios",
- "hubCompareDesc": "Compara Peanut con otras opciones de transferencia.",
- "hubExploreCountries": "Explorar otros países",
- "hubInboundCorridors": "Envía dinero a {country} desde estos países:",
- "hubSendMoneyFrom": "Enviar Dinero desde {country}",
"sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
- "sendMoneyFromToDesc": "Transfiere dinero de {from} a {to}. Rápido, económico y seguro.",
- "fromToContext": "Peanut facilita enviar dinero de {from} a {to}. Tasas competitivas, comisiones bajas y entrega rápida.",
"receiveMoneyFrom": "Recibir Dinero de {country}",
"receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
- "payWith": "Pagar con {method}",
- "payWithDesc": "Usa {method} para enviar y recibir dinero en Peanut.",
"teamTitle": "Nuestro Equipo",
"teamSubtitle": "Las personas detrás de Peanut.",
"lastUpdated": "Última actualización: {date}",
diff --git a/src/i18n/pt-br.json b/src/i18n/pt-br.json
index 1549639f2..860c0970a 100644
--- a/src/i18n/pt-br.json
+++ b/src/i18n/pt-br.json
@@ -2,24 +2,14 @@
"sendMoneyTo": "Enviar Dinheiro para {country}",
"sendMoneyToSubtitle": "Transferências rápidas e acessíveis para {country} em {currency}. Melhores taxas que os bancos.",
"getStarted": "Começar",
- "createAccount": "Crie sua conta Peanut",
"howItWorks": "Como Funciona",
- "paymentMethods": "Métodos de Pagamento",
"frequentlyAskedQuestions": "Perguntas Frequentes",
"sendMoneyToOtherCountries": "Enviar dinheiro para outros países",
- "sendingMoneyTo": "Enviar Dinheiro para {country}",
"stepCreateAccount": "Crie sua conta Peanut",
"stepCreateAccountDesc": "Cadastre-se em menos de 2 minutos com seu email ou carteira.",
"stepDepositFunds": "Deposite fundos",
"stepDepositFundsDesc": "Adicione dinheiro por transferência bancária, {method}, ou stablecoins (USDC/USDT).",
- "stepSendTo": "Envie para {country}",
"stepSendToDesc": "Insira os dados do destinatário e confirme. Eles recebem {currency} em minutos via {method}.",
- "instantDeposits": "Depósitos e pagamentos instantâneos via {method} em {country}.",
- "qrPayments": "Use seu saldo em milhões de estabelecimentos com pagamentos QR.",
- "stablecoins": "Stablecoins (USDC / USDT)",
- "stablecoinsDesc": "Deposite stablecoins de qualquer carteira ou exchange. Convertidos para {currency} a taxas de mercado.",
- "bankTransfer": "Transferência Bancária",
- "bankTransferDesc": "Transferência bancária tradicional ou local. Os tempos variam por região.",
"readMore": "Leia mais",
"allArticles": "Todos os artigos",
"blog": "Blog",
@@ -37,25 +27,9 @@
"processingTime": "Tempo de Processamento",
"troubleshooting": "Solução de Problemas",
"hubTitle": "Peanut em {country}",
- "hubSubtitle": "Tudo que você precisa para enviar, receber e gastar dinheiro em {country}.",
- "hubSendMoney": "Enviar Dinheiro para {country}",
- "hubSendMoneyDesc": "Transfira dinheiro para {country} com taxas competitivas.",
- "hubConvert": "Converter para {currency}",
- "hubConvertDesc": "Veja taxas de câmbio ao vivo e converta moedas.",
- "hubDeposit": "Financie Sua Conta",
- "hubDepositDesc": "Adicione dinheiro de exchanges e carteiras populares.",
- "hubCompare": "Comparar Serviços",
- "hubCompareDesc": "Veja como o Peanut se compara a outras opções de transferência.",
- "hubExploreCountries": "Explorar outros países",
- "hubInboundCorridors": "Envie dinheiro para {country} destes países:",
- "hubSendMoneyFrom": "Enviar Dinheiro de {country}",
"sendMoneyFromTo": "Enviar Dinheiro de {from} para {to}",
- "sendMoneyFromToDesc": "Transfira dinheiro de {from} para {to}. Rápido, acessível e seguro.",
- "fromToContext": "O Peanut facilita o envio de dinheiro de {from} para {to}. Taxas competitivas, baixas comissões e entrega rápida.",
"receiveMoneyFrom": "Receber Dinheiro de {country}",
"receiveMoneyFromDesc": "Receba dinheiro enviado de {country}. Rápido e seguro.",
- "payWith": "Pagar com {method}",
- "payWithDesc": "Use {method} para enviar e receber dinheiro no Peanut.",
"teamTitle": "Nossa Equipe",
"teamSubtitle": "As pessoas por trás do Peanut.",
"lastUpdated": "Última atualização: {date}",
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
index fd1297e76..f8acfc407 100644
--- a/src/i18n/types.ts
+++ b/src/i18n/types.ts
@@ -8,31 +8,19 @@ export interface Translations {
sendMoneyTo: string // "Send Money to {country}"
sendMoneyToSubtitle: string // "Fast, affordable transfers to {country} in {currency}. Better rates than banks."
getStarted: string
- createAccount: string
// Section titles
howItWorks: string
- paymentMethods: string
frequentlyAskedQuestions: string
sendMoneyToOtherCountries: string
- sendingMoneyTo: string // "Sending Money to {country}"
// Steps
stepCreateAccount: string
stepCreateAccountDesc: string
stepDepositFunds: string
stepDepositFundsDesc: string // "Add money via bank transfer, {method}, or stablecoins (USDC/USDT)."
- stepSendTo: string // "Send to {country}"
stepSendToDesc: string // "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}."
- // Payment methods
- instantDeposits: string // "Instant deposits and payments via {method} in {country}."
- qrPayments: string
- stablecoins: string
- stablecoinsDesc: string
- bankTransfer: string
- bankTransferDesc: string
-
// Blog
readMore: string
allArticles: string
@@ -61,32 +49,14 @@ export interface Translations {
// Hub
hubTitle: string // "Peanut in {country}"
- hubSubtitle: string // "Everything you need to send, receive, and spend money in {country}."
- hubSendMoney: string // "Send Money to {country}"
- hubSendMoneyDesc: string // "Transfer money to {country} with competitive rates."
- hubConvert: string // "Convert to {currency}"
- hubConvertDesc: string // "See live rates and convert currencies."
- hubDeposit: string // "Fund Your Account"
- hubDepositDesc: string // "Add money from popular exchanges and wallets."
- hubCompare: string // "Compare Services"
- hubCompareDesc: string // "See how Peanut compares to other options."
- hubExploreCountries: string // "Explore other countries"
- hubInboundCorridors: string // "Send money to {country} from these countries:"
- hubSendMoneyFrom: string // "Send Money from {country}"
// From-to corridors
sendMoneyFromTo: string // "Send Money from {from} to {to}"
- sendMoneyFromToDesc: string // "Transfer money from {from} to {to}. Fast, affordable, and secure."
- fromToContext: string // "Peanut makes it easy to send money from {from} to {to}."
// Receive money
receiveMoneyFrom: string // "Receive Money from {country}"
receiveMoneyFromDesc: string // "Get money sent to you from {country}. Fast and secure."
- // Pay with
- payWith: string // "Pay with {method}"
- payWithDesc: string // "Use {method} to send and receive money on Peanut."
-
// Team
teamTitle: string // "Our Team"
teamSubtitle: string // "The people behind Peanut."
From 8e2694f218b690f69f3947c03707b06efe5ad9bf Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sun, 22 Feb 2026 19:36:45 +0000
Subject: [PATCH 13/61] =?UTF-8?q?feat:=20SEO=20improvements=20=E2=80=94=20?=
=?UTF-8?q?breadcrumbs,=20FAQ=20schema,=20sitemap=20dates,=20ping=20script?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add visible breadcrumb nav to ContentPage (all MDX pages) and blog posts
- Add FAQPage JSON-LD schema on blog posts via frontmatter `faqs` field
- Use per-entry lastModified in sitemap (blog posts use post date)
- Add scripts/ping-sitemap.sh for post-deploy sitemap submission
---
scripts/ping-sitemap.sh | 13 +++++
.../[locale]/(marketing)/blog/[slug]/page.tsx | 53 +++++++++++++++++++
src/app/sitemap.ts | 7 ++-
src/components/Marketing/ContentPage.tsx | 21 +++++++-
src/lib/blog.ts | 1 +
5 files changed, 92 insertions(+), 3 deletions(-)
create mode 100755 scripts/ping-sitemap.sh
diff --git a/scripts/ping-sitemap.sh b/scripts/ping-sitemap.sh
new file mode 100755
index 000000000..35c68c849
--- /dev/null
+++ b/scripts/ping-sitemap.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Notify search engines of sitemap update after deploy.
+# Usage: Run as a post-deploy step or manually after content changes.
+
+SITEMAP_URL="https://peanut.me/sitemap.xml"
+
+echo "Pinging Google..."
+curl -s -o /dev/null -w " HTTP %{http_code}\n" "https://www.google.com/ping?sitemap=${SITEMAP_URL}"
+
+echo "Pinging Bing..."
+curl -s -o /dev/null -w " HTTP %{http_code}\n" "https://www.bing.com/ping?sitemap=${SITEMAP_URL}"
+
+echo "Done."
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
index 3efa1a75e..8b013d29a 100644
--- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -1,4 +1,5 @@
import { notFound } from 'next/navigation'
+import Link from 'next/link'
import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
import { getAllPosts, getPostBySlug } from '@/lib/blog'
@@ -6,6 +7,7 @@ import { MarketingShell } from '@/components/Marketing/MarketingShell'
import { JsonLd } from '@/components/Marketing/JsonLd'
import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
import type { Locale } from '@/i18n/types'
+import { getTranslations } from '@/i18n'
interface PageProps {
params: Promise<{ locale: string; slug: string }>
@@ -49,6 +51,8 @@ export default async function BlogPostPageLocalized({ params }: PageProps) {
const post = (await getPostBySlug(slug, locale as Locale)) ?? (await getPostBySlug(slug, 'en'))
if (!post) notFound()
+ const i18n = getTranslations(locale)
+
const blogPostSchema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
@@ -61,10 +65,59 @@ export default async function BlogPostPageLocalized({ params }: PageProps) {
mainEntityOfPage: `https://peanut.me/${locale}/blog/${slug}`,
}
+ // FAQ schema from frontmatter (optional)
+ const faqs = post.frontmatter.faqs
+ const faqSchema = faqs?.length
+ ? {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: faqs.map((faq) => ({
+ '@type': 'Question',
+ name: faq.question,
+ acceptedAnswer: { '@type': 'Answer', text: faq.answer },
+ })),
+ }
+ : null
+
+ const breadcrumbs = [
+ { name: i18n.home, href: '/' },
+ { name: i18n.blog, href: `/${locale}/blog` },
+ { name: post.frontmatter.title, href: `/${locale}/blog/${slug}` },
+ ]
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: breadcrumbs.map((crumb, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: crumb.name,
+ item: crumb.href.startsWith('http') ? crumb.href : `https://peanut.me${crumb.href}`,
+ })),
+ }
+
return (
<>
+
+ {faqSchema && }
+
+
+ {breadcrumbs.map((crumb, i) => (
+
+ {i > 0 && / }
+ {i < breadcrumbs.length - 1 ? (
+
+ {crumb.name}
+
+ ) : (
+ {crumb.name}
+ )}
+
+ ))}
+
+
{post.frontmatter.category && (
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 979147220..9931aacee 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -11,11 +11,15 @@ import type { Locale } from '@/i18n/types'
// TODO (infra): Add peanut.me to Google Search Console and submit this sitemap
// TODO (GA4): Create data filter to exclude trafficheap.com referral traffic
+/** Build date used for non-content pages that don't have their own date. */
+const BUILD_DATE = new Date()
+
async function generateSitemap(): Promise {
type SitemapEntry = {
path: string
priority: number
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']
+ lastModified?: Date
}
const pages: SitemapEntry[] = [
@@ -116,6 +120,7 @@ async function generateSitemap(): Promise {
path: `/${locale}/blog/${post.slug}`,
priority: 0.6 * basePriority,
changeFrequency: 'monthly',
+ lastModified: new Date(post.frontmatter.date),
})
}
@@ -125,7 +130,7 @@ async function generateSitemap(): Promise {
return pages.map((page) => ({
url: `${BASE_URL}${page.path}`,
- lastModified: new Date(),
+ lastModified: page.lastModified ?? BUILD_DATE,
changeFrequency: page.changeFrequency,
priority: page.priority,
}))
diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx
index effa1c112..c51f7596c 100644
--- a/src/components/Marketing/ContentPage.tsx
+++ b/src/components/Marketing/ContentPage.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
+import Link from 'next/link'
import { JsonLd } from './JsonLd'
import { BASE_URL } from '@/constants/general.consts'
@@ -11,8 +12,8 @@ interface ContentPageProps {
/**
* Universal wrapper for MDX-rendered marketing pages.
- * Handles BreadcrumbList JSON-LD only — the MDX body owns all layout
- * (Hero is full-bleed, prose sections are contained, Steps/FAQ break out).
+ * Handles BreadcrumbList JSON-LD + visible breadcrumb nav.
+ * The MDX body owns all layout (Hero is full-bleed, prose sections are contained).
*/
export function ContentPage({ children, breadcrumbs }: ContentPageProps) {
const breadcrumbSchema = {
@@ -29,6 +30,22 @@ export function ContentPage({ children, breadcrumbs }: ContentPageProps) {
return (
<>
+
+
+ {breadcrumbs.map((crumb, i) => (
+
+ {i > 0 && / }
+ {i < breadcrumbs.length - 1 ? (
+
+ {crumb.name}
+
+ ) : (
+ {crumb.name}
+ )}
+
+ ))}
+
+
{children}
>
)
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
index ef50e41c9..df189b600 100644
--- a/src/lib/blog.ts
+++ b/src/lib/blog.ts
@@ -18,6 +18,7 @@ export interface BlogPost {
date: string
category?: string
author?: string
+ faqs?: Array<{ question: string; answer: string }>
}
content: string
}
From 3f305a0c5fccafaa145560196ad4facefe342982 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 21:53:35 +0000
Subject: [PATCH 14/61] chore: gitignore .claude directory
---
.claude/worktrees/feat-seo-polish | 1 -
.gitignore | 1 +
2 files changed, 1 insertion(+), 1 deletion(-)
delete mode 160000 .claude/worktrees/feat-seo-polish
diff --git a/.claude/worktrees/feat-seo-polish b/.claude/worktrees/feat-seo-polish
deleted file mode 160000
index 8e2694f21..000000000
--- a/.claude/worktrees/feat-seo-polish
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 8e2694f218b690f69f3947c03707b06efe5ad9bf
diff --git a/.gitignore b/.gitignore
index 148c7fb2e..afb8d3eaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -76,3 +76,4 @@ public/swe-worker*
# mobile POC
android/
+.claude/
From 05a104df2d174fb1e11cc56791c61a1a0e66c186 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 21:55:15 +0000
Subject: [PATCH 15/61] fix: use hasCashbackLeft (boolean) in CashCard,
matching BE response
---
src/app/(mobile-ui)/points/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 2de14d641..19d165a65 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -178,7 +178,7 @@ const PointsPage = () => {
{/* cash section */}
{cashStatus?.success && cashStatus.data && (
)}
From 25f385f7ab53071eeb0517590caa06b13a383f89 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 22:00:42 +0000
Subject: [PATCH 16/61] chore: point content submodule to main (new content
pages)
---
src/content | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/content b/src/content
index dc2cc11e1..3aee5e9f0 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit dc2cc11e1adda35c7358f8d0d222c4593441a24c
+Subproject commit 3aee5e9f0c2a5f14135fb1238089394662352650
From 8b70a982a4acacd50c7da64fbddda4d06197c18c Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 22:11:22 +0000
Subject: [PATCH 17/61] fix: publish all SEO content + guard corridor loader
against undefined origin
- Flip published: true on all 161 content pages in submodule
- Guard corridors.ts against entity files using destination: instead of origin:
(prevents undefined from poisoning generateStaticParams)
---
src/content | 2 +-
src/data/seo/corridors.ts | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/content b/src/content
index 3aee5e9f0..02bcab227 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 3aee5e9f0c2a5f14135fb1238089394662352650
+Subproject commit 02bcab227f3d895384f18fa149f92c2d934ce458
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
index 904e31d07..2abb2aff5 100644
--- a/src/data/seo/corridors.ts
+++ b/src/data/seo/corridors.ts
@@ -158,10 +158,12 @@ function loadAll() {
corridors: fm.corridors?.map((c) => ({ origin: c.origin, priority: c.priority })) ?? [],
}
- // Build corridors from entity data
+ // Build corridors from entity data (some entities use destination: instead of origin:, skip those)
if (fm.corridors) {
for (const corridor of fm.corridors) {
- corridors.push({ from: corridor.origin, to: slug })
+ if (corridor.origin) {
+ corridors.push({ from: corridor.origin, to: slug })
+ }
}
}
}
From d28d85a74179b27731232f269901aa50bbe3d7c0 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 22:39:33 +0000
Subject: [PATCH 18/61] =?UTF-8?q?fix:=20SEO=20page=20polish=20=E2=80=94=20?=
=?UTF-8?q?breadcrumbs,=20flags,=20hero,=20exchange=20widget,=20content?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Move breadcrumb nav to bottom of article inside bg-background
- Add MarketingErrorBoundary wrapper around content pages
- Add SLUG_TO_ISO2 map for all 27 SEO country flags in DestinationGrid
- Restyle Hero: use Roboto Flex bold instead of knerd Title component
- Decouple ExchangeWidget from Hero, add sourceCurrency prop, blue bg
- Add Tabs/TabPanel MDX components
- Add h1 prose override in MDX component map
- Replace "Fiat / Crypto" and "CRYPTO" with "USDT/USDC" in marquees
- Update content submodule: remove duplicate headings before Steps/FAQ
---
src/components/LandingPage/landingPageData.ts | 2 +-
src/components/LandingPage/marquee.tsx | 2 +-
src/components/Marketing/ContentPage.tsx | 42 ++++++-----
src/components/Marketing/DestinationGrid.tsx | 17 +++--
.../Marketing/MarketingErrorBoundary.tsx | 41 +++++++++++
src/components/Marketing/MarketingHero.tsx | 2 +-
.../Marketing/mdx/ExchangeWidget.tsx | 41 ++++++++---
src/components/Marketing/mdx/Hero.tsx | 17 ++---
src/components/Marketing/mdx/Tabs.tsx | 71 +++++++++++++++++++
src/components/Marketing/mdx/components.tsx | 9 +++
src/content | 2 +-
11 files changed, 200 insertions(+), 46 deletions(-)
create mode 100644 src/components/Marketing/MarketingErrorBoundary.tsx
create mode 100644 src/components/Marketing/mdx/Tabs.tsx
diff --git a/src/components/LandingPage/landingPageData.ts b/src/components/LandingPage/landingPageData.ts
index 87257fd50..e3750c386 100644
--- a/src/components/LandingPage/landingPageData.ts
+++ b/src/components/LandingPage/landingPageData.ts
@@ -6,7 +6,7 @@ export const heroConfig = {
},
}
-export const marqueeMessages = ['No fees', 'Instant', '24/7', 'USD', 'EUR', 'CRYPTO', 'GLOBAL', 'SELF-CUSTODIAL']
+export const marqueeMessages = ['No fees', 'Instant', '24/7', 'USD', 'EUR', 'USDT/USDC', 'GLOBAL', 'SELF-CUSTODIAL']
export const faqData = {
heading: 'Faqs',
diff --git a/src/components/LandingPage/marquee.tsx b/src/components/LandingPage/marquee.tsx
index e8922fad9..764c591c1 100644
--- a/src/components/LandingPage/marquee.tsx
+++ b/src/components/LandingPage/marquee.tsx
@@ -10,7 +10,7 @@ type MarqueeProps = {
export function Marquee({
visible = true,
- message = ['No fees', 'Instant', '24/7', 'Dollars', 'Fiat / Crypto'],
+ message = ['No fees', 'Instant', '24/7', 'Dollars', 'USDT/USDC'],
imageSrc = HandThumbsUp.src,
backgroundColor = 'bg-secondary-1',
}: MarqueeProps) {
diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx
index c51f7596c..f64c8ec05 100644
--- a/src/components/Marketing/ContentPage.tsx
+++ b/src/components/Marketing/ContentPage.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import Link from 'next/link'
import { JsonLd } from './JsonLd'
import { BASE_URL } from '@/constants/general.consts'
+import { MarketingErrorBoundary } from './MarketingErrorBoundary'
interface ContentPageProps {
/** Compiled MDX content element */
@@ -30,23 +31,30 @@ export function ContentPage({ children, breadcrumbs }: ContentPageProps) {
return (
<>
-
-
- {breadcrumbs.map((crumb, i) => (
-
- {i > 0 && / }
- {i < breadcrumbs.length - 1 ? (
-
- {crumb.name}
-
- ) : (
- {crumb.name}
- )}
-
- ))}
-
-
- {children}
+
+
+ {children}
+
+
+ {breadcrumbs.map((crumb, i) => (
+
+ {i > 0 && / }
+ {i < breadcrumbs.length - 1 ? (
+
+ {crumb.name}
+
+ ) : (
+ {crumb.name}
+ )}
+
+ ))}
+
+
+
+
>
)
}
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
index 121f38ce7..aef1b3166 100644
--- a/src/components/Marketing/DestinationGrid.tsx
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -1,11 +1,22 @@
import Link from 'next/link'
import { Card } from '@/components/0_Bruddle/Card'
import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
-import { getFlagUrl, findMappingBySlug } from '@/constants/countryCurrencyMapping'
+import { getFlagUrl } from '@/constants/countryCurrencyMapping'
import { localizedPath } from '@/i18n/config'
import { CARD_HOVER } from '@/components/Marketing/mdx/constants'
import type { Locale } from '@/i18n/types'
+const SLUG_TO_ISO2: Record = {
+ argentina: 'ar', australia: 'au', brazil: 'br', canada: 'ca',
+ colombia: 'co', 'costa-rica': 'cr', indonesia: 'id', japan: 'jp',
+ kenya: 'ke', malaysia: 'my', mexico: 'mx', pakistan: 'pk',
+ peru: 'pe', philippines: 'ph', poland: 'pl', portugal: 'pt',
+ singapore: 'sg', 'south-africa': 'za', spain: 'es', sweden: 'se',
+ tanzania: 'tz', thailand: 'th', turkey: 'tr',
+ 'united-arab-emirates': 'ae', 'united-kingdom': 'gb',
+ 'united-states': 'us', vietnam: 'vn',
+}
+
interface DestinationGridProps {
/** If provided, only show these country slugs */
countries?: string[]
@@ -27,10 +38,8 @@ export function DestinationGrid({ countries, exclude, title = 'Send money to', l
const seo = COUNTRIES_SEO[slug]
if (!seo) return null
- const mapping = findMappingBySlug(slug)
-
const countryName = getCountryName(slug, locale)
- const flagCode = mapping?.flagCode
+ const flagCode = SLUG_TO_ISO2[slug]
return (
diff --git a/src/components/Marketing/MarketingErrorBoundary.tsx b/src/components/Marketing/MarketingErrorBoundary.tsx
new file mode 100644
index 000000000..d698a5dea
--- /dev/null
+++ b/src/components/Marketing/MarketingErrorBoundary.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { Component, type ReactNode } from 'react'
+
+interface Props {
+ children: ReactNode
+ fallback?: ReactNode
+}
+
+interface State {
+ hasError: boolean
+}
+
+export class MarketingErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props)
+ this.state = { hasError: false }
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true }
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('MDX rendering error:', error, errorInfo)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ this.props.fallback || (
+
+
Content unavailable
+
Please try refreshing the page.
+
+ )
+ )
+ }
+ return this.props.children
+ }
+}
diff --git a/src/components/Marketing/MarketingHero.tsx b/src/components/Marketing/MarketingHero.tsx
index 6f19933c9..9c8d5d480 100644
--- a/src/components/Marketing/MarketingHero.tsx
+++ b/src/components/Marketing/MarketingHero.tsx
@@ -57,7 +57,7 @@ export function MarketingHero({
diff --git a/src/components/Marketing/mdx/ExchangeWidget.tsx b/src/components/Marketing/mdx/ExchangeWidget.tsx
index 10ab03de4..138fec464 100644
--- a/src/components/Marketing/mdx/ExchangeWidget.tsx
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -3,28 +3,41 @@
import { Suspense, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import ExchangeRateWidget from '@/components/Global/ExchangeRateWidget'
+import { Star } from '@/assets'
+import { CloudsCss } from '@/components/LandingPage/CloudsCss'
+
+const widgetClouds = [
+ { top: '10%', width: 140, speed: '38s', direction: 'ltr' as const },
+ { top: '75%', width: 120, speed: '44s', direction: 'rtl' as const, delay: '5s' },
+]
interface ExchangeWidgetProps {
+ /** ISO 4217 destination currency code, e.g. "ARS", "BRL" */
destinationCurrency?: string
+ /** ISO 4217 source currency code. Defaults to "USD". */
+ sourceCurrency?: string
}
-function ExchangeWidgetInner({ destinationCurrency }: ExchangeWidgetProps) {
+function ExchangeWidgetInner({ destinationCurrency, sourceCurrency = 'USD' }: ExchangeWidgetProps) {
const router = useRouter()
const searchParams = useSearchParams()
- // Set initial destination currency in URL if not already set
+ // Set initial currencies in URL if not already set
useEffect(() => {
if (destinationCurrency && !searchParams.get('to')) {
const params = new URLSearchParams(searchParams.toString())
params.set('to', destinationCurrency)
- if (!params.get('from')) params.set('from', 'USD')
+ if (!params.get('from')) params.set('from', sourceCurrency)
router.replace(`?${params.toString()}`, { scroll: false })
}
- }, [destinationCurrency, searchParams, router])
+ }, [destinationCurrency, sourceCurrency, searchParams, router])
return (
-
-
+
+
+
+
+
+ *
+ */
+export function ExchangeWidget({ destinationCurrency, sourceCurrency }: ExchangeWidgetProps) {
return (
-
+
}
>
-
+
)
}
diff --git a/src/components/Marketing/mdx/Hero.tsx b/src/components/Marketing/mdx/Hero.tsx
index 4230a9123..c27ad052a 100644
--- a/src/components/Marketing/mdx/Hero.tsx
+++ b/src/components/Marketing/mdx/Hero.tsx
@@ -1,9 +1,7 @@
-import Title from '@/components/0_Bruddle/Title'
import Link from 'next/link'
import { CloudsCss } from '@/components/LandingPage/CloudsCss'
import { MarqueeComp } from '@/components/Global/MarqueeWrapper'
import { HandThumbsUp } from '@/assets'
-import { ExchangeWidget } from './ExchangeWidget'
const marketingClouds = [
{ top: '15%', width: 160, speed: '45s', direction: 'ltr' as const },
@@ -16,22 +14,22 @@ interface HeroProps {
subtitle: string
cta?: string
ctaHref?: string
- /** Default destination currency for the exchange widget (e.g. "ARS", "BRL") */
+ /** @deprecated — ignored. Use standalone
in MDX body instead. */
currency?: string
}
/**
- * MDX Hero — large bubble title (knerd font, tight stacked lines),
- * Roboto Flex bold subtitle, white CTA button on pink background, with optional exchange widget.
+ * MDX Hero — large bold title (Roboto Flex), subtitle, white CTA button
+ * on pink background.
*/
-export function Hero({ title, subtitle, cta, ctaHref, currency }: HeroProps) {
+export function Hero({ title, subtitle, cta, ctaHref }: HeroProps) {
return (
<>
-
-
+
+ {title}
{subtitle}
@@ -49,11 +47,10 @@ export function Hero({ title, subtitle, cta, ctaHref, currency }: HeroProps) {
- {currency && }
{/* Spacer ensures consistent gap between Hero block and prose content */}
>
diff --git a/src/components/Marketing/mdx/Tabs.tsx b/src/components/Marketing/mdx/Tabs.tsx
new file mode 100644
index 000000000..1ee8403af
--- /dev/null
+++ b/src/components/Marketing/mdx/Tabs.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import * as RadixTabs from '@radix-ui/react-tabs'
+import { type ReactNode } from 'react'
+import { PROSE_WIDTH } from './constants'
+
+interface TabsProps {
+ /** Comma-separated tab labels, e.g. "Peanut,Wise,Western Union" */
+ labels: string
+ children: ReactNode
+}
+
+interface TabPanelProps {
+ /** Must match one of the labels exactly */
+ label: string
+ children: ReactNode
+}
+
+const triggerClasses =
+ 'flex-1 rounded-xl border border-transparent px-3 py-2 text-sm font-medium text-grey-1 transition-all data-[state=active]:border-primary-1 data-[state=active]:bg-primary-1/10 data-[state=active]:text-primary-1'
+
+/**
+ * Tabbed content for MDX pages.
+ *
+ * Usage:
+ * ```mdx
+ *
+ *
+ * Content about Peanut...
+ *
+ *
+ * Content about Wise...
+ *
+ *
+ * Content about Western Union...
+ *
+ *
+ * ```
+ */
+export function Tabs({ labels, children }: TabsProps) {
+ const tabs = labels.split(',').map((l) => l.trim())
+ return (
+
+
+
+ {tabs.map((tab) => (
+
+ {tab}
+
+ ))}
+
+ {children}
+
+
+ )
+}
+
+export function TabPanel({ label, children }: TabPanelProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/Marketing/mdx/components.tsx b/src/components/Marketing/mdx/components.tsx
index e8315cbe9..4caa4196c 100644
--- a/src/components/Marketing/mdx/components.tsx
+++ b/src/components/Marketing/mdx/components.tsx
@@ -8,6 +8,7 @@ import { ExchangeWidget } from './ExchangeWidget'
import { RelatedPages, RelatedLink } from './RelatedPages'
import { CountryGrid } from './CountryGrid'
import { ProseStars } from './ProseStars'
+import { Tabs, TabPanel } from './Tabs'
import { PROSE_WIDTH } from './constants'
/**
@@ -33,8 +34,16 @@ export const mdxComponents: Record> = {
RelatedPages,
RelatedLink,
CountryGrid,
+ Tabs,
+ TabPanel,
// Element overrides — prose styling
+ h1: (props: React.HTMLAttributes) => (
+
+ ),
h2: (props: React.HTMLAttributes) => (
diff --git a/src/content b/src/content
index 02bcab227..8b9667c2d 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 02bcab227f3d895384f18fa149f92c2d934ce458
+Subproject commit 8b9667c2d08c93199715c01e54f6ba09fb611d4b
From 9e07236684b0329494a924c8ef3275710df439fe Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 22:57:39 +0000
Subject: [PATCH 19/61] fix: ExchangeWidget overflow + bulk migrate content to
inline widget
- Fix currency selector dropdown clipped by overflow-hidden: move clouds
and stars into their own overflow-hidden wrapper with pointer-events-none
- Update content submodule: strip currency= from 140 Hero tags, add
inline on 70 send-to/corridor pages, update 4 templates
- Add migration script (scripts/migrate-exchange-widget.py)
---
scripts/migrate-exchange-widget.py | 203 ++++++++++++++++++
.../Marketing/mdx/ExchangeWidget.tsx | 10 +-
src/content | 2 +-
3 files changed, 210 insertions(+), 5 deletions(-)
create mode 100644 scripts/migrate-exchange-widget.py
diff --git a/scripts/migrate-exchange-widget.py b/scripts/migrate-exchange-widget.py
new file mode 100644
index 000000000..30d21bfb4
--- /dev/null
+++ b/scripts/migrate-exchange-widget.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+"""
+Bulk-migrate content files:
+1. Strip `currency="..."` from tags (all files)
+2. Insert inline for send-to and corridor pages
+"""
+
+import re
+import os
+from pathlib import Path
+
+CONTENT_DIR = Path(__file__).parent.parent / "src" / "content" / "content"
+
+# Map origin country slug to ISO 4217 currency code (for corridor sourceCurrency)
+ORIGIN_CURRENCY = {
+ "italy": "EUR",
+ "france": "EUR",
+ "spain": "EUR",
+ "germany": "EUR",
+ "portugal": "EUR",
+ "united-kingdom": "GBP",
+ "united-states": "USD",
+ "brazil": "BRL",
+ "argentina": "ARS",
+ "australia": "AUD",
+ "canada": "CAD",
+ "japan": "JPY",
+ "mexico": "MXN",
+ "colombia": "COP",
+ "singapore": "SGD",
+}
+
+def extract_currency(content: str) -> str | None:
+ """Extract currency code from Hero tag."""
+ m = re.search(r'currency="([A-Z]{3})"', content)
+ return m.group(1) if m else None
+
+
+def strip_currency_from_hero(content: str) -> str:
+ """Remove currency="..." prop from tag."""
+ return re.sub(r'\s+currency="[A-Z]{3}"', '', content)
+
+
+def has_exchange_widget(content: str) -> bool:
+ """Check if file already has an ExchangeWidget."""
+ return ' str:
+ """Determine page type from file path."""
+ rel = filepath.relative_to(CONTENT_DIR)
+ parts = rel.parts
+
+ if parts[0] == "send-to" and "from" in parts:
+ return "corridor"
+ elif parts[0] == "send-to":
+ return "send-to"
+ elif parts[0] == "countries":
+ return "hub"
+ elif parts[0] == "pay-with":
+ return "pay-with"
+ return "other"
+
+
+def get_corridor_origin(filepath: Path) -> str | None:
+ """Extract origin country slug from corridor path."""
+ rel = filepath.relative_to(CONTENT_DIR)
+ parts = rel.parts
+ # send-to/{dest}/from/{origin}/{lang}.md
+ try:
+ from_idx = parts.index("from")
+ return parts[from_idx + 1]
+ except (ValueError, IndexError):
+ return None
+
+
+def build_widget_tag(dest_currency: str, source_currency: str | None = None) -> str:
+ """Build the ExchangeWidget MDX tag."""
+ if source_currency and source_currency != "USD":
+ return f' '
+ return f' '
+
+
+def insert_widget_send_to(content: str, widget_tag: str) -> str:
+ """Insert widget before str:
+ """Insert widget before inline CTA in corridor pages."""
+ # Insert before inline CTA (variant="secondary")
+ m = re.search(r'\n bool:
+ """Process a single content file. Returns True if modified."""
+ content = filepath.read_text(encoding="utf-8")
+
+ currency = extract_currency(content)
+ if not currency:
+ return False
+
+ page_type = get_page_type(filepath)
+ modified = False
+
+ # Step 1: Always strip currency from Hero
+ new_content = strip_currency_from_hero(content)
+ if new_content != content:
+ modified = True
+ content = new_content
+
+ # Step 2: Add ExchangeWidget for eligible page types
+ if page_type in ("send-to", "corridor") and not has_exchange_widget(content):
+ source_currency = None
+ if page_type == "corridor":
+ origin = get_corridor_origin(filepath)
+ if origin:
+ source_currency = ORIGIN_CURRENCY.get(origin)
+
+ widget_tag = build_widget_tag(currency, source_currency)
+
+ if page_type == "send-to":
+ new_content = insert_widget_send_to(content, widget_tag)
+ else:
+ new_content = insert_widget_corridor(content, widget_tag)
+
+ if new_content != content:
+ modified = True
+ content = new_content
+
+ if modified:
+ filepath.write_text(content, encoding="utf-8")
+
+ return modified
+
+
+def main():
+ stats = {"stripped": 0, "widget_added": 0, "skipped": 0, "errors": 0}
+
+ for md_file in sorted(CONTENT_DIR.rglob("*.md")):
+ content = md_file.read_text(encoding="utf-8")
+ if 'currency="' not in content:
+ continue
+
+ rel = md_file.relative_to(CONTENT_DIR)
+ page_type = get_page_type(md_file)
+ currency = extract_currency(content)
+
+ try:
+ had_widget = has_exchange_widget(content)
+ modified = process_file(md_file)
+
+ if modified:
+ new_content = md_file.read_text(encoding="utf-8")
+ widget_added = not had_widget and has_exchange_widget(new_content)
+
+ stats["stripped"] += 1
+ if widget_added:
+ stats["widget_added"] += 1
+ print(f" ✓ {rel} [{page_type}] — stripped currency={currency}, added ExchangeWidget")
+ else:
+ print(f" ✓ {rel} [{page_type}] — stripped currency={currency}")
+ else:
+ stats["skipped"] += 1
+ print(f" · {rel} [{page_type}] — skipped (no currency= found)")
+ except Exception as e:
+ stats["errors"] += 1
+ print(f" ✗ {rel} — ERROR: {e}")
+
+ print(f"\nDone:")
+ print(f" Stripped currency=: {stats['stripped']}")
+ print(f" Added ExchangeWidget: {stats['widget_added']}")
+ print(f" Skipped: {stats['skipped']}")
+ print(f" Errors: {stats['errors']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/components/Marketing/mdx/ExchangeWidget.tsx b/src/components/Marketing/mdx/ExchangeWidget.tsx
index 138fec464..ef330afbf 100644
--- a/src/components/Marketing/mdx/ExchangeWidget.tsx
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -33,10 +33,12 @@ function ExchangeWidgetInner({ destinationCurrency, sourceCurrency = 'USD' }: Ex
}, [destinationCurrency, sourceCurrency, searchParams, router])
return (
-
-
-
-
+
+
+
+
+
+
Date: Wed, 25 Feb 2026 23:07:34 +0000
Subject: [PATCH 20/61] =?UTF-8?q?fix:=20SEO=20audit=20=E2=80=94=20OG=20URL?=
=?UTF-8?q?,=20robots.txt=20locales,=20deduplicate=20extractors,=20add=20e?=
=?UTF-8?q?rror=20boundary?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix OpenGraph URL to use canonical path instead of always pointing to homepage
- Generate robots.txt locale allowlist from SUPPORTED_LOCALES (was missing es-419, es-ar, es-es, pt-br)
- Extract shared extractFaqs/extractSteps/extractTroubleshooting into src/data/seo/utils.ts
- extractFaqs now also supports MDX syntax as fallback
- Add isPublished check in comparisons.ts (was skipped during transition)
- Add error.tsx boundary for marketing routes
- Add content validation script (pnpm validate-content)
---
package.json | 3 +-
scripts/validate-content.ts | 266 +++++++++++++++++++++++++
src/app/[locale]/(marketing)/error.tsx | 30 +++
src/app/metadata.ts | 2 +-
src/app/robots.ts | 7 +-
src/data/seo/comparisons.ts | 35 +---
src/data/seo/corridors.ts | 31 +--
src/data/seo/exchanges.ts | 61 +-----
src/data/seo/payment-methods.ts | 53 +----
src/data/seo/utils.ts | 95 +++++++++
10 files changed, 413 insertions(+), 170 deletions(-)
create mode 100644 scripts/validate-content.ts
create mode 100644 src/app/[locale]/(marketing)/error.tsx
create mode 100644 src/data/seo/utils.ts
diff --git a/package.json b/package.json
index 6d3253cc4..eff1fe5d7 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
- "script": "NODE_OPTIONS=\"--experimental-json-modules\" tsx"
+ "script": "NODE_OPTIONS=\"--experimental-json-modules\" tsx",
+ "validate-content": "tsx scripts/validate-content.ts"
},
"dependencies": {
"@dicebear/collection": "^9.2.2",
diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts
new file mode 100644
index 000000000..8b8831162
--- /dev/null
+++ b/scripts/validate-content.ts
@@ -0,0 +1,266 @@
+#!/usr/bin/env tsx
+/**
+ * Content validation for peanut-ui.
+ * Run: npx tsx scripts/validate-content.ts
+ *
+ * Validates that content consumed by SEO loaders (src/data/seo/*.ts) has:
+ * 1. Valid YAML frontmatter with required fields per content type
+ * 2. Slugs matching expected URL patterns
+ * 3. Published flag set correctly
+ * 4. en.md files present for all published content
+ * 5. Entity data files present for all content pages
+ */
+
+import fs from 'fs'
+import path from 'path'
+import matter from 'gray-matter'
+
+const ROOT = path.join(process.cwd(), 'src/content')
+const errors: string[] = []
+const warnings: string[] = []
+
+function error(msg: string) {
+ errors.push(`ERROR: ${msg}`)
+}
+
+function warn(msg: string) {
+ warnings.push(`WARN: ${msg}`)
+}
+
+function rel(filePath: string): string {
+ return path.relative(ROOT, filePath)
+}
+
+function readFrontmatter(filePath: string): Record | null {
+ try {
+ const raw = fs.readFileSync(filePath, 'utf8')
+ const { data } = matter(raw)
+ return data
+ } catch (e) {
+ error(`Invalid frontmatter: ${rel(filePath)} — ${(e as Error).message}`)
+ return null
+ }
+}
+
+function listDirs(dir: string): string[] {
+ try {
+ return fs
+ .readdirSync(dir, { withFileTypes: true })
+ .filter((d) => d.isDirectory())
+ .map((d) => d.name)
+ } catch {
+ return []
+ }
+}
+
+function listMdFiles(dir: string): string[] {
+ try {
+ return fs
+ .readdirSync(dir)
+ .filter((f) => f.endsWith('.md') && f !== 'README.md')
+ } catch {
+ return []
+ }
+}
+
+// --- Content type validators ---
+
+interface ContentTypeConfig {
+ /** Directory under content/ */
+ contentDir: string
+ /** Directory under input/data/ for entity data (null if no entity data expected) */
+ entityDir: string | null
+ /** Required frontmatter fields */
+ requiredFields: string[]
+ /** Slug pattern regex (validates the directory name) */
+ slugPattern?: RegExp
+ /** Optional: additional entity data required fields */
+ entityRequiredFields?: string[]
+}
+
+const CONTENT_TYPES: ContentTypeConfig[] = [
+ {
+ contentDir: 'countries',
+ entityDir: 'countries',
+ requiredFields: ['title', 'description', 'slug', 'lang', 'published'],
+ slugPattern: /^[a-z]+(-[a-z]+)*$/,
+ entityRequiredFields: ['name', 'currency'],
+ },
+ {
+ contentDir: 'compare',
+ entityDir: 'competitors',
+ requiredFields: ['title', 'description', 'slug', 'lang', 'published', 'competitor'],
+ slugPattern: /^[a-z0-9]+(-[a-z0-9]+)*$/,
+ entityRequiredFields: ['name', 'type'],
+ },
+ {
+ contentDir: 'deposit',
+ entityDir: null, // deposit content doesn't map 1:1 to exchange entities
+ requiredFields: ['title', 'description', 'slug', 'lang', 'published'],
+ slugPattern: /^[a-z0-9]+(-[a-z0-9]+)*$/,
+ },
+ {
+ contentDir: 'pay-with',
+ entityDir: 'spending-methods',
+ requiredFields: ['title', 'description', 'slug', 'lang', 'published'],
+ slugPattern: /^[a-z]+(-[a-z]+)*$/,
+ entityRequiredFields: ['name', 'type'],
+ },
+]
+
+interface TypeCounts {
+ total: number
+ published: number
+ draft: number
+ missingEn: number
+}
+
+function validateContentType(config: ContentTypeConfig): TypeCounts {
+ const contentPath = path.join(ROOT, 'content', config.contentDir)
+ const slugs = listDirs(contentPath)
+ const counts: TypeCounts = { total: slugs.length, published: 0, draft: 0, missingEn: 0 }
+
+ for (const slug of slugs) {
+ const slugDir = path.join(contentPath, slug)
+
+ // Validate slug format
+ if (config.slugPattern && !config.slugPattern.test(slug)) {
+ error(`${config.contentDir}/${slug}: slug doesn't match pattern ${config.slugPattern}`)
+ }
+
+ // Check en.md exists
+ const enPath = path.join(slugDir, 'en.md')
+ if (!fs.existsSync(enPath)) {
+ error(`${config.contentDir}/${slug}: missing en.md`)
+ counts.missingEn++
+ continue
+ }
+
+ // Validate frontmatter
+ const fm = readFrontmatter(enPath)
+ if (!fm) continue
+
+ // Check required fields
+ for (const field of config.requiredFields) {
+ if (fm[field] === undefined || fm[field] === null || fm[field] === '') {
+ error(`${config.contentDir}/${slug}/en.md: missing required field '${field}'`)
+ }
+ }
+
+ // Check slug consistency
+ if (fm.slug && fm.slug !== slug) {
+ warn(`${config.contentDir}/${slug}/en.md: frontmatter slug '${fm.slug}' doesn't match directory name '${slug}'`)
+ }
+
+ // Check published status
+ if (fm.published === true) {
+ counts.published++
+ } else {
+ counts.draft++
+ }
+
+ // Validate locale files have matching slugs
+ const mdFiles = listMdFiles(slugDir)
+ for (const mdFile of mdFiles) {
+ if (mdFile === 'en.md') continue
+ const localeFm = readFrontmatter(path.join(slugDir, mdFile))
+ if (localeFm && localeFm.slug && localeFm.slug !== slug) {
+ warn(`${config.contentDir}/${slug}/${mdFile}: frontmatter slug '${localeFm.slug}' doesn't match directory '${slug}'`)
+ }
+ if (localeFm && localeFm.lang) {
+ const expectedLang = mdFile.replace('.md', '')
+ if (localeFm.lang !== expectedLang) {
+ warn(`${config.contentDir}/${slug}/${mdFile}: frontmatter lang '${localeFm.lang}' doesn't match filename '${expectedLang}'`)
+ }
+ }
+ }
+
+ // Cross-reference entity data
+ if (config.entityDir) {
+ const entityPath = path.join(ROOT, 'input/data', config.entityDir, `${slug}.md`)
+ if (!fs.existsSync(entityPath)) {
+ warn(`${config.contentDir}/${slug}: no matching entity data at input/data/${config.entityDir}/${slug}.md`)
+ } else if (config.entityRequiredFields) {
+ const entityFm = readFrontmatter(entityPath)
+ if (entityFm) {
+ for (const field of config.entityRequiredFields) {
+ if (entityFm[field] === undefined || entityFm[field] === null) {
+ error(`input/data/${config.entityDir}/${slug}.md: missing required field '${field}'`)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return counts
+}
+
+// --- Validate entity data without content pages ---
+
+function validateEntityData() {
+ // Check exchanges entity data (consumed directly by exchanges.ts loader)
+ const exchangeDir = path.join(ROOT, 'input/data/exchanges')
+ const exchangeFiles = listMdFiles(exchangeDir)
+
+ for (const file of exchangeFiles) {
+ const slug = file.replace('.md', '')
+ const fm = readFrontmatter(path.join(exchangeDir, file))
+ if (!fm) continue
+
+ if (!fm.name) error(`input/data/exchanges/${file}: missing required field 'name'`)
+ if (!fm.supported_networks) warn(`input/data/exchanges/${file}: missing 'supported_networks'`)
+ }
+
+ console.log(` Exchange entities: ${exchangeFiles.length}`)
+}
+
+// --- Validate convert pairs ---
+
+function validateConvertPairs() {
+ const pairsPath = path.join(ROOT, 'content/convert/pairs.yaml')
+ if (!fs.existsSync(pairsPath)) {
+ // Try alternate location
+ const altPath = path.join(ROOT, 'input/data/currencies/pairs.yaml')
+ if (!fs.existsSync(altPath)) {
+ warn('No convert pairs file found')
+ return
+ }
+ }
+}
+
+// --- Run ---
+
+console.log('\nValidating peanut-ui content...\n')
+
+for (const config of CONTENT_TYPES) {
+ const counts = validateContentType(config)
+ const parts = [`${counts.total} entries`]
+ if (counts.published > 0 || counts.draft > 0) {
+ parts.push(`${counts.published} published, ${counts.draft} draft`)
+ }
+ if (counts.missingEn > 0) {
+ parts.push(`${counts.missingEn} missing en.md`)
+ }
+ console.log(` ${config.contentDir}: ${parts.join(' — ')}`)
+}
+
+validateEntityData()
+validateConvertPairs()
+
+console.log('')
+
+if (warnings.length > 0) {
+ console.log(`${warnings.length} warning(s):`)
+ for (const w of warnings) console.log(` ${w}`)
+ console.log('')
+}
+
+if (errors.length > 0) {
+ console.log(`${errors.length} error(s):`)
+ for (const e of errors) console.log(` ${e}`)
+ console.log('')
+ process.exit(1)
+} else {
+ console.log('All content valid!\n')
+}
diff --git a/src/app/[locale]/(marketing)/error.tsx b/src/app/[locale]/(marketing)/error.tsx
new file mode 100644
index 000000000..99086a54c
--- /dev/null
+++ b/src/app/[locale]/(marketing)/error.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+
+export default function MarketingError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ useEffect(() => {
+ console.error(error)
+ }, [error])
+
+ return (
+
+
Something went wrong
+
+ We had trouble loading this page. Please try again or go back to the homepage.
+
+
+
+ Try again
+
+
+ Go home
+
+
+
+ )
+}
diff --git a/src/app/metadata.ts b/src/app/metadata.ts
index fefc08636..64c4cbb50 100644
--- a/src/app/metadata.ts
+++ b/src/app/metadata.ts
@@ -25,7 +25,7 @@ export function generateMetadata({
type: 'website',
title,
description,
- url: BASE_URL,
+ url: canonical ? `${BASE_URL}${canonical}` : BASE_URL,
siteName: 'Peanut',
images: [{ url: image, width: 1200, height: 630, alt: title }],
},
diff --git a/src/app/robots.ts b/src/app/robots.ts
index ba173f1c6..9ce741e3f 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -1,5 +1,6 @@
import type { MetadataRoute } from 'next'
import { BASE_URL } from '@/constants/general.consts'
+import { SUPPORTED_LOCALES } from '@/i18n/types'
const IS_PRODUCTION_DOMAIN = BASE_URL === 'https://peanut.me'
@@ -44,10 +45,8 @@ export default function robots(): MetadataRoute.Robots {
'/terms',
'/exchange',
'/lp/card',
- // SEO routes (all locale-prefixed: /en/, /es/, /pt/)
- '/en/',
- '/es/',
- '/pt/',
+ // SEO routes (all locale-prefixed)
+ ...SUPPORTED_LOCALES.map((l) => `/${l}/`),
],
disallow: [
'/api/',
diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts
index fdf463d96..ee17d2a46 100644
--- a/src/data/seo/comparisons.ts
+++ b/src/data/seo/comparisons.ts
@@ -3,6 +3,7 @@
// Public API unchanged from previous version.
import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs, isPublished } from '@/lib/content'
+import { extractFaqs } from './utils'
// --- Entity frontmatter (input/data/competitors/{slug}.md) ---
@@ -63,8 +64,7 @@ function loadCompetitors(): Record {
const content = readPageContent('compare', slug, 'en')
- // During transition: include if entity exists and content exists (even if unpublished)
- if (!content) continue
+ if (!content || !isPublished(content)) continue
const fm = entity.frontmatter
const body = content.body
@@ -139,35 +139,4 @@ function buildVerdict(fm: CompetitorEntityFrontmatter): string {
return `Both services have their strengths. Peanut excels for local payments in Latin America with better exchange rates.`
}
-/** Extract FAQ items from markdown body (## FAQ or ## Frequently Asked Questions section) */
-function extractFaqs(body: string): Array<{ q: string; a: string }> {
- const faqs: Array<{ q: string; a: string }> = []
-
- // Look for ### headings after a FAQ section header
- const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
- if (!faqSection) return faqs
-
- const lines = faqSection[1].split('\n')
- let currentQ = ''
- let currentA = ''
-
- for (const line of lines) {
- if (line.startsWith('### ')) {
- if (currentQ && currentA.trim()) {
- faqs.push({ q: currentQ, a: currentA.trim() })
- }
- currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
- currentA = ''
- } else if (currentQ) {
- currentA += line + '\n'
- }
- }
-
- if (currentQ && currentA.trim()) {
- faqs.push({ q: currentQ, a: currentA.trim() })
- }
-
- return faqs
-}
-
export const COMPETITORS: Record = loadCompetitors()
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
index 2abb2aff5..d3403fdf8 100644
--- a/src/data/seo/corridors.ts
+++ b/src/data/seo/corridors.ts
@@ -12,6 +12,7 @@ import {
isPublished,
} from '@/lib/content'
import type { Locale } from '@/i18n/types'
+import { extractFaqs } from './utils'
// --- Entity frontmatter schema (input/data/countries/{slug}.md) ---
@@ -70,32 +71,6 @@ export interface Corridor {
to: string
}
-// --- Helpers ---
-
-/** Extract FAQ items from markdown body (## FAQ section with ### question headings) */
-function extractFaqsFromBody(body: string): Array<{ q: string; a: string }> {
- const faqs: Array<{ q: string; a: string }> = []
- const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
- if (!faqSection) return faqs
-
- const lines = faqSection[1].split('\n')
- let currentQ = ''
- let currentA = ''
-
- for (const line of lines) {
- if (line.startsWith('### ')) {
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
- currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
- currentA = ''
- } else if (currentQ) {
- currentA += line + '\n'
- }
- }
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
-
- return faqs
-}
-
// --- Loader ---
function loadAll() {
@@ -144,7 +119,7 @@ function loadAll() {
}
// Extract FAQs from the content body
- const faqs = content ? extractFaqsFromBody(content.body) : []
+ const faqs = content ? extractFaqs(content.body) : []
countries[slug] = {
name: fm.name,
@@ -241,7 +216,7 @@ export function getLocalizedSEO(country: string, locale: Locale): CountrySEO | n
const localized = readPageContentLocalized('countries', country, locale)
if (!localized) return base
- const localizedFaqs = extractFaqsFromBody(localized.body)
+ const localizedFaqs = extractFaqs(localized.body)
return {
...base,
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
index 2704ff13f..9486629d6 100644
--- a/src/data/seo/exchanges.ts
+++ b/src/data/seo/exchanges.ts
@@ -3,6 +3,7 @@
// Public API unchanged from previous version.
import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs } from '@/lib/content'
+import { extractFaqs, extractSteps, extractTroubleshooting } from './utils'
// --- Entity frontmatter (input/data/exchanges/{slug}.md) ---
@@ -58,7 +59,7 @@ function loadExchanges(): Record {
const fm = entity.frontmatter
// Extract steps from entity body (numbered list under ## Deposit to Peanut Flow)
- const steps = extractSteps(entity.body)
+ const steps = extractSteps(entity.body, /Deposit to Peanut Flow|Step-by-Step|How to Deposit/)
const troubleshooting = extractTroubleshooting(entity.body)
const faqs = extractFaqs(entity.body)
@@ -104,62 +105,4 @@ function estimateProcessingTime(network: string): string {
return times[network] ?? '1-10 minutes'
}
-/** Extract numbered steps from markdown body */
-function extractSteps(body: string): string[] {
- const steps: string[] = []
- const stepSection = body.match(
- /## (?:Deposit to Peanut Flow|Step-by-Step|How to Deposit)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i
- )
- if (!stepSection) return steps
-
- const lines = stepSection[1].split('\n')
- for (const line of lines) {
- const match = line.match(/^\d+\.\s+(.+)/)
- if (match) {
- steps.push(match[1].replace(/\*\*/g, '').trim())
- }
- }
- return steps
-}
-
-/** Extract troubleshooting items from markdown body */
-function extractTroubleshooting(body: string): Array<{ issue: string; fix: string }> {
- const items: Array<{ issue: string; fix: string }> = []
- const section = body.match(/## (?:Troubleshooting|Common Issues)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
- if (!section) return items
-
- const lines = section[1].split('\n')
- for (const line of lines) {
- const match = line.match(/^[-*]\s+\*\*(.+?)\*\*[:\s]+(.+)/)
- if (match) {
- items.push({ issue: match[1], fix: match[2].trim() })
- }
- }
- return items
-}
-
-/** Extract FAQ items from markdown body */
-function extractFaqs(body: string): Array<{ q: string; a: string }> {
- const faqs: Array<{ q: string; a: string }> = []
- const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
- if (!faqSection) return faqs
-
- const lines = faqSection[1].split('\n')
- let currentQ = ''
- let currentA = ''
-
- for (const line of lines) {
- if (line.startsWith('### ')) {
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
- currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
- currentA = ''
- } else if (currentQ) {
- currentA += line + '\n'
- }
- }
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
-
- return faqs
-}
-
export const EXCHANGES: Record = loadExchanges()
diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts
index 31480fd00..832cb488c 100644
--- a/src/data/seo/payment-methods.ts
+++ b/src/data/seo/payment-methods.ts
@@ -4,6 +4,7 @@
// Public API unchanged from previous version.
import { readEntityData, readPageContent, listEntitySlugs, listContentSlugs } from '@/lib/content'
+import { extractFaqs, extractSteps } from './utils'
// --- Entity frontmatter (input/data/spending-methods/{slug}.md) ---
@@ -67,7 +68,14 @@ function loadPaymentMethods(): Record {
name: fm.name,
countries: fm.countries ?? [],
description: content.body,
- steps: extractSteps(content.body),
+ steps: extractSteps(
+ content.body,
+ /Merchant QR Payments|How to Pay|Steps|How It Works/,
+ (line) => {
+ const match = line.match(/^\d+\.\s+\*\*(.+?)\*\*/)
+ return match ? match[1].trim() : null
+ }
+ ),
faqs: extractFaqs(content.body),
}
}
@@ -75,48 +83,5 @@ function loadPaymentMethods(): Record {
return result
}
-/** Extract numbered steps from markdown body */
-function extractSteps(body: string): string[] {
- const steps: string[] = []
- // Look for numbered lists in "How to" or step sections
- const section = body.match(
- /###?\s+(?:Merchant QR Payments|How to Pay|Steps|How It Works)\s*\n([\s\S]*?)(?=\n###?\s|$)/i
- )
- if (!section) return steps
-
- const lines = section[1].split('\n')
- for (const line of lines) {
- const match = line.match(/^\d+\.\s+\*\*(.+?)\*\*/)
- if (match) {
- steps.push(match[1].trim())
- }
- }
- return steps
-}
-
-/** Extract FAQ items from markdown body */
-function extractFaqs(body: string): Array<{ q: string; a: string }> {
- const faqs: Array<{ q: string; a: string }> = []
- const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
- if (!faqSection) return faqs
-
- const lines = faqSection[1].split('\n')
- let currentQ = ''
- let currentA = ''
-
- for (const line of lines) {
- if (line.startsWith('### ')) {
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
- currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
- currentA = ''
- } else if (currentQ) {
- currentA += line + '\n'
- }
- }
- if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
-
- return faqs
-}
-
export const PAYMENT_METHODS = loadPaymentMethods()
export const PAYMENT_METHOD_SLUGS = Object.keys(PAYMENT_METHODS)
diff --git a/src/data/seo/utils.ts b/src/data/seo/utils.ts
new file mode 100644
index 000000000..02b681a28
--- /dev/null
+++ b/src/data/seo/utils.ts
@@ -0,0 +1,95 @@
+// Shared extraction utilities for SEO content loaders.
+// Parses structured data (FAQs, steps, troubleshooting) from markdown/MDX body text.
+
+export interface FAQ {
+ q: string
+ a: string
+}
+
+/**
+ * Extract FAQ items from markdown/MDX body.
+ * Supports two formats:
+ * 1. Markdown: ## FAQ section with ### question headings
+ * 2. MDX: answer components
+ */
+export function extractFaqs(body: string): FAQ[] {
+ const faqs: FAQ[] = []
+
+ // Format 1: Markdown ## FAQ section with ### headings
+ const faqSection = body.match(/## (?:FAQ|Frequently Asked Questions)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (faqSection) {
+ const lines = faqSection[1].split('\n')
+ let currentQ = ''
+ let currentA = ''
+
+ for (const line of lines) {
+ if (line.startsWith('### ')) {
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+ currentQ = line.replace(/^### /, '').replace(/\*\*/g, '').trim()
+ currentA = ''
+ } else if (currentQ) {
+ currentA += line + '\n'
+ }
+ }
+ if (currentQ && currentA.trim()) faqs.push({ q: currentQ, a: currentA.trim() })
+ }
+
+ // Format 2: MDX answer
+ if (faqs.length === 0) {
+ const faqItems = body.matchAll(/]*>([\s\S]*?)<\/FAQItem>/g)
+ for (const match of faqItems) {
+ faqs.push({ q: match[1], a: match[2].trim() })
+ }
+ }
+
+ return faqs
+}
+
+/**
+ * Extract numbered steps from a markdown section.
+ * @param body - markdown body text
+ * @param headingPattern - regex pattern to match the section heading (without ## prefix)
+ * @param lineParser - optional custom line parser; defaults to extracting `1. step text`
+ */
+export function extractSteps(
+ body: string,
+ headingPattern: RegExp,
+ lineParser?: (line: string) => string | null
+): string[] {
+ const steps: string[] = []
+ const section = body.match(new RegExp(`##?#?\\s+(?:${headingPattern.source})\\s*\\n([\\s\\S]*?)(?=\\n##?#?\\s|$)`, 'i'))
+ if (!section) return steps
+
+ const defaultParser = (line: string): string | null => {
+ const match = line.match(/^\d+\.\s+(.+)/)
+ return match ? match[1].replace(/\*\*/g, '').trim() : null
+ }
+
+ const parse = lineParser ?? defaultParser
+
+ const lines = section[1].split('\n')
+ for (const line of lines) {
+ const result = parse(line)
+ if (result) steps.push(result)
+ }
+ return steps
+}
+
+/**
+ * Extract troubleshooting items from markdown body.
+ * Looks for `- **issue**: fix` patterns under a ## Troubleshooting heading.
+ */
+export function extractTroubleshooting(body: string): Array<{ issue: string; fix: string }> {
+ const items: Array<{ issue: string; fix: string }> = []
+ const section = body.match(/## (?:Troubleshooting|Common Issues)\s*\n([\s\S]*?)(?=\n## [^#]|$)/i)
+ if (!section) return items
+
+ const lines = section[1].split('\n')
+ for (const line of lines) {
+ const match = line.match(/^[-*]\s+\*\*(.+?)\*\*[:\s]+(.+)/)
+ if (match) {
+ items.push({ issue: match[1], fix: match[2].trim() })
+ }
+ }
+ return items
+}
From 848151c7cee7697a995d3fb813494d50e9020de9 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 23:34:13 +0000
Subject: [PATCH 21/61] chore: remove thin programmatic pages, unpublish
blog/team from production
- Delete convert pages (284 thin currency pair pages with no real content)
- Delete send-money-to index page (country grid, no unique content)
- Remove convert.ts data loader and its exports from seo/index.ts
- Remove convert + send-money-to index + blog + team from sitemap
- Gate blog and team generateStaticParams to dev-only (code kept, not built in prod)
- Simplify breadcrumbs in send-money-to/[country] and send-money-from/[from]/to/[to]
---
.../[locale]/(marketing)/blog/[slug]/page.tsx | 1 +
.../(marketing)/blog/category/[cat]/page.tsx | 1 +
src/app/[locale]/(marketing)/blog/page.tsx | 1 +
.../(marketing)/convert/[pair]/page.tsx | 140 ------------------
.../send-money-from/[from]/to/[to]/page.tsx | 1 -
.../send-money-to/[country]/page.tsx | 1 -
.../(marketing)/send-money-to/page.tsx | 68 ---------
src/app/[locale]/(marketing)/team/page.tsx | 1 +
src/app/sitemap.ts | 30 +---
src/data/seo/convert.ts | 86 -----------
src/data/seo/index.ts | 1 -
11 files changed, 7 insertions(+), 324 deletions(-)
delete mode 100644 src/app/[locale]/(marketing)/convert/[pair]/page.tsx
delete mode 100644 src/app/[locale]/(marketing)/send-money-to/page.tsx
delete mode 100644 src/data/seo/convert.ts
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
index 8b013d29a..dedbdc583 100644
--- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -14,6 +14,7 @@ interface PageProps {
}
export async function generateStaticParams() {
+ if (process.env.NODE_ENV === 'production') return []
// Generate params for locales that have blog content (fall back to en slugs)
return SUPPORTED_LOCALES.flatMap((locale) => {
let posts = getAllPosts(locale as Locale)
diff --git a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
index 9926d05d4..71a911f53 100644
--- a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
@@ -15,6 +15,7 @@ interface PageProps {
}
export async function generateStaticParams() {
+ if (process.env.NODE_ENV === 'production') return []
return SUPPORTED_LOCALES.flatMap((locale) => {
// Use English categories as fallback
const cats = getAllCategories(locale as Locale)
diff --git a/src/app/[locale]/(marketing)/blog/page.tsx b/src/app/[locale]/(marketing)/blog/page.tsx
index 14c5d482b..e30220e20 100644
--- a/src/app/[locale]/(marketing)/blog/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/page.tsx
@@ -15,6 +15,7 @@ interface PageProps {
}
export async function generateStaticParams() {
+ if (process.env.NODE_ENV === 'production') return []
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
diff --git a/src/app/[locale]/(marketing)/convert/[pair]/page.tsx b/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
deleted file mode 100644
index 7c31d7b96..000000000
--- a/src/app/[locale]/(marketing)/convert/[pair]/page.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { notFound } from 'next/navigation'
-import { type Metadata } from 'next'
-import { generateMetadata as metadataHelper } from '@/app/metadata'
-import { CONVERT_PAIRS, CURRENCY_DISPLAY, parseConvertPair } from '@/data/seo'
-import { MarketingHero } from '@/components/Marketing/MarketingHero'
-import { MarketingShell } from '@/components/Marketing/MarketingShell'
-import { Section } from '@/components/Marketing/Section'
-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 } from '@/i18n'
-
-export const revalidate = 300
-
-interface PageProps {
- params: Promise<{ locale: string; pair: string }>
-}
-
-export async function generateStaticParams() {
- return SUPPORTED_LOCALES.flatMap((locale) => CONVERT_PAIRS.map((pair) => ({ locale, pair })))
-}
-export const dynamicParams = false
-
-export async function generateMetadata({ params }: PageProps): Promise {
- const { locale, pair } = await params
- if (!isValidLocale(locale)) return {}
-
- const parsed = parseConvertPair(pair)
- if (!parsed) return {}
- const fromDisplay = CURRENCY_DISPLAY[parsed.from]
- const toDisplay = CURRENCY_DISPLAY[parsed.to]
- if (!fromDisplay || !toDisplay) return {}
-
- const i18n = getTranslations(locale as Locale)
-
- return {
- ...metadataHelper({
- title: `${t(i18n.convertTitle, { from: parsed.from.toUpperCase(), to: parsed.to.toUpperCase() })} | Peanut`,
- description: `${t(i18n.convertTitle, { from: fromDisplay.name, to: toDisplay.name })}`,
- canonical: `/${locale}/convert/${pair}`,
- }),
- alternates: {
- canonical: `/${locale}/convert/${pair}`,
- languages: getAlternates('convert', pair),
- },
- }
-}
-
-export default async function ConvertPairPageLocalized({ params }: PageProps) {
- const { locale, pair } = await params
- if (!isValidLocale(locale)) notFound()
-
- const parsed = parseConvertPair(pair)
- if (!parsed || !(CONVERT_PAIRS as readonly string[]).includes(pair)) notFound()
-
- const fromDisplay = CURRENCY_DISPLAY[parsed.from]
- const toDisplay = CURRENCY_DISPLAY[parsed.to]
- if (!fromDisplay || !toDisplay) notFound()
-
- const i18n = getTranslations(locale as Locale)
- const fromCode = parsed.from.toUpperCase()
- const toCode = parsed.to.toUpperCase()
- const conversionAmounts = [10, 50, 100, 250, 500, 1000]
-
- 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: t(i18n.convertTitle, { from: fromCode, to: toCode }),
- item: `https://peanut.me/${locale}/convert/${pair}`,
- },
- ],
- }
-
- const faqs = [
- {
- q: t(i18n.convertTitle, { from: fromCode, to: toCode }) + '?',
- a: `Peanut — ${t(i18n.convertTitle, { from: fromDisplay.name, to: toDisplay.name })}`,
- },
- ]
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
- {fromCode}
- {toCode}
-
-
-
- {conversionAmounts.map((amount, i) => (
-
-
- {fromDisplay.symbol}
- {amount.toLocaleString()}
-
-
- {i18n.liveRate}
-
-
- ))}
-
-
-
-
-
-
-
- {i18n.stepCreateAccountDesc}
- {t(i18n.stepDepositFundsDesc, { method: '' })}
- {t(i18n.stepSendToDesc, { currency: toDisplay.name, method: '' })}
-
-
-
- {/* TODO (marketer): Add 300+ words of editorial content per currency pair to avoid
- thin content flags. Include: currency background, exchange rate trends,
- tips for getting best rates, common use cases. See Wise convert pages for reference. */}
-
-
-
- >
- )
-}
diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
index 45faf9e8f..0ec5bf995 100644
--- a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -59,7 +59,6 @@ export default async function FromToCorridorPage({ params }: PageProps) {
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
index 18d7c7221..618f53673 100644
--- a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -57,7 +57,6 @@ export default async function SendMoneyToCountryPageLocalized({ params }: PagePr
diff --git a/src/app/[locale]/(marketing)/send-money-to/page.tsx b/src/app/[locale]/(marketing)/send-money-to/page.tsx
deleted file mode 100644
index 4a8c38b60..000000000
--- a/src/app/[locale]/(marketing)/send-money-to/page.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { notFound } from 'next/navigation'
-import { type Metadata } from 'next'
-import { generateMetadata as metadataHelper } from '@/app/metadata'
-import { MarketingHero } from '@/components/Marketing/MarketingHero'
-import { MarketingShell } from '@/components/Marketing/MarketingShell'
-import { DestinationGrid } from '@/components/Marketing/DestinationGrid'
-import { JsonLd } from '@/components/Marketing/JsonLd'
-import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
-import type { Locale } from '@/i18n/types'
-import { getTranslations } from '@/i18n'
-
-interface PageProps {
- params: Promise<{ locale: string }>
-}
-
-export async function generateStaticParams() {
- return SUPPORTED_LOCALES.map((locale) => ({ locale }))
-}
-
-export async function generateMetadata({ params }: PageProps): Promise {
- const { locale } = await params
- if (!isValidLocale(locale)) return {}
-
- const i18n = getTranslations(locale as Locale)
-
- return {
- ...metadataHelper({
- title: `${i18n.sendMoney} | Peanut`,
- description: i18n.sendMoneyToSubtitle.replace('{country}', '').replace('{currency}', '').trim(),
- canonical: `/${locale}/send-money-to`,
- }),
- alternates: {
- canonical: `/${locale}/send-money-to`,
- languages: getAlternates('send-money-to'),
- },
- }
-}
-
-export default async function SendMoneyToIndexPageLocalized({ params }: PageProps) {
- const { locale } = await params
- if (!isValidLocale(locale)) notFound()
-
- const i18n = getTranslations(locale as Locale)
-
- 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: i18n.sendMoney,
- item: `https://peanut.me/${locale}/send-money-to`,
- },
- ],
- }
-
- return (
- <>
-
-
-
-
-
- >
- )
-}
diff --git a/src/app/[locale]/(marketing)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx
index 41add6ef3..5f523c846 100644
--- a/src/app/[locale]/(marketing)/team/page.tsx
+++ b/src/app/[locale]/(marketing)/team/page.tsx
@@ -15,6 +15,7 @@ interface PageProps {
}
export async function generateStaticParams() {
+ if (process.env.NODE_ENV === 'production') return []
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 9931aacee..5da5b172c 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,9 +1,7 @@
import { type MetadataRoute } from 'next'
import { BASE_URL } from '@/constants/general.consts'
-import { COUNTRIES_SEO, CORRIDORS, CONVERT_PAIRS, COMPETITORS, EXCHANGES, PAYMENT_METHOD_SLUGS } from '@/data/seo'
-import { getAllPosts } from '@/lib/blog'
+import { COUNTRIES_SEO, CORRIDORS, COMPETITORS, EXCHANGES, PAYMENT_METHOD_SLUGS } from '@/data/seo'
import { SUPPORTED_LOCALES } from '@/i18n/config'
-import type { Locale } from '@/i18n/types'
// TODO (infra): Set up 301 redirect peanut.to/* → peanut.me/ at Vercel/Cloudflare level
// TODO (infra): Set up 301 redirect docs.peanut.to/* → peanut.me/help
@@ -48,8 +46,7 @@ async function generateSitemap(): Promise {
pages.push({ path: `/${locale}/${country}`, priority: 0.9 * basePriority, changeFrequency: 'weekly' })
}
- // Corridor index + country pages
- pages.push({ path: `/${locale}/send-money-to`, priority: 0.9 * basePriority, changeFrequency: 'weekly' })
+ // Send-money-to country pages
for (const country of Object.keys(COUNTRIES_SEO)) {
pages.push({
path: `/${locale}/send-money-to/${country}`,
@@ -77,11 +74,6 @@ async function generateSitemap(): Promise {
})
}
- // Convert pages
- for (const pair of CONVERT_PAIRS) {
- pages.push({ path: `/${locale}/convert/${pair}`, priority: 0.7 * basePriority, changeFrequency: 'daily' })
- }
-
// Comparison pages
for (const slug of Object.keys(COMPETITORS)) {
pages.push({
@@ -109,23 +101,7 @@ async function generateSitemap(): Promise {
})
}
- // Blog — only include posts that actually exist for this locale (avoid duplicate content)
- const localePosts = getAllPosts(locale as Locale)
- const enPosts = getAllPosts('en')
- const postsToInclude = localePosts.length > 0 ? localePosts : isDefault ? enPosts : []
-
- pages.push({ path: `/${locale}/blog`, priority: 0.8 * basePriority, changeFrequency: 'weekly' })
- for (const post of postsToInclude) {
- pages.push({
- path: `/${locale}/blog/${post.slug}`,
- priority: 0.6 * basePriority,
- changeFrequency: 'monthly',
- lastModified: new Date(post.frontmatter.date),
- })
- }
-
- // Team page
- pages.push({ path: `/${locale}/team`, priority: 0.5 * basePriority, changeFrequency: 'monthly' })
+ // Blog and team pages excluded from production sitemap (not yet launched)
}
return pages.map((page) => ({
diff --git a/src/data/seo/convert.ts b/src/data/seo/convert.ts
deleted file mode 100644
index bc5784350..000000000
--- a/src/data/seo/convert.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-// Typed re-exports for currency conversion data.
-// Reads from peanut-content: input/data/currencies/
-// Builds convert pairs from available currency entities.
-
-import { readEntityData, listEntitySlugs } from '@/lib/content'
-
-// --- Entity frontmatter (input/data/currencies/{slug}.md) ---
-
-interface CurrencyEntityFrontmatter {
- slug: string
- name: string
- type: 'fiat' | 'stablecoin'
- symbol: string
- iso_code?: string
- countries?: string[]
-}
-
-// --- Build currency display data and pairs ---
-
-function loadCurrencyData() {
- const slugs = listEntitySlugs('currencies')
- const display: Record = {}
- const stablecoins: string[] = []
- const fiats: string[] = []
-
- for (const slug of slugs) {
- const entity = readEntityData('currencies', slug)
- if (!entity) continue
-
- const fm = entity.frontmatter
- display[slug] = { name: fm.name, symbol: fm.symbol }
-
- if (fm.type === 'stablecoin') {
- stablecoins.push(slug)
- } else {
- fiats.push(slug)
- }
- }
-
- // Build convert pairs: each stablecoin ↔ each fiat
- // Plus usd ↔ each non-USD fiat
- const pairs: string[] = []
-
- for (const stable of stablecoins) {
- for (const fiat of fiats) {
- pairs.push(`${stable}-to-${fiat}`)
- pairs.push(`${fiat}-to-${stable}`)
- }
- }
-
- // USD to/from major fiats
- if (fiats.includes('usd')) {
- for (const fiat of fiats) {
- if (fiat === 'usd') continue
- const pair1 = `usd-to-${fiat}`
- const pair2 = `${fiat}-to-usd`
- if (!pairs.includes(pair1)) pairs.push(pair1)
- if (!pairs.includes(pair2)) pairs.push(pair2)
- }
- }
-
- // EUR to/from major fiats
- if (fiats.includes('eur')) {
- for (const fiat of fiats) {
- if (fiat === 'eur') continue
- const pair1 = `eur-to-${fiat}`
- const pair2 = `${fiat}-to-eur`
- if (!pairs.includes(pair1)) pairs.push(pair1)
- if (!pairs.includes(pair2)) pairs.push(pair2)
- }
- }
-
- return { pairs, display }
-}
-
-const _loaded = loadCurrencyData()
-
-export const CONVERT_PAIRS: readonly string[] = _loaded.pairs
-export const CURRENCY_DISPLAY: Record = _loaded.display
-
-/** Parse a convert pair slug into from/to currencies: 'usd-to-ars' → { from: 'usd', to: 'ars' } */
-export function parseConvertPair(pair: string): { from: string; to: string } | null {
- const match = pair.match(/^([a-z]+)-to-([a-z]+)$/)
- if (!match) return null
- return { from: match[1], to: match[2] }
-}
diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts
index 89581e534..2b6e03d8a 100644
--- a/src/data/seo/index.ts
+++ b/src/data/seo/index.ts
@@ -1,7 +1,6 @@
export { COUNTRIES_SEO, CORRIDORS, getLocalizedSEO, getCountryName } from './corridors'
export type { CountrySEO, Corridor } from './corridors'
-export { CONVERT_PAIRS, CURRENCY_DISPLAY, parseConvertPair } from './convert'
export { COMPETITORS } from './comparisons'
export type { Competitor } from './comparisons'
From 3b47bddbd191b9c9de4fbf4ca3534974780b590e Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 23:35:15 +0000
Subject: [PATCH 22/61] fix: scroll freeze on mobile, modal bug, null guards,
small fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix LandingPageClient scroll freeze on mobile (add touchmove handler, use refs)
- Fix Card Pioneer modal showing to purchased users when card API is down
- Add contributedPoints null guard to prevent "NaN pts" rendering
- Fix typo "Card Purchare" → "Card Purchase"
- Guard PEANUT_API_KEY env var with descriptive error
- Fix React.ReactNode import in LandingPageShell
- Prettier fix on RelatedPages.tsx
---
src/app/(mobile-ui)/home/page.tsx | 10 ++-
src/app/(mobile-ui)/points/invites/page.tsx | 4 +-
src/app/(mobile-ui)/points/page.tsx | 2 +-
src/app/actions/card.ts | 5 +-
src/components/Card/CardGeoScreen.tsx | 2 +-
.../LandingPage/LandingPageClient.tsx | 85 +++++++++++++------
.../LandingPage/LandingPageShell.tsx | 3 +-
src/components/Marketing/mdx/RelatedPages.tsx | 18 ++--
8 files changed, 85 insertions(+), 44 deletions(-)
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index 62b554b1f..e4ed39a98 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -67,7 +67,11 @@ export default function Home() {
const { isFetchingUser, fetchUser } = useAuth()
const { isUserKycApproved } = useKycStatus()
- const { hasPurchased: hasCardPioneerPurchased } = useCardPioneerInfo()
+ const {
+ hasPurchased: hasCardPioneerPurchased,
+ isLoading: isCardInfoLoading,
+ error: cardInfoError,
+ } = useCardPioneerInfo()
const username = user?.user.username
const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false)
@@ -273,8 +277,10 @@ export default function Home() {
{/* Card Pioneer Modal - Show to all users who haven't purchased */}
{/* Eligibility check happens during the flow (geo screen), not here */}
- {/* Only shows if no higher-priority modals are active */}
+ {/* Only shows if no higher-priority modals are active and card info loaded successfully */}
{!underMaintenanceConfig.disableCardPioneers &&
+ !isCardInfoLoading &&
+ !cardInfoError &&
!showBalanceWarningModal &&
!showPermissionModal &&
!showKycModal &&
diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx
index 7c2e7a070..3a543e76f 100644
--- a/src/app/(mobile-ui)/points/invites/page.tsx
+++ b/src/app/(mobile-ui)/points/invites/page.tsx
@@ -41,7 +41,7 @@ const InvitesPage = () => {
const totalPointsEarned =
invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
- return sum + invite.contributedPoints
+ return sum + (invite.contributedPoints ?? 0)
}, 0) || 0
const animatedTotal = useCountUp(totalPointsEarned, {
@@ -87,7 +87,7 @@ const InvitesPage = () => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = invite.contributedPoints
+ const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 19d165a65..7af97a3ec 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -239,7 +239,7 @@ const PointsPage = () => {
const username = invite.username
const fullName = invite.fullName
const isVerified = invite.kycStatus === 'approved'
- const pointsEarned = invite.contributedPoints
+ const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
diff --git a/src/app/actions/card.ts b/src/app/actions/card.ts
index a3bd088ed..b6436ea3d 100644
--- a/src/app/actions/card.ts
+++ b/src/app/actions/card.ts
@@ -4,7 +4,10 @@ import { PEANUT_API_URL } from '@/constants/general.consts'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { getJWTCookie } from '@/utils/cookie-migration.utils'
-const API_KEY = process.env.PEANUT_API_KEY!
+const API_KEY = process.env.PEANUT_API_KEY
+if (!API_KEY) {
+ throw new Error('PEANUT_API_KEY environment variable is not set')
+}
export interface CardInfoResponse {
hasPurchased: boolean
diff --git a/src/components/Card/CardGeoScreen.tsx b/src/components/Card/CardGeoScreen.tsx
index 2f1b9de66..5f177be90 100644
--- a/src/components/Card/CardGeoScreen.tsx
+++ b/src/components/Card/CardGeoScreen.tsx
@@ -96,7 +96,7 @@ const CardGeoScreen = ({
Verification Required
-
Card Purchare requires identity verification.
+
Card Purchase requires identity verification.
diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx
index eb8b218df..ef9566466 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 { useEffect, useState, useRef, type ReactNode } from 'react'
+import { useEffect, useState, useRef, useCallback, type ReactNode } from 'react'
import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage'
import TweetCarousel from '@/components/LandingPage/TweetCarousel'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -59,6 +59,17 @@ export function LandingPageClient({
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) {
@@ -68,6 +79,25 @@ export function LandingPageClient({
}
}, [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) {
@@ -83,27 +113,31 @@ export function LandingPageClient({
const shouldFreeze =
targetRect.top <= stickyButtonBottom - 60 &&
targetRect.bottom >= stickyButtonTop - 60 &&
- !animationComplete &&
- !shrinkingPhase &&
- !hasGrown
+ !animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ !hasGrownRef.current
- if (shouldFreeze && !isScrollFrozen) {
+ if (shouldFreeze && !isScrollFrozenRef.current) {
setIsScrollFrozen(true)
frozenScrollY.current = currentScrollY
virtualScrollY.current = 0
document.body.style.overflow = 'hidden'
window.scrollTo(0, frozenScrollY.current)
- } else if (isScrollFrozen && !animationComplete) {
+ } else if (isScrollFrozenRef.current && !animationCompleteRef.current) {
window.scrollTo(0, frozenScrollY.current)
- } else if (animationComplete && !shrinkingPhase && currentScrollY > frozenScrollY.current + 50) {
+ } else if (
+ animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ currentScrollY > frozenScrollY.current + 50
+ ) {
setShrinkingPhase(true)
- } else if (shrinkingPhase) {
+ } 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 (animationComplete && currentScrollY < frozenScrollY.current - 100) {
+ } else if (animationCompleteRef.current && currentScrollY < frozenScrollY.current - 100) {
setAnimationComplete(false)
setShrinkingPhase(false)
setButtonScale(1)
@@ -113,36 +147,39 @@ export function LandingPageClient({
}
const handleWheel = (event: WheelEvent) => {
- if (isScrollFrozen && !animationComplete) {
+ if (isScrollFrozenRef.current && !animationCompleteRef.current) {
event.preventDefault()
+ handleScrollDelta(event.deltaY)
+ }
+ }
- if (event.deltaY > 0) {
- virtualScrollY.current += event.deltaY
-
- const maxVirtualScroll = 500
- const newScale = Math.min(1.5, 1 + (virtualScrollY.current / maxVirtualScroll) * 0.5)
- setButtonScale(newScale)
+ const handleTouchStart = (event: TouchEvent) => {
+ touchStartY.current = event.touches[0].clientY
+ }
- if (newScale >= 1.5) {
- setAnimationComplete(true)
- setHasGrown(true)
- document.body.style.overflow = ''
- setIsScrollFrozen(false)
- }
- }
+ 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 = ''
}
- }, [isScrollFrozen, animationComplete, shrinkingPhase, hasGrown])
+ }, [handleScrollDelta])
const marqueeProps = { visible: true, message: marqueeMessages }
diff --git a/src/components/LandingPage/LandingPageShell.tsx b/src/components/LandingPage/LandingPageShell.tsx
index a4a2ae922..94a848860 100644
--- a/src/components/LandingPage/LandingPageShell.tsx
+++ b/src/components/LandingPage/LandingPageShell.tsx
@@ -1,6 +1,7 @@
+import type { ReactNode } from 'react'
import { FooterVisibilityObserver } from '@/components/Global/FooterVisibilityObserver'
-export function LandingPageShell({ children }: { children: React.ReactNode }) {
+export function LandingPageShell({ children }: { children: ReactNode }) {
return (
{children}
diff --git a/src/components/Marketing/mdx/RelatedPages.tsx b/src/components/Marketing/mdx/RelatedPages.tsx
index 0f792afbe..7a92e1a91 100644
--- a/src/components/Marketing/mdx/RelatedPages.tsx
+++ b/src/components/Marketing/mdx/RelatedPages.tsx
@@ -10,11 +10,7 @@ interface RelatedLinkProps {
/** Individual related page link. Used as a child of
. */
export function RelatedLink({ href, children }: RelatedLinkProps) {
- return (
-
- {children}
-
- )
+ return {children}
}
interface RelatedPagesProps {
@@ -40,9 +36,10 @@ export function RelatedPages({ title = 'Related Pages', children }: RelatedPages
if (child.type === RelatedLink || child.props?.href) {
links.push({
href: child.props.href,
- text: typeof child.props.children === 'string'
- ? child.props.children
- : String(child.props.children ?? ''),
+ text:
+ typeof child.props.children === 'string'
+ ? child.props.children
+ : String(child.props.children ?? ''),
})
}
})
@@ -55,10 +52,7 @@ export function RelatedPages({ title = 'Related Pages', children }: RelatedPages
{links.map((link) => (
-
+
{link.text}
→
From 1a0c83395744de71eb2f26990b9edf92007d7fc5 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 23:37:43 +0000
Subject: [PATCH 23/61] =?UTF-8?q?fix:=20update=20content=20submodule=20?=
=?UTF-8?q?=E2=80=94=20escape=20<20=20in=20MDX=20tables?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/content | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/content b/src/content
index e2f527924..bbc9e2533 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit e2f527924bdfbb0367f4dc34ab9fce4b8d5e1a8c
+Subproject commit bbc9e25335d3754fed11eaae058fa53a9d77a128
From c9561fa34a34aa826b714ceda5a8fb5f98ed85d4 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Wed, 25 Feb 2026 23:39:52 +0000
Subject: [PATCH 24/61] ci: retrigger after pushing content submodule
From f314e48187155250a57bd74efff83c1e22ad53e9 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:02:53 +0000
Subject: [PATCH 25/61] fix: show loading spinner on sign test tx button while
user data loads
Button was disabled with no spinner while user data was being fetched
during setup, making the UI feel stuck. Now shows spinner + "Loading..."
until user is available.
---
src/components/Setup/Views/SignTestTransaction.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx
index 2b98eab6c..ad39241b1 100644
--- a/src/components/Setup/Views/SignTestTransaction.tsx
+++ b/src/components/Setup/Views/SignTestTransaction.tsx
@@ -160,13 +160,13 @@ const SignTestTransaction = () => {
}
}
- const isLoading = isSigning || isProcessing || isFetchingUser
- const isDisabled = isLoading || !user
+ const isLoading = isSigning || isProcessing || isFetchingUser || !user
+ const isDisabled = isLoading
const displayError = error || setupError
// determine button text based on state
const getButtonText = () => {
- if (isFetchingUser) return 'Loading...'
+ if (isFetchingUser || !user) return 'Loading...'
if (testTransactionCompleted && displayError) return 'Retry account setup'
return 'Sign test transaction'
}
From 2c054ffca9009872249329d152ef86e63b6701af Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:06:23 +0000
Subject: [PATCH 26/61] style: run prettier on all files
---
scripts/validate-content.ts | 20 ++++---
src/app/(mobile-ui)/points/page.tsx | 2 +-
.../[locale]/(marketing)/blog/[slug]/page.tsx | 9 ++--
src/app/[locale]/(marketing)/error.tsx | 5 +-
.../send-money-to/[country]/page.tsx | 6 ++-
src/components/Marketing/ContentPage.tsx | 2 +-
src/components/Marketing/DestinationGrid.tsx | 40 +++++++++-----
src/components/Marketing/mdx/CTA.tsx | 4 +-
src/components/Marketing/mdx/Callout.tsx | 4 +-
.../Marketing/mdx/ExchangeWidget.tsx | 21 ++++++--
src/components/Marketing/mdx/FAQ.tsx | 6 +--
src/components/Marketing/mdx/ProseStars.tsx | 53 ++++++++++++++-----
src/components/Marketing/mdx/Stars.tsx | 27 ++++++++--
src/components/Marketing/mdx/Steps.tsx | 6 +--
src/components/Marketing/mdx/components.tsx | 36 ++++++-------
src/components/Marketing/mdx/constants.ts | 3 +-
src/components/Points/CashCard.tsx | 4 +-
src/data/seo/index.ts | 1 -
src/data/seo/payment-methods.ts | 12 ++---
src/data/seo/utils.ts | 4 +-
20 files changed, 172 insertions(+), 93 deletions(-)
diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts
index 8b8831162..716ebc104 100644
--- a/scripts/validate-content.ts
+++ b/scripts/validate-content.ts
@@ -55,9 +55,7 @@ function listDirs(dir: string): string[] {
function listMdFiles(dir: string): string[] {
try {
- return fs
- .readdirSync(dir)
- .filter((f) => f.endsWith('.md') && f !== 'README.md')
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md')
} catch {
return []
}
@@ -149,7 +147,9 @@ function validateContentType(config: ContentTypeConfig): TypeCounts {
// Check slug consistency
if (fm.slug && fm.slug !== slug) {
- warn(`${config.contentDir}/${slug}/en.md: frontmatter slug '${fm.slug}' doesn't match directory name '${slug}'`)
+ warn(
+ `${config.contentDir}/${slug}/en.md: frontmatter slug '${fm.slug}' doesn't match directory name '${slug}'`
+ )
}
// Check published status
@@ -165,12 +165,16 @@ function validateContentType(config: ContentTypeConfig): TypeCounts {
if (mdFile === 'en.md') continue
const localeFm = readFrontmatter(path.join(slugDir, mdFile))
if (localeFm && localeFm.slug && localeFm.slug !== slug) {
- warn(`${config.contentDir}/${slug}/${mdFile}: frontmatter slug '${localeFm.slug}' doesn't match directory '${slug}'`)
+ warn(
+ `${config.contentDir}/${slug}/${mdFile}: frontmatter slug '${localeFm.slug}' doesn't match directory '${slug}'`
+ )
}
if (localeFm && localeFm.lang) {
const expectedLang = mdFile.replace('.md', '')
if (localeFm.lang !== expectedLang) {
- warn(`${config.contentDir}/${slug}/${mdFile}: frontmatter lang '${localeFm.lang}' doesn't match filename '${expectedLang}'`)
+ warn(
+ `${config.contentDir}/${slug}/${mdFile}: frontmatter lang '${localeFm.lang}' doesn't match filename '${expectedLang}'`
+ )
}
}
}
@@ -179,7 +183,9 @@ function validateContentType(config: ContentTypeConfig): TypeCounts {
if (config.entityDir) {
const entityPath = path.join(ROOT, 'input/data', config.entityDir, `${slug}.md`)
if (!fs.existsSync(entityPath)) {
- warn(`${config.contentDir}/${slug}: no matching entity data at input/data/${config.entityDir}/${slug}.md`)
+ warn(
+ `${config.contentDir}/${slug}: no matching entity data at input/data/${config.entityDir}/${slug}.md`
+ )
} else if (config.entityRequiredFields) {
const entityFm = readFrontmatter(entityPath)
if (entityFm) {
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 7af97a3ec..7fe63125a 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -208,7 +208,7 @@ const PointsPage = () => {
invited you.{' '}
>
)}
- You earn rewards whenever friends you invite use Peanut!
+ You earn rewards whenever friends your friends use Peanut!
>
)}
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
index dedbdc583..387c89979 100644
--- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -103,17 +103,20 @@ export default async function BlogPostPageLocalized({ params }: PageProps) {
{faqSchema && }
-
+
{breadcrumbs.map((crumb, i) => (
{i > 0 && / }
{i < breadcrumbs.length - 1 ? (
-
+
{crumb.name}
) : (
- {crumb.name}
+ {crumb.name}
)}
))}
diff --git a/src/app/[locale]/(marketing)/error.tsx b/src/app/[locale]/(marketing)/error.tsx
index 99086a54c..938173d6a 100644
--- a/src/app/[locale]/(marketing)/error.tsx
+++ b/src/app/[locale]/(marketing)/error.tsx
@@ -21,7 +21,10 @@ export default function MarketingError({ error, reset }: { error: Error & { dige
>
Try again
-
+
Go home
diff --git a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
index 618f53673..3e09b2f79 100644
--- a/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -26,7 +26,11 @@ export async function generateMetadata({ params }: PageProps): Promise
const seo = COUNTRIES_SEO[country]
if (!seo) return {}
- const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>('send-to', country, locale)
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'send-to',
+ country,
+ locale
+ )
if (!mdxContent || mdxContent.frontmatter.published === false) return {}
return {
diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx
index f64c8ec05..61981a1e8 100644
--- a/src/components/Marketing/ContentPage.tsx
+++ b/src/components/Marketing/ContentPage.tsx
@@ -47,7 +47,7 @@ export function ContentPage({ children, breadcrumbs }: ContentPageProps) {
{crumb.name}
) : (
- {crumb.name}
+ {crumb.name}
)}
))}
diff --git a/src/components/Marketing/DestinationGrid.tsx b/src/components/Marketing/DestinationGrid.tsx
index aef1b3166..3f84b6e16 100644
--- a/src/components/Marketing/DestinationGrid.tsx
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -7,14 +7,33 @@ import { CARD_HOVER } from '@/components/Marketing/mdx/constants'
import type { Locale } from '@/i18n/types'
const SLUG_TO_ISO2: Record = {
- argentina: 'ar', australia: 'au', brazil: 'br', canada: 'ca',
- colombia: 'co', 'costa-rica': 'cr', indonesia: 'id', japan: 'jp',
- kenya: 'ke', malaysia: 'my', mexico: 'mx', pakistan: 'pk',
- peru: 'pe', philippines: 'ph', poland: 'pl', portugal: 'pt',
- singapore: 'sg', 'south-africa': 'za', spain: 'es', sweden: 'se',
- tanzania: 'tz', thailand: 'th', turkey: 'tr',
- 'united-arab-emirates': 'ae', 'united-kingdom': 'gb',
- 'united-states': 'us', vietnam: 'vn',
+ argentina: 'ar',
+ australia: 'au',
+ brazil: 'br',
+ canada: 'ca',
+ colombia: 'co',
+ 'costa-rica': 'cr',
+ indonesia: 'id',
+ japan: 'jp',
+ kenya: 'ke',
+ malaysia: 'my',
+ mexico: 'mx',
+ pakistan: 'pk',
+ peru: 'pe',
+ philippines: 'ph',
+ poland: 'pl',
+ portugal: 'pt',
+ singapore: 'sg',
+ 'south-africa': 'za',
+ spain: 'es',
+ sweden: 'se',
+ tanzania: 'tz',
+ thailand: 'th',
+ turkey: 'tr',
+ 'united-arab-emirates': 'ae',
+ 'united-kingdom': 'gb',
+ 'united-states': 'us',
+ vietnam: 'vn',
}
interface DestinationGridProps {
@@ -43,10 +62,7 @@ export function DestinationGrid({ countries, exclude, title = 'Send money to', l
return (
-
+
{flagCode && (
- {subtitle && (
- {subtitle}
- )}
+ {subtitle && {subtitle}
}
diff --git a/src/components/Marketing/mdx/Callout.tsx b/src/components/Marketing/mdx/Callout.tsx
index 98062d22c..79b9c80db 100644
--- a/src/components/Marketing/mdx/Callout.tsx
+++ b/src/components/Marketing/mdx/Callout.tsx
@@ -20,9 +20,7 @@ export function Callout({ type = 'info', children }: CalloutProps) {
return (
-
- {style.label}
-
+ {style.label}
{children}
diff --git a/src/components/Marketing/mdx/ExchangeWidget.tsx b/src/components/Marketing/mdx/ExchangeWidget.tsx
index ef330afbf..5f71dd49a 100644
--- a/src/components/Marketing/mdx/ExchangeWidget.tsx
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -36,8 +36,20 @@ function ExchangeWidgetInner({ destinationCurrency, sourceCurrency = 'USD' }: Ex
+
diff --git a/src/components/Marketing/mdx/FAQ.tsx b/src/components/Marketing/mdx/FAQ.tsx
index e9b1a11b7..24cf62454 100644
--- a/src/components/Marketing/mdx/FAQ.tsx
+++ b/src/components/Marketing/mdx/FAQ.tsx
@@ -12,11 +12,7 @@ interface FAQItemProps {
export function FAQItem({ question, children }: FAQItemProps) {
// FAQItem doesn't render on its own — FAQ collects these via children.
// This exists for type safety and readability in MDX content.
- return (
-
- {children}
-
- )
+ return {children}
}
interface FAQProps {
diff --git a/src/components/Marketing/mdx/ProseStars.tsx b/src/components/Marketing/mdx/ProseStars.tsx
index 035e3838f..4c50edf43 100644
--- a/src/components/Marketing/mdx/ProseStars.tsx
+++ b/src/components/Marketing/mdx/ProseStars.tsx
@@ -16,17 +16,52 @@ interface StarPlacement {
*/
const placements: StarPlacement[][] = [
[
- { className: 'absolute -right-4 -top-2 md:right-8', width: 40, height: 40, delay: '0.15s', x: '5px', rotate: '22deg' },
+ {
+ className: 'absolute -right-4 -top-2 md:right-8',
+ width: 40,
+ height: 40,
+ delay: '0.15s',
+ x: '5px',
+ rotate: '22deg',
+ },
],
[
- { className: 'absolute -left-4 top-0 md:left-8', width: 35, height: 35, delay: '0.25s', x: '-5px', rotate: '-15deg' },
+ {
+ className: 'absolute -left-4 top-0 md:left-8',
+ width: 35,
+ height: 35,
+ delay: '0.25s',
+ x: '-5px',
+ rotate: '-15deg',
+ },
],
[
- { className: 'absolute -right-2 -top-4 md:right-16', width: 32, height: 32, delay: '0.1s', x: '3px', rotate: '45deg' },
- { className: 'absolute -left-6 top-4 md:left-4 hidden md:block', width: 28, height: 28, delay: '0.5s', x: '-4px', rotate: '-10deg' },
+ {
+ className: 'absolute -right-2 -top-4 md:right-16',
+ width: 32,
+ height: 32,
+ delay: '0.1s',
+ x: '3px',
+ rotate: '45deg',
+ },
+ {
+ className: 'absolute -left-6 top-4 md:left-4 hidden md:block',
+ width: 28,
+ height: 28,
+ delay: '0.5s',
+ x: '-4px',
+ rotate: '-10deg',
+ },
],
[
- { className: 'absolute -left-2 -top-2 md:left-12', width: 38, height: 38, delay: '0.2s', x: '-3px', rotate: '12deg' },
+ {
+ className: 'absolute -left-2 -top-2 md:left-12',
+ width: 38,
+ height: 38,
+ delay: '0.2s',
+ x: '-3px',
+ rotate: '12deg',
+ },
],
]
@@ -40,13 +75,7 @@ export function ProseStars() {
return (
<>
{set.map((star, i) => (
-
+
))}
diff --git a/src/components/Marketing/mdx/Stars.tsx b/src/components/Marketing/mdx/Stars.tsx
index e7903dc87..e031486a6 100644
--- a/src/components/Marketing/mdx/Stars.tsx
+++ b/src/components/Marketing/mdx/Stars.tsx
@@ -11,9 +11,30 @@ interface StarConfig {
}
const defaultStars: StarConfig[] = [
- { className: 'absolute right-6 top-6 md:right-12 md:top-10', width: 40, height: 40, delay: '0.2s', x: '5px', rotate: '22deg' },
- { className: 'absolute left-8 bottom-8 md:left-16', width: 35, height: 35, delay: '0.5s', x: '-5px', rotate: '-15deg' },
- { className: 'absolute right-1/4 bottom-12 hidden md:block', width: 30, height: 30, delay: '0.8s', x: '3px', rotate: '45deg' },
+ {
+ className: 'absolute right-6 top-6 md:right-12 md:top-10',
+ width: 40,
+ height: 40,
+ delay: '0.2s',
+ x: '5px',
+ rotate: '22deg',
+ },
+ {
+ className: 'absolute left-8 bottom-8 md:left-16',
+ width: 35,
+ height: 35,
+ delay: '0.5s',
+ x: '-5px',
+ rotate: '-15deg',
+ },
+ {
+ className: 'absolute right-1/4 bottom-12 hidden md:block',
+ width: 30,
+ height: 30,
+ delay: '0.8s',
+ x: '3px',
+ rotate: '45deg',
+ },
]
/** Decorative animated stars. Sprinkle on sections for visual interest. */
diff --git a/src/components/Marketing/mdx/Steps.tsx b/src/components/Marketing/mdx/Steps.tsx
index 190111f76..ec2797897 100644
--- a/src/components/Marketing/mdx/Steps.tsx
+++ b/src/components/Marketing/mdx/Steps.tsx
@@ -11,11 +11,7 @@ interface StepProps {
/** Individual step. Used as a child of . */
export function Step({ title, children }: StepProps) {
- return (
-
- {children}
-
- )
+ return {children}
}
interface StepsProps {
diff --git a/src/components/Marketing/mdx/components.tsx b/src/components/Marketing/mdx/components.tsx
index 4caa4196c..6c39904dd 100644
--- a/src/components/Marketing/mdx/components.tsx
+++ b/src/components/Marketing/mdx/components.tsx
@@ -40,7 +40,7 @@ export const mdxComponents: Record> = {
// Element overrides — prose styling
h1: (props: React.HTMLAttributes) => (
),
@@ -48,44 +48,37 @@ export const mdxComponents: Record> = {
),
h3: (props: React.HTMLAttributes) => (
),
p: (props: React.HTMLAttributes) => (
-
+
),
a: ({ href = '', ...props }: React.AnchorHTMLAttributes) => (
-
- ),
- ul: (props: React.HTMLAttributes) => (
-
),
+ ul: (props: React.HTMLAttributes) => (
+
+ ),
ol: (props: React.HTMLAttributes) => (
-
+
),
li: (props: React.HTMLAttributes) => (
),
- strong: (props: React.HTMLAttributes) => (
-
- ),
+ strong: (props: React.HTMLAttributes) => ,
table: (props: React.HTMLAttributes) => (
@@ -94,7 +87,10 @@ export const mdxComponents: Record> = {
),
th: (props: React.HTMLAttributes
) => (
-
+
),
td: (props: React.HTMLAttributes) => (
diff --git a/src/components/Marketing/mdx/constants.ts b/src/components/Marketing/mdx/constants.ts
index b33fbbfe4..da9a9173b 100644
--- a/src/components/Marketing/mdx/constants.ts
+++ b/src/components/Marketing/mdx/constants.ts
@@ -4,4 +4,5 @@ export const PROSE_WIDTH = 'max-w-[640px]'
/** Standard hover/active classes for interactive cards with Bruddle shadow.
* Hover: card lifts up-left, shadow grows to compensate (appears stationary).
* Active: card presses into shadow. */
-export const CARD_HOVER = 'transition-all duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[6px_6px_0_#000] active:translate-x-[3px] active:translate-y-[4px] active:shadow-none'
+export const CARD_HOVER =
+ 'transition-all duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[6px_6px_0_#000] active:translate-x-[3px] active:translate-y-[4px] active:shadow-none'
diff --git a/src/components/Points/CashCard.tsx b/src/components/Points/CashCard.tsx
index 388b7dfa5..6518eeb33 100644
--- a/src/components/Points/CashCard.tsx
+++ b/src/components/Points/CashCard.tsx
@@ -16,7 +16,7 @@ export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) =>
Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
@@ -24,7 +24,7 @@ export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) =>
{hasCashbackLeft ? (
- You have more cashback left! Make a payment to receive it.
+ You have more cashback left! Make a payment to claim it.
) : (
Invite friends to unlock more cashback.
)}
diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts
index 2b6e03d8a..e25b4fbe9 100644
--- a/src/data/seo/index.ts
+++ b/src/data/seo/index.ts
@@ -1,7 +1,6 @@
export { COUNTRIES_SEO, CORRIDORS, getLocalizedSEO, getCountryName } from './corridors'
export type { CountrySEO, Corridor } from './corridors'
-
export { COMPETITORS } from './comparisons'
export type { Competitor } from './comparisons'
diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts
index 832cb488c..13c2dad2c 100644
--- a/src/data/seo/payment-methods.ts
+++ b/src/data/seo/payment-methods.ts
@@ -68,14 +68,10 @@ function loadPaymentMethods(): Record {
name: fm.name,
countries: fm.countries ?? [],
description: content.body,
- steps: extractSteps(
- content.body,
- /Merchant QR Payments|How to Pay|Steps|How It Works/,
- (line) => {
- const match = line.match(/^\d+\.\s+\*\*(.+?)\*\*/)
- return match ? match[1].trim() : null
- }
- ),
+ steps: extractSteps(content.body, /Merchant QR Payments|How to Pay|Steps|How It Works/, (line) => {
+ const match = line.match(/^\d+\.\s+\*\*(.+?)\*\*/)
+ return match ? match[1].trim() : null
+ }),
faqs: extractFaqs(content.body),
}
}
diff --git a/src/data/seo/utils.ts b/src/data/seo/utils.ts
index 02b681a28..1a8989464 100644
--- a/src/data/seo/utils.ts
+++ b/src/data/seo/utils.ts
@@ -57,7 +57,9 @@ export function extractSteps(
lineParser?: (line: string) => string | null
): string[] {
const steps: string[] = []
- const section = body.match(new RegExp(`##?#?\\s+(?:${headingPattern.source})\\s*\\n([\\s\\S]*?)(?=\\n##?#?\\s|$)`, 'i'))
+ const section = body.match(
+ new RegExp(`##?#?\\s+(?:${headingPattern.source})\\s*\\n([\\s\\S]*?)(?=\\n##?#?\\s|$)`, 'i')
+ )
if (!section) return steps
const defaultParser = (line: string): string | null => {
From c536f838e9a89215b39e4c77b85c67592923cdb7 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:10:05 +0000
Subject: [PATCH 27/61] docs: add CI and tech stack badges to README
---
README.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/README.md b/README.md
index ee2299790..390666e19 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,15 @@
+# Peanut UI
+
+[](https://github.com/peanutprotocol/peanut-ui/actions/workflows/tests.yml)
+[](https://github.com/peanutprotocol/peanut-ui/actions/workflows/prettier.yml)
+[](https://github.com/peanutprotocol/peanut-ui/actions/workflows/codeql.yml)
+[](https://nextjs.org/)
+[](https://react.dev/)
+[](https://www.typescriptlang.org/)
+[](https://tailwindcss.com/)
+[](https://peanut.me)
+[](https://peanut.me)
+
Live at: [peanut.me](https://peanut.me) | [staging.peanut.me](https://staging.peanut.me)
## Getting Started
From d509eb735782eeb65568d04b86ffe18704a980eb Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:20:30 +0000
Subject: [PATCH 28/61] fix: prevent withdraw success screen from redirecting
away from /home
Race condition: after clicking "Back to home", resetting withdraw state
(amountToWithdraw = '') triggered useEffect guards that called
router.replace('/withdraw'), overriding the router.push('/home').
- Crypto: guard early redirect with currentView !== 'STATUS'
- Bank: guard useEffect with view === 'SUCCESS', reorder NavHeader
onPrev to navigate before resetting state, add onComplete prop
---
.../withdraw/[country]/bank/page.tsx | 17 ++++++++++++++---
src/app/(mobile-ui)/withdraw/crypto/page.tsx | 4 +++-
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 166f45d07..295994292 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -77,6 +77,9 @@ export default function WithdrawBankPage() {
)
useEffect(() => {
+ // Skip redirects when on success view — clearing state during navigation
+ // would race with router.push('/home') and redirect back to /withdraw
+ if (view === 'SUCCESS') return
if (!amountToWithdraw) {
// If no amount, go back to main page
router.replace('/withdraw')
@@ -84,7 +87,7 @@ export default function WithdrawBankPage() {
// If amount is set but no bank account, go to country method selection
router.replace(`/withdraw/${country}`)
}
- }, [bankAccount, router, amountToWithdraw, country])
+ }, [bankAccount, router, amountToWithdraw, country, view])
const destinationDetails = (account: Account) => {
let countryId: string
@@ -264,11 +267,15 @@ export default function WithdrawBankPage() {
title={fromSendFlow ? 'Send' : 'Withdraw'}
icon={view === 'SUCCESS' ? 'cancel' : undefined}
onPrev={() => {
- setAmountToWithdraw('')
- setSelectedMethod(null)
if (view === 'SUCCESS') {
+ // Navigate first, then reset — otherwise clearing amountToWithdraw
+ // triggers the useEffect redirect to /withdraw, overriding /home
router.push('/home')
+ setAmountToWithdraw('')
+ setSelectedMethod(null)
} else {
+ setAmountToWithdraw('')
+ setSelectedMethod(null)
router.back()
}
}}
@@ -374,6 +381,10 @@ export default function WithdrawBankPage() {
currencyAmount={`$${amountToWithdraw}`}
message={bankAccount ? shortenStringLong(bankAccount.identifier.toUpperCase()) : ''}
points={pointsData?.estimatedPoints}
+ onComplete={() => {
+ setAmountToWithdraw('')
+ setSelectedMethod(null)
+ }}
/>
)}
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
index ad58fbc39..d97db5f86 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -382,8 +382,10 @@ export default function WithdrawCryptoPage() {
return 0
}, [xChainRoute])
- if (!amountToWithdraw) {
+ if (!amountToWithdraw && currentView !== 'STATUS') {
// Redirect to main withdraw page for amount input
+ // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw,
+ // which would override the router.push('/home') in handleDone
router.push('/withdraw')
return
}
From 04216156da17614c8f252d6e0719b64aa19a00a8 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:26:51 +0000
Subject: [PATCH 29/61] fix: prevent infinite loading from stale SW-cached auth
responses
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Exclude /api/peanut/user/* from SW cache (NetworkOnly) — stale 401
responses were being served from cache, causing infinite spinner loops
that only worked in incognito (no SW)
- Stop refreshing JWT cookie maxAge on every read in getJWTCookie() —
this was keeping expired JWTs alive for 30 more days on each page load
- Add maxAge: 30 days consistently to login, register, and get-jwt-token
routes so the cookie is persistent from the start without needing
refresh-on-read
---
src/app/api/peanut/user/get-jwt-token/route.ts | 1 +
src/app/api/peanut/user/login-user/route.ts | 1 +
src/app/api/peanut/user/register-user/route.ts | 1 +
src/app/sw.ts | 12 ++++++++++--
src/utils/cookie-migration.utils.ts | 18 +-----------------
5 files changed, 14 insertions(+), 19 deletions(-)
diff --git a/src/app/api/peanut/user/get-jwt-token/route.ts b/src/app/api/peanut/user/get-jwt-token/route.ts
index 158178ea0..4f8a1c187 100644
--- a/src/app/api/peanut/user/get-jwt-token/route.ts
+++ b/src/app/api/peanut/user/get-jwt-token/route.ts
@@ -43,6 +43,7 @@ export async function POST(request: NextRequest) {
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
})
return new NextResponse(JSON.stringify(data), {
diff --git a/src/app/api/peanut/user/login-user/route.ts b/src/app/api/peanut/user/login-user/route.ts
index dcffd05c5..b81446403 100644
--- a/src/app/api/peanut/user/login-user/route.ts
+++ b/src/app/api/peanut/user/login-user/route.ts
@@ -40,6 +40,7 @@ export async function POST(request: NextRequest) {
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
})
return new NextResponse(JSON.stringify(data), {
diff --git a/src/app/api/peanut/user/register-user/route.ts b/src/app/api/peanut/user/register-user/route.ts
index 87da22491..0b07fac51 100644
--- a/src/app/api/peanut/user/register-user/route.ts
+++ b/src/app/api/peanut/user/register-user/route.ts
@@ -46,6 +46,7 @@ export async function POST(request: NextRequest) {
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
})
return new NextResponse(JSON.stringify(data), {
status: 200,
diff --git a/src/app/sw.ts b/src/app/sw.ts
index 22a34cf60..910994044 100644
--- a/src/app/sw.ts
+++ b/src/app/sw.ts
@@ -1,6 +1,6 @@
import { defaultCache } from '@serwist/next/worker'
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
-import { Serwist } from 'serwist'
+import { NetworkOnly, Serwist } from 'serwist'
// This declares the value of `injectionPoint` to TypeScript.
// `injectionPoint` is the string that will be replaced by the
@@ -20,7 +20,15 @@ const serwist = new Serwist({
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
- runtimeCaching: defaultCache,
+ runtimeCaching: [
+ // Never cache auth/user API responses — stale 401s cause infinite loading loops
+ {
+ matcher: ({ sameOrigin, url: { pathname } }: { sameOrigin: boolean; url: URL }) =>
+ sameOrigin && pathname.startsWith('/api/peanut/user/'),
+ handler: new NetworkOnly(),
+ },
+ ...defaultCache,
+ ],
disableDevLogs: false,
})
diff --git a/src/utils/cookie-migration.utils.ts b/src/utils/cookie-migration.utils.ts
index 46a810139..dec62cf6b 100644
--- a/src/utils/cookie-migration.utils.ts
+++ b/src/utils/cookie-migration.utils.ts
@@ -9,21 +9,5 @@ import { cookies } from 'next/headers'
export async function getJWTCookie() {
const cookieStore = await cookies()
- const cookie = cookieStore.get('jwt-token')
-
- if (cookie?.value) {
- try {
- cookieStore.set('jwt-token', cookie.value, {
- httpOnly: false, // Required for client-side services to read token (see TODO above)
- secure: process.env.NODE_ENV === 'production',
- path: '/',
- sameSite: 'lax',
- maxAge: 30 * 24 * 60 * 60,
- })
- } catch (error) {
- console.warn('Failed to refresh JWT cookie:', error)
- }
- }
-
- return cookie
+ return cookieStore.get('jwt-token')
}
From 4e48a4c2a9f46e3aed635b22dea0f10b96c3526e Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:33:08 +0000
Subject: [PATCH 30/61] fix: refresh JWT cookie maxAge only on successful auth
Move the cookie maxAge refresh from getJWTCookie() (every read) to
get-user-from-cookie route (only on 200). Active users stay logged in
indefinitely; expired JWTs naturally expire without being refreshed.
---
.../peanut/user/get-user-from-cookie/route.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/src/app/api/peanut/user/get-user-from-cookie/route.ts b/src/app/api/peanut/user/get-user-from-cookie/route.ts
index 5e4814ccc..29e7b172c 100644
--- a/src/app/api/peanut/user/get-user-from-cookie/route.ts
+++ b/src/app/api/peanut/user/get-user-from-cookie/route.ts
@@ -1,6 +1,7 @@
import { PEANUT_API_URL } from '@/constants/general.consts'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { NextRequest, NextResponse } from 'next/server'
+import { cookies } from 'next/headers'
import { getJWTCookie } from '@/utils/cookie-migration.utils'
export async function GET(_request: NextRequest) {
@@ -39,6 +40,23 @@ export async function GET(_request: NextRequest) {
}
const data = await response.json()
+
+ // Refresh cookie expiry only when backend confirms JWT is valid.
+ // This keeps active users logged in indefinitely without refreshing
+ // expired JWTs (which caused infinite loading loops).
+ try {
+ const cookieStore = await cookies()
+ cookieStore.set('jwt-token', token.value, {
+ httpOnly: false,
+ secure: process.env.NODE_ENV === 'production',
+ path: '/',
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ })
+ } catch {
+ // cookie refresh is best-effort
+ }
+
return new NextResponse(JSON.stringify(data), {
status: 200,
headers: {
From 33a6fe0e9f67995d445f52541e529354c5a60ed0 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:36:10 +0000
Subject: [PATCH 31/61] fix: typo "friends your friends" and document blog
dynamicParams gotcha
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix user-facing typo on points page: "friends your friends" → "your friends"
- Add TODO comment on blog/[slug] page: when blog content is added,
the generateStaticParams production guard + dynamicParams=false would
404 all blog pages. No effect currently (0 blog posts exist).
---
src/app/(mobile-ui)/points/page.tsx | 2 +-
src/app/[locale]/(marketing)/blog/[slug]/page.tsx | 5 +++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 7fe63125a..7690531a5 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -208,7 +208,7 @@ const PointsPage = () => {
invited you.{' '}
>
)}
- You earn rewards whenever friends your friends use Peanut!
+ You earn rewards whenever your friends use Peanut!
>
)}
diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
index 387c89979..418455970 100644
--- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -22,6 +22,11 @@ export async function generateStaticParams() {
return posts.map((post) => ({ locale, slug: post.slug }))
})
}
+// TODO: when blog content is added to src/content/blog/, either remove the
+// production guard in generateStaticParams above, or set dynamicParams = true.
+// Currently no blog posts exist so this has no effect, but with content present
+// the combination of returning [] in prod + dynamicParams = false would 404 all
+// blog pages.
export const dynamicParams = false
export async function generateMetadata({ params }: PageProps): Promise {
From 3da333025291ae3fa329a59554748b668c04fc43 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:37:52 +0000
Subject: [PATCH 32/61] ci: add SUBMODULE_TOKEN to preview workflow for private
content submodule
---
.github/workflows/preview.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml
index 13be38b27..41f3a6f1c 100644
--- a/.github/workflows/preview.yaml
+++ b/.github/workflows/preview.yaml
@@ -12,6 +12,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: true
+ token: ${{ secrets.SUBMODULE_TOKEN }}
- uses: pnpm/action-setup@v4
with:
version: 9
From 5f2745b38970c71b0ae57e15ebfe5f60be793410 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:40:42 +0000
Subject: [PATCH 33/61] test: add unit tests for get-user-from-cookie auth
cookie lifecycle
---
.../__tests__/route.test.ts | 124 ++++++++++++++++++
1 file changed, 124 insertions(+)
create mode 100644 src/app/api/peanut/user/get-user-from-cookie/__tests__/route.test.ts
diff --git a/src/app/api/peanut/user/get-user-from-cookie/__tests__/route.test.ts b/src/app/api/peanut/user/get-user-from-cookie/__tests__/route.test.ts
new file mode 100644
index 000000000..1747a237a
--- /dev/null
+++ b/src/app/api/peanut/user/get-user-from-cookie/__tests__/route.test.ts
@@ -0,0 +1,124 @@
+/**
+ * @jest-environment node
+ */
+import { NextRequest } from 'next/server'
+
+// --- Mocks ---
+
+const mockCookieGet = jest.fn()
+const mockCookieSet = jest.fn()
+jest.mock('next/headers', () => ({
+ cookies: jest.fn(async () => ({
+ get: mockCookieGet,
+ set: mockCookieSet,
+ })),
+}))
+
+// Mock getJWTCookie to use our mock cookie store
+jest.mock('@/utils/cookie-migration.utils', () => ({
+ getJWTCookie: jest.fn(async () => mockCookieGet('jwt-token')),
+}))
+
+const mockFetch = jest.fn()
+jest.mock('@/utils/sentry.utils', () => ({
+ fetchWithSentry: (...args: unknown[]) => mockFetch(...args),
+}))
+
+jest.mock('@/constants/general.consts', () => ({
+ PEANUT_API_URL: 'https://api.test',
+}))
+
+// --- Tests ---
+
+import { GET } from '../route'
+
+function makeRequest() {
+ return new NextRequest('http://localhost/api/peanut/user/get-user-from-cookie')
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ process.env.PEANUT_API_KEY = 'test-api-key'
+})
+
+describe('GET /api/peanut/user/get-user-from-cookie', () => {
+ it('returns 400 when no JWT cookie exists', async () => {
+ mockCookieGet.mockReturnValue(undefined)
+
+ const res = await GET(makeRequest())
+
+ expect(res.status).toBe(400)
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+
+ it('returns user data and refreshes cookie on successful auth (200)', async () => {
+ mockCookieGet.mockReturnValue({ name: 'jwt-token', value: 'valid-token' })
+ mockFetch.mockResolvedValue({
+ status: 200,
+ json: async () => ({ user: { userId: '123', email: 'test@test.com' } }),
+ })
+
+ const res = await GET(makeRequest())
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.user.userId).toBe('123')
+
+ // Cookie should be refreshed with 30-day maxAge
+ expect(mockCookieSet).toHaveBeenCalledWith('jwt-token', 'valid-token', {
+ httpOnly: false,
+ secure: false, // NODE_ENV !== 'production' in tests
+ path: '/',
+ sameSite: 'lax',
+ maxAge: 30 * 24 * 60 * 60,
+ })
+ })
+
+ it('clears cookie and sets Clear-Site-Data on 401 (expired JWT)', async () => {
+ mockCookieGet.mockReturnValue({ name: 'jwt-token', value: 'expired-token' })
+ mockFetch.mockResolvedValue({
+ status: 401,
+ })
+
+ const res = await GET(makeRequest())
+
+ expect(res.status).toBe(401)
+
+ // Cookie should be cleared
+ expect(res.headers.get('Set-Cookie')).toBe('jwt-token=; Path=/; Max-Age=0; SameSite=Lax')
+ expect(res.headers.get('Clear-Site-Data')).toBe('"cache"')
+
+ // Cookie should NOT be refreshed
+ expect(mockCookieSet).not.toHaveBeenCalled()
+ })
+
+ it('does NOT refresh cookie on non-200 responses', async () => {
+ mockCookieGet.mockReturnValue({ name: 'jwt-token', value: 'some-token' })
+ mockFetch.mockResolvedValue({
+ status: 500,
+ })
+
+ const res = await GET(makeRequest())
+
+ expect(res.status).toBe(500)
+ expect(mockCookieSet).not.toHaveBeenCalled()
+ })
+
+ it('still returns 200 if cookie refresh fails', async () => {
+ mockCookieGet.mockReturnValue({ name: 'jwt-token', value: 'valid-token' })
+ mockFetch.mockResolvedValue({
+ status: 200,
+ json: async () => ({ user: { userId: '123' } }),
+ })
+ mockCookieSet.mockImplementation(() => {
+ throw new Error('cookies() can only be used in server components')
+ })
+
+ const res = await GET(makeRequest())
+
+ // Should still succeed — cookie refresh is best-effort
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.user.userId).toBe('123')
+ })
+})
From ce45dd094cb263c7270d214200d9eea212a26db6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:45:18 +0000
Subject: [PATCH 34/61] chore: remove one-shot migration script, stale TODO
doc, deprecated asset
---
docs/TODO-SEO.md | 72 --
scripts/migrate-exchange-widget.py | 203 ------
.../cards/DEPRECATED_Cart Gradient 5.svg | 625 ------------------
3 files changed, 900 deletions(-)
delete mode 100644 docs/TODO-SEO.md
delete mode 100644 scripts/migrate-exchange-widget.py
delete mode 100644 src/assets/cards/DEPRECATED_Cart Gradient 5.svg
diff --git a/docs/TODO-SEO.md b/docs/TODO-SEO.md
deleted file mode 100644
index d99378cc2..000000000
--- a/docs/TODO-SEO.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# SEO TODOs
-
-## Content Tasks (Marketer)
-
-### Convert Pages — Editorial Content
-Each `/convert/{pair}` page needs 300+ words of unique editorial content to avoid thin content flags.
-Include: currency background, exchange rate trends, tips for getting best rates, common use cases.
-Reference: Wise convert pages for structure and depth.
-
-### Blog — Seed Posts
-Write 10-15 seed posts targeting hub topics:
-- One per major country (Argentina, Brazil, Mexico)
-- Cross-cutting guides ("cheapest way to send money internationally", "crypto vs wire transfer")
-- Use `peanut-content/reference/agent-workflow.md` as the generation playbook.
-
-### Payment Method Pages
-Expand `/pay-with/{method}` content for each payment method.
-Current placeholder content is thin. Each needs 500+ words.
-
-### Team Page
-Fill in real team member data in `src/data/team.ts`:
-- Real names, roles, bios
-- Headshots (400x400px WebP in /public/team/)
-- Social links (LinkedIn especially — builds E-E-A-T)
-
-## Engineering Tasks
-
-### Content Submodule Migration (BLOCKING — do first)
-CI build fails because `src/content` is a local symlink. Once `0xkkonrad/peanut` PR #1
-(`reorganize-for-peanut-ui`) merges to `master`, run these steps:
-```bash
-# 1. Remove the symlink from git
-git rm --cached src/content
-rm src/content
-
-# 2. Add as submodule (same pattern as src/assets/animations)
-git submodule add https://github.com/0xkkonrad/peanut.git src/content
-
-# 3. Verify structure matches what code expects
-ls src/content/countries/argentina/ # should have data.yaml + en.md
-
-# 4. Commit and push
-git add .gitmodules src/content
-git commit -m "chore: add peanut-content as git submodule"
-git push
-```
-CI already has `submodules: true` in `.github/workflows/tests.yml`, so it will fetch automatically.
-
-### Scroll-Depth CTAs
-TODO: Add mid-content CTA cards on long editorial pages (blog posts, corridor pages).
-Trigger: Insert CTA after 50% scroll or after the 3rd section.
-Design: Use Bruddle Card with `variant="purple"` Button.
-Purpose: Increase conversion from organic traffic on content-heavy pages.
-
-### Sitemap Submission on Deploy
-Add to Vercel build hook or post-deploy script:
-```bash
-curl "https://www.google.com/ping?sitemap=https://peanut.me/sitemap.xml"
-curl "https://www.bing.com/ping?sitemap=https://peanut.me/sitemap.xml"
-```
-Or use Google Search Console API for programmatic submission.
-
-### Help Center (Crisp)
-DNS only — add CNAME record:
-```
-help.peanut.me → [crisp-kb-domain]
-```
-Configure in Crisp Dashboard > Settings > Knowledge Base > Custom Domain.
-
-### Content Generation CI
-Set up a script/CI job that runs Konrad's agent workflow to generate/update content files.
-See `peanut-content/reference/agent-workflow.md`.
diff --git a/scripts/migrate-exchange-widget.py b/scripts/migrate-exchange-widget.py
deleted file mode 100644
index 30d21bfb4..000000000
--- a/scripts/migrate-exchange-widget.py
+++ /dev/null
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3
-"""
-Bulk-migrate content files:
-1. Strip `currency="..."` from tags (all files)
-2. Insert inline for send-to and corridor pages
-"""
-
-import re
-import os
-from pathlib import Path
-
-CONTENT_DIR = Path(__file__).parent.parent / "src" / "content" / "content"
-
-# Map origin country slug to ISO 4217 currency code (for corridor sourceCurrency)
-ORIGIN_CURRENCY = {
- "italy": "EUR",
- "france": "EUR",
- "spain": "EUR",
- "germany": "EUR",
- "portugal": "EUR",
- "united-kingdom": "GBP",
- "united-states": "USD",
- "brazil": "BRL",
- "argentina": "ARS",
- "australia": "AUD",
- "canada": "CAD",
- "japan": "JPY",
- "mexico": "MXN",
- "colombia": "COP",
- "singapore": "SGD",
-}
-
-def extract_currency(content: str) -> str | None:
- """Extract currency code from Hero tag."""
- m = re.search(r'currency="([A-Z]{3})"', content)
- return m.group(1) if m else None
-
-
-def strip_currency_from_hero(content: str) -> str:
- """Remove currency="..." prop from tag."""
- return re.sub(r'\s+currency="[A-Z]{3}"', '', content)
-
-
-def has_exchange_widget(content: str) -> bool:
- """Check if file already has an ExchangeWidget."""
- return ' str:
- """Determine page type from file path."""
- rel = filepath.relative_to(CONTENT_DIR)
- parts = rel.parts
-
- if parts[0] == "send-to" and "from" in parts:
- return "corridor"
- elif parts[0] == "send-to":
- return "send-to"
- elif parts[0] == "countries":
- return "hub"
- elif parts[0] == "pay-with":
- return "pay-with"
- return "other"
-
-
-def get_corridor_origin(filepath: Path) -> str | None:
- """Extract origin country slug from corridor path."""
- rel = filepath.relative_to(CONTENT_DIR)
- parts = rel.parts
- # send-to/{dest}/from/{origin}/{lang}.md
- try:
- from_idx = parts.index("from")
- return parts[from_idx + 1]
- except (ValueError, IndexError):
- return None
-
-
-def build_widget_tag(dest_currency: str, source_currency: str | None = None) -> str:
- """Build the ExchangeWidget MDX tag."""
- if source_currency and source_currency != "USD":
- return f' '
- return f' '
-
-
-def insert_widget_send_to(content: str, widget_tag: str) -> str:
- """Insert widget before str:
- """Insert widget before inline CTA in corridor pages."""
- # Insert before inline CTA (variant="secondary")
- m = re.search(r'\n bool:
- """Process a single content file. Returns True if modified."""
- content = filepath.read_text(encoding="utf-8")
-
- currency = extract_currency(content)
- if not currency:
- return False
-
- page_type = get_page_type(filepath)
- modified = False
-
- # Step 1: Always strip currency from Hero
- new_content = strip_currency_from_hero(content)
- if new_content != content:
- modified = True
- content = new_content
-
- # Step 2: Add ExchangeWidget for eligible page types
- if page_type in ("send-to", "corridor") and not has_exchange_widget(content):
- source_currency = None
- if page_type == "corridor":
- origin = get_corridor_origin(filepath)
- if origin:
- source_currency = ORIGIN_CURRENCY.get(origin)
-
- widget_tag = build_widget_tag(currency, source_currency)
-
- if page_type == "send-to":
- new_content = insert_widget_send_to(content, widget_tag)
- else:
- new_content = insert_widget_corridor(content, widget_tag)
-
- if new_content != content:
- modified = True
- content = new_content
-
- if modified:
- filepath.write_text(content, encoding="utf-8")
-
- return modified
-
-
-def main():
- stats = {"stripped": 0, "widget_added": 0, "skipped": 0, "errors": 0}
-
- for md_file in sorted(CONTENT_DIR.rglob("*.md")):
- content = md_file.read_text(encoding="utf-8")
- if 'currency="' not in content:
- continue
-
- rel = md_file.relative_to(CONTENT_DIR)
- page_type = get_page_type(md_file)
- currency = extract_currency(content)
-
- try:
- had_widget = has_exchange_widget(content)
- modified = process_file(md_file)
-
- if modified:
- new_content = md_file.read_text(encoding="utf-8")
- widget_added = not had_widget and has_exchange_widget(new_content)
-
- stats["stripped"] += 1
- if widget_added:
- stats["widget_added"] += 1
- print(f" ✓ {rel} [{page_type}] — stripped currency={currency}, added ExchangeWidget")
- else:
- print(f" ✓ {rel} [{page_type}] — stripped currency={currency}")
- else:
- stats["skipped"] += 1
- print(f" · {rel} [{page_type}] — skipped (no currency= found)")
- except Exception as e:
- stats["errors"] += 1
- print(f" ✗ {rel} — ERROR: {e}")
-
- print(f"\nDone:")
- print(f" Stripped currency=: {stats['stripped']}")
- print(f" Added ExchangeWidget: {stats['widget_added']}")
- print(f" Skipped: {stats['skipped']}")
- print(f" Errors: {stats['errors']}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/assets/cards/DEPRECATED_Cart Gradient 5.svg b/src/assets/cards/DEPRECATED_Cart Gradient 5.svg
deleted file mode 100644
index 9133d2123..000000000
--- a/src/assets/cards/DEPRECATED_Cart Gradient 5.svg
+++ /dev/null
@@ -1,625 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
From 9f29a4744f632b906c21d8b8ed74bebc01879706 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 00:54:21 +0000
Subject: [PATCH 35/61] ci: add --archive=tgz to vercel deploy to handle >15k
files
---
.github/workflows/preview.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml
index 41f3a6f1c..4cff7f75e 100644
--- a/.github/workflows/preview.yaml
+++ b/.github/workflows/preview.yaml
@@ -25,4 +25,4 @@ jobs:
- name: Build Project Artifacts
run: vercel build --target=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
- run: vercel deploy --prebuilt --target=preview --token=${{ secrets.VERCEL_TOKEN }}
+ run: vercel deploy --prebuilt --archive=tgz --target=preview --token=${{ secrets.VERCEL_TOKEN }}
From 3d25321a65726eb9cdf335489bc1e057e655eb59 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 01:04:30 +0000
Subject: [PATCH 36/61] fix: sanitize blog slug to prevent stored XSS (CodeQL
high)
---
src/components/Marketing/BlogCard.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/Marketing/BlogCard.tsx b/src/components/Marketing/BlogCard.tsx
index 5ad762c6f..87e3573c2 100644
--- a/src/components/Marketing/BlogCard.tsx
+++ b/src/components/Marketing/BlogCard.tsx
@@ -11,8 +11,9 @@ interface BlogCardProps {
}
export function BlogCard({ slug, title, excerpt, date, category, hrefPrefix = '/blog' }: BlogCardProps) {
+ const safeSlug = encodeURIComponent(slug)
return (
-
+
Date: Thu, 26 Feb 2026 01:15:44 +0000
Subject: [PATCH 37/61] fix: restore lockfile to zerodev 5.5.7, revert CashCard
threshold (BE handles it)
---
src/components/Points/CashCard.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/Points/CashCard.tsx b/src/components/Points/CashCard.tsx
index 6518eeb33..c5d825611 100644
--- a/src/components/Points/CashCard.tsx
+++ b/src/components/Points/CashCard.tsx
@@ -16,7 +16,7 @@ export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) =>
Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
@@ -24,7 +24,7 @@ export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) =>
{hasCashbackLeft ? (
- You have more cashback left! Make a payment to claim it.
+ You have unclaimed cashback left! Make a payment to claim it.
) : (
Invite friends to unlock more cashback.
)}
From 50e71ca75f1a7f1e1c7b0245cc5f92e0c4af315f Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 01:32:55 +0000
Subject: [PATCH 38/61] fix: lazy-load bundle analyzer to avoid prod build
failure
---
next.config.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/next.config.js b/next.config.js
index b41d343c5..18535adf2 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,9 +1,9 @@
const os = require('os')
const { execSync } = require('child_process')
-const withBundleAnalyzer = require('@next/bundle-analyzer')({
- // Only enable in production builds when explicitly requested
- enabled: process.env.ANALYZE === 'true' && process.env.NODE_ENV !== 'development',
-})
+const withBundleAnalyzer =
+ process.env.ANALYZE === 'true'
+ ? require('@next/bundle-analyzer')({ enabled: true })
+ : (config) => config
const redirectsConfig = require('./redirects.json')
From 154c3cc6e878f103857f13a560cdf60afffa0be6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 02:24:59 +0000
Subject: [PATCH 39/61] fix: remove hardcoded 20% from cashback CTA, cap invite
graph at 200 nodes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Carousel CTA for LATAM users now says "Earn cashback" instead of "Earn 20% cashback"
(actual rate is 10% with dynamic caps, old copy was misleading)
- Cap InvitesGraph in minimal mode (points page) to 200 nodes sorted by points,
trimming edges to included nodes only — fixes slow rendering for users with many invites
---
src/components/Global/InvitesGraph/index.tsx | 16 ++++++++++++++++
src/hooks/useHomeCarouselCTAs.tsx | 2 +-
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx
index 60dfddc9c..2c50141da 100644
--- a/src/components/Global/InvitesGraph/index.tsx
+++ b/src/components/Global/InvitesGraph/index.tsx
@@ -266,6 +266,22 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const data = isMinimal ? props.data : fetchedGraphData
if (!data) return null
+ // Minimal mode (points page): cap at 200 nodes for performance
+ if (isMinimal && data.nodes.length > 200) {
+ const sortedNodes = [...data.nodes].sort((a, b) => (b.totalPoints ?? 0) - (a.totalPoints ?? 0))
+ const limitedNodes = sortedNodes.slice(0, 200)
+ const limitedNodeIds = new Set(limitedNodes.map((n) => n.id))
+ const filteredEdges = data.edges.filter(
+ (edge) => limitedNodeIds.has(edge.source) && limitedNodeIds.has(edge.target)
+ )
+ return {
+ nodes: limitedNodes,
+ edges: filteredEdges,
+ p2pEdges: [],
+ stats: { ...data.stats, totalNodes: limitedNodes.length, totalEdges: filteredEdges.length, totalP2PEdges: 0 },
+ }
+ }
+
// Performance mode: limit to top 1000 nodes on frontend (payment graph only)
const performanceMode = !isMinimal && (props as FullModeProps).performanceMode
if (performanceMode && data.nodes.length > 1000) {
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index 8613eccf9..dc408c42c 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -159,7 +159,7 @@ export const useHomeCarouselCTAs = () => {
id: 'latam-cashback-invite',
title: (
- Earn 20% cashback on QR payments
+ Earn cashback on QR payments
),
description: (
From f5425c12ff5c6f7e6a0b33bae443e16608f2ef33 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 02:40:53 +0000
Subject: [PATCH 40/61] fix: improve QR scanner detection reliability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Users reported QR codes not being detected when placed in the scanner overlay.
- Reduce maxScansPerSecond from 25 to 8 (library default is 5). At 25fps
the Web Worker gets overwhelmed on mid-range phones and silently drops frames.
- Switch inversion mode from 'both' to 'original' — payment QRs (PIX,
Mercado Pago, Peanut) are all standard dark-on-light. 'both' doubled
decode work per frame for no practical benefit.
- Widen scan region from 2/3 square to 70%x70% of video, centered slightly
above middle to better match the visual overlay across device aspect ratios.
- Add continuous autofocus constraint after camera start — some devices
default to single-shot focus, leaving the image blurry when moved.
Net effect: decode load drops from ~50/sec to 8/sec while covering a larger
detection area with active autofocus.
---
src/app/(mobile-ui)/qr-pay/page.tsx | 8 +++-
.../Global/QRScanner/useQRScanner.ts | 41 ++++++++++++-------
2 files changed, 33 insertions(+), 16 deletions(-)
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index d684d7da3..9b0fdb023 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -1274,7 +1274,13 @@ export default function QRPayPage() {
Peanut got you!
{(() => {
- const percentage = qrPayment?.perk?.discountPercentage || 100
+ const amountSponsored = qrPayment?.perk?.amountSponsored
+ const transactionUsd =
+ parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0
+ const percentage =
+ amountSponsored && transactionUsd > 0
+ ? Math.round((amountSponsored / transactionUsd) * 100)
+ : qrPayment?.perk?.discountPercentage || 100
if (percentage === 100) {
return 'We paid for this bill! Earn points, climb tiers and unlock even better perks.'
} else if (percentage > 100) {
diff --git a/src/components/Global/QRScanner/useQRScanner.ts b/src/components/Global/QRScanner/useQRScanner.ts
index b8e6c2c0b..6e948c7f8 100644
--- a/src/components/Global/QRScanner/useQRScanner.ts
+++ b/src/components/Global/QRScanner/useQRScanner.ts
@@ -11,7 +11,7 @@ const CONFIG = {
CAMERA_RETRY_DELAY_MS: 1000,
MAX_CAMERA_RETRIES: 3,
IOS_CAMERA_DELAY_MS: 200,
- SCANNER_MAX_SCANS_PER_SECOND: 25,
+ SCANNER_MAX_SCANS_PER_SECOND: 8,
SCANNER_CLOSE_DELAY_MS: 1500,
VIDEO_ELEMENT_RETRY_DELAY_MS: 100,
MAX_VIDEO_ELEMENT_RETRIES: 2,
@@ -24,23 +24,20 @@ const CAMERA_ERRORS = {
} as const
/**
- * Custom scan region: top 2/3 of video, horizontally centered.
- * Matches the visual overlay position better.
+ * Scan region: half the video area, centered slightly above middle.
* Uses 800x800 downscale for dense QR codes (Mercado Pago, PIX).
*/
const calculateScanRegion = (video: HTMLVideoElement) => {
- // Use 2/3 of the smaller dimension for a square scan region
- const smallerDimension = Math.min(video.videoWidth, video.videoHeight)
- const scanRegionSize = Math.round((2 / 3) * smallerDimension)
+ const regionW = Math.round(video.videoWidth * 0.7)
+ const regionH = Math.round(video.videoHeight * 0.7)
return {
- x: Math.round((video.videoWidth - scanRegionSize) / 2), // Centered horizontally
- y: 0, // Top aligned
- width: scanRegionSize,
- height: scanRegionSize,
- // Larger downscale for dense QR codes (default is 400x400)
- downScaledWidth: Math.min(scanRegionSize, 800),
- downScaledHeight: Math.min(scanRegionSize, 800),
+ x: Math.round((video.videoWidth - regionW) / 2),
+ y: Math.round((video.videoHeight - regionH) / 2 * 0.7),
+ width: regionW,
+ height: regionH,
+ downScaledWidth: Math.min(regionW, 800),
+ downScaledHeight: Math.min(regionH, 800),
}
}
@@ -219,11 +216,25 @@ export function useQRScanner(onScan: QRScanHandler, onClose: (() => void) | unde
preferredCamera,
})
- // Enable scanning both normal and inverted QR codes (dark on light AND light on dark)
- scanner.setInversionMode('both')
+ scanner.setInversionMode('original')
scannerRef.current = scanner
await scanner.start()
+
+ // Request continuous autofocus — some devices default to single-shot
+ // focus on start, leaving the image blurry when the user moves the phone.
+ try {
+ const stream = videoRef.current?.srcObject as MediaStream | null
+ const track = stream?.getVideoTracks()[0]
+ if (track && 'applyConstraints' in track) {
+ await track.applyConstraints({
+ advanced: [{ focusMode: 'continuous' } as MediaTrackConstraintSet],
+ })
+ }
+ } catch {
+ // Not all devices support focusMode — safe to ignore
+ }
+
console.log('[QR Scanner] Camera started, ready to scan')
retryCountRef.current = 0
} catch (err: any) {
From 83330001d19857d448b0094c2e05c812fee873c5 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Thu, 26 Feb 2026 02:59:05 +0000
Subject: [PATCH 41/61] fix: currency picker z-index on SEO pages, accurate
cashback percentage
- Add z-50 to CurrencySelect PopoverPanel so dropdown renders above page content
- Show actual cashback percentage on perk success screen instead of campaign rate
---
src/components/LandingPage/CurrencySelect.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/LandingPage/CurrencySelect.tsx b/src/components/LandingPage/CurrencySelect.tsx
index cdc13e8bd..e6276d955 100644
--- a/src/components/LandingPage/CurrencySelect.tsx
+++ b/src/components/LandingPage/CurrencySelect.tsx
@@ -58,7 +58,7 @@ const CurrencySelect = ({
{trigger}