diff --git a/.cursorrules b/.cursorrules
index 659e694f0..b0636760a 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -10,6 +10,7 @@
- **Do not generate .md files** unless explicity told to do so.
- **Comments** should always be made in all lowercase and simple english
- **Error messages**, any error being shown in the ui should be user friendly and easy to understand, and any error being logged in consoles and sentry should be descriptive for developers to help with debugging
+- **Never add AI co-author to commits** - do not add "Co-Authored-By" lines for AI assistants in git commits
## 💻 Code Quality
@@ -78,6 +79,57 @@
- **Service Worker cache version** - only bump `NEXT_PUBLIC_API_VERSION` for breaking API changes (see JSDoc in `src/app/sw.ts`). Users auto-migrate.
- **Gate heavy features in dev** - prefetching, precompiling, or eager loading of routes can add 5-10s to dev cold starts. wrap with `process.env.NODE_ENV !== 'development'` (e.g. ` ` in layout.tsx).
+## 🎨 Design System
+
+- **Live showcase**: visit `/dev/components` to see all components rendered with all variants and copy-paste code
+- **Three layers**: Bruddle primitives (`src/components/0_Bruddle/`), Global shared components (`src/components/Global/`), and Tailwind custom classes (`tailwind.config.js`)
+
+### Bruddle Primitives (`0_Bruddle/`)
+- Button, Card (named export), BaseInput, BaseSelect, Checkbox, Divider, Title, Toast, PageContainer, CloudsBackground
+
+### Global Shared Components (`Global/`)
+- **Navigation**: NavHeader (back button + title), TopNavbar, Footer
+- **Modals**: Modal (base @headlessui Dialog), ActionModal (with buttons/checkboxes/icons), Drawer (vaul bottom sheet)
+- **Loading**: Loading (spinner), PeanutLoading (branded), PeanutFactsLoading (with fun facts)
+- **Cards**: Card (with position prop for stacked lists), InfoCard, PeanutActionCard
+- **Status**: StatusPill, StatusBadge, ErrorAlert, ProgressBar
+- **Icons**: Icon component with 50+ icons — ` `
+- **Inputs**: AmountInput, ValidatedInput, CopyField, GeneralRecipientInput, TokenSelector
+- **Utilities**: CopyToClipboard, AddressLink, ExternalWalletButton, ShareButton, Banner, MarqueeWrapper
+
+### Color Names (misleading!)
+- `purple-1` / `primary-1` = `#FF90E8` (pink, not purple)
+- `primary-3` = `#EFE4FF` (lavender)
+- `yellow-1` / `secondary-1` = `#FFC900`
+- `green-1` = `#98E9AB`
+
+### Key Rules
+- **Button sizing trap**: `size="large"` is `h-10` (40px) — SHORTER than default `h-13` (52px). never use for primary CTAs
+- **Primary CTA**: `` — no size prop
+- **Secondary CTA**: ``
+- **Shadows**: always black `#000000`. use `shadowSize="4"` for action buttons
+- **Border radius**: always `rounded-sm` (not rounded-lg or rounded-md)
+- **Border stroke**: `border border-n-1` (1px black)
+- **Links**: `text-black underline` — never `text-purple-1` (pink)
+- **Text hierarchy**: `text-n-1` primary, `text-grey-1` secondary
+- **Two Card components**: `0_Bruddle/Card` (standalone containers, named export) vs `Global/Card` (stacked list items, default export)
+- **Backgrounds**: `bg-peanut-repeat-normal` for waving peanut pattern
+- **Messaging**: use "starter balance" for card deposits — never "card balance" or "Peanut rewards"
+
+### Page Layouts
+
+- **Outer shell**: `flex min-h-[inherit] flex-col gap-8` — NavHeader is first child
+- **Centered content + CTA** (default): wrap content AND CTA in ``. CTA must be INSIDE this div, never a sibling
+- **Pinned footer CTA**: add `justify-between` to outer div, CTA as last child outside content
+- **Never** use `space-y-*` on the outer flex div (conflicts with centering) — use `gap-*` instead
+
+## ✅ Before Pushing
+
+- **Format**: `pnpm prettier --write
` then verify with `pnpm prettier --check .`
+- **Typecheck**: `npm run typecheck` — catches type errors that tests miss (tests don't run tsc)
+- **Test**: `npm test` (fast, ~5s) — all 17 suites must pass
+- **Build** (if touching imports/types): `npm run build` to catch compile errors
+
## 📝 Commits
- **Be descriptive**
diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml
index 13be38b27..4cff7f75e 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
@@ -24,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 }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 74299a181..723d717c4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,6 +9,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+ with:
+ submodules: true
+ token: ${{ secrets.SUBMODULE_TOKEN }}
- uses: actions/setup-node@v4
with:
@@ -24,11 +27,24 @@ jobs:
- name: Check formatting
run: pnpm prettier --check .
- - name: Run Tests
+ - name: Run Unit Tests
run: pnpm test
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps chromium
+
+ - name: Run E2E Tests
+ run: pnpm test:e2e
+
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
+
+ - name: Upload Playwright Report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
diff --git a/.gitignore b/.gitignore
index 811e26f55..afb8d3eaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@ PR.md
# testing
/coverage
+/test-results/
+/playwright-report/
+/playwright/.cache/
# next.js
/.next/
@@ -70,3 +73,7 @@ public/sw*
public/swe-worker*
.idea
+
+# mobile POC
+android/
+.claude/
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/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
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 000000000..5ff2ddb37
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,170 @@
+# Testing Philosophy
+
+## Overview
+
+peanut-ui uses a focused testing strategy that prioritizes **high-value tests** over coverage theater. We test critical paths that catch real bugs, not to hit arbitrary coverage percentages.
+
+## Test Types
+
+### 1. Unit Tests (Jest)
+
+**Location**: Tests live with the code they test (e.g. `src/utils/foo.test.ts`)
+
+**What we test**:
+- Pure business logic (calculations, validations, transformations)
+- Complex utility functions
+- Critical algorithms (e.g. point calculations, country eligibility)
+
+**What we DON'T test**:
+- React components (brittle, low ROI)
+- API service wrappers (thin fetch calls)
+- Hooks that just wrap react-query (already tested upstream)
+- JSX/UI layout (visual QA better)
+
+**Run tests**:
+- `npm test` - Run all unit tests
+- `npm run test:watch` - Run tests in watch mode
+
+### 2. E2E Tests (Playwright)
+
+**Location**: Tests live with features they test (e.g. `src/features/card-pioneer/card-pioneer.e2e.test.ts`)
+
+**What we test**:
+- ✅ Multi-step navigation flows
+- ✅ Form validation and error states
+- ✅ URL state management (nuqs integration)
+- ✅ Auth flows (signup, login, logout)
+- ✅ UI interactions without external dependencies
+
+**What we DON'T test**:
+- ❌ Payment flows (require real transactions)
+- ❌ KYC flows (external API dependencies)
+- ❌ Bank transfers (real money, manual QA required)
+- ❌ Wallet connections (MetaMask/WalletConnect popups)
+
+**Why this split?**
+External dependencies (payments, KYC, banks) are better tested manually because:
+1. They require real credentials and real money
+2. They have complex state (KYC approval status, bank account verification)
+3. They involve third-party UIs (wallet popups, bank OAuth)
+4. Manual QA catches edge cases E2E can't simulate
+
+**Run tests**:
+- `npm run test:e2e` - Run all E2E tests (headless)
+- `npm run test:e2e:headed` - Run with browser visible
+- `npm run test:e2e:ui` - Run in interactive UI mode
+- `npx playwright test --grep "Card Pioneer"` - Run specific test suite
+- `npx playwright test --list` - List all tests without running
+
+## Testing Principles
+
+### 1. Test Critical Paths Only
+
+Focus on code that:
+- Has financial impact (payment calculations, point multipliers)
+- Has legal requirements (sanctions compliance, geo restrictions)
+- Has security implications (reference parsing, user ID extraction)
+- Is complex or hard to verify manually (date logic, ISO code mappings)
+
+### 2. Fast Tests
+
+- Unit tests run in ~5s
+- E2E tests focus on minimal, high-value flows
+- No unnecessary setup/teardown
+- Mock external APIs but keep mocks simple
+
+### 3. Tests Live With Code
+
+Per .cursorrules: tests live where the code they test is, not in a separate folder.
+
+```
+src/
+ utils/
+ geo.ts
+ geo.test.ts ← unit test
+ features/
+ card-pioneer/
+ card-pioneer.tsx
+ card-pioneer.e2e.test.ts ← e2e test
+```
+
+### 4. DRY in Tests
+
+Reuse test utilities, shared fixtures, and helper functions. Less code is better code.
+
+## Example Test Scenarios
+
+### Unit Test Example
+
+```typescript
+// src/utils/country-codes.test.ts
+describe('convertIso3ToIso2', () => {
+ it('should convert USA to US', () => {
+ expect(convertIso3ToIso2('USA')).toBe('US')
+ })
+
+ it('should handle sanctioned countries', () => {
+ expect(convertIso3ToIso2('CUB')).toBe('CU') // Cuba
+ expect(convertIso3ToIso2('VEN')).toBe('VE') // Venezuela
+ })
+})
+```
+
+### E2E Test Example
+
+```typescript
+// src/features/card-pioneer/card-pioneer.e2e.test.ts
+test('user can navigate card pioneer flow', async ({ page }) => {
+ await page.goto('/card-pioneer')
+
+ // step 1: info screen
+ await expect(page.getByRole('heading', { name: /card pioneer/i })).toBeVisible()
+ await page.getByRole('button', { name: /get started/i }).click()
+
+ // step 2: details screen (check URL state)
+ await expect(page).toHaveURL(/step=details/)
+ await page.getByRole('button', { name: /continue/i }).click()
+
+ // step 3: geo check
+ await expect(page).toHaveURL(/step=geo/)
+})
+```
+
+## When to Add Tests
+
+Add tests when:
+1. Implementing new financial logic
+2. Handling compliance requirements
+3. Complex algorithms or data transformations
+4. Bug fixes (regression tests)
+
+Skip tests when:
+1. Just rendering JSX
+2. Thin wrappers around libraries
+3. Purely visual changes
+4. Code that's easier to verify manually
+
+## CI Integration
+
+- Unit tests run on every PR
+- E2E tests run on PR to main
+- Fail fast: first failure stops the build
+
+## Maintenance
+
+Keep tests:
+- **Concise** - no verbose setup or comments
+- **Focused** - one concern per test
+- **Stable** - avoid flaky selectors or timing issues
+- **Up-to-date** - delete tests for removed features
+
+When tests fail:
+1. Fix the bug (if test caught a real issue)
+2. Update the test (if behavior intentionally changed)
+3. Delete the test (if feature was removed)
+
+## Resources
+
+- Jest: https://jestjs.io/
+- Playwright: https://playwright.dev/
+- Testing Library: https://testing-library.com/ (for React unit tests if needed)
diff --git a/package.json b/package.json
index 5f0b4717b..eff1fe5d7 100644
--- a/package.json
+++ b/package.json
@@ -15,10 +15,15 @@
"knip": "knip",
"format": "prettier --write .",
"analyze": "ANALYZE=true next build",
+ "typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
- "script": "NODE_OPTIONS=\"--experimental-json-modules\" tsx"
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:headed": "playwright test --headed",
+ "script": "NODE_OPTIONS=\"--experimental-json-modules\" tsx",
+ "validate-content": "tsx scripts/validate-content.ts"
},
"dependencies": {
"@dicebear/collection": "^9.2.2",
@@ -48,7 +53,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",
@@ -56,11 +61,14 @@
"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",
+ "next-mdx-remote": "^6.0.0",
"nuqs": "^2.8.6",
"pix-utils": "^2.8.2",
"pulltorefreshjs": "^0.1.22",
@@ -75,6 +83,8 @@
"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",
"tailwind-scrollbar": "^3.1.0",
@@ -86,6 +96,7 @@
},
"devDependencies": {
"@next/bundle-analyzer": "^16.1.1",
+ "@playwright/test": "^1.58.0",
"@serwist/build": "^9.0.10",
"@size-limit/preset-app": "^11.2.0",
"@testing-library/jest-dom": "^6.4.2",
@@ -147,6 +158,10 @@
"**/__tests__/**/*.test.[jt]s?(x)",
"**/?(*.)+(spec|test).[jt]s?(x)"
],
+ "testPathIgnorePatterns": [
+ "/node_modules/",
+ "\\.e2e\\.test\\.(ts|tsx)$"
+ ],
"extensionsToTreatAsEsm": [
".ts",
".tsx"
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 000000000..9185f2cd1
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,53 @@
+import { defineConfig, devices } from '@playwright/test'
+
+/**
+ * Playwright E2E Test Configuration
+ *
+ * Testing philosophy:
+ * - Test navigation flows and UI logic without external dependencies
+ * - Do NOT test payment/KYC/bank flows (manual QA required)
+ * - Fast, reliable tests for core user journeys
+ *
+ * See docs/TESTING.md for full testing philosophy
+ */
+export default defineConfig({
+ // test directory lives with code (per .cursorrules)
+ testDir: './src',
+ testMatch: '**/*.e2e.test.ts',
+
+ // reasonable timeout for UI tests
+ timeout: 30 * 1000,
+
+ // fail fast in CI
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+
+ // clear, concise reporting
+ reporter: 'html',
+
+ use: {
+ // base url for tests
+ baseURL: 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+
+ // test against chromium only (simplest, fastest)
+ // can expand to firefox/webkit if cross-browser bugs emerge
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+
+ // start dev server before tests
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120 * 1000, // 2 minutes for dev server startup
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 059ffce5c..192bc6dff 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)
@@ -69,10 +69,10 @@ importers:
version: 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
'@sentry/nextjs':
specifier: ^8.39.0
- version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)
+ version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(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)(webpack@5.104.1)
'@serwist/next':
specifier: ^9.0.10
- version: 9.5.0(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)
+ version: 9.5.0(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)(typescript@5.9.3)(webpack@5.104.1)
'@simplewebauthn/browser':
specifier: ^8.3.7
version: 8.3.7
@@ -87,16 +87,16 @@ importers:
version: 3.20.0(react@19.2.4)
'@vercel/analytics':
specifier: ^1.4.1
- version: 1.6.1(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ version: 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)
'@wagmi/core':
specifier: 2.19.0
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)
@@ -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,12 +133,18 @@ 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)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ 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)
pix-utils:
specifier: ^2.8.2
version: 2.8.2
@@ -175,6 +184,12 @@ 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
siwe:
specifier: ^2.3.2
version: 2.3.2(ethers@5.7.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))
@@ -183,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
@@ -203,6 +218,9 @@ importers:
'@next/bundle-analyzer':
specifier: ^16.1.1
version: 16.1.6(bufferutil@4.1.0)(utf-8-validate@5.0.10)
+ '@playwright/test':
+ specifier: ^1.58.0
+ version: 1.58.2
'@serwist/build':
specifier: ^9.0.10
version: 9.5.0(typescript@5.9.3)
@@ -271,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)
@@ -1333,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'}
@@ -1945,6 +1972,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -2619,6 +2651,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==}
@@ -2796,12 +2849,18 @@ 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==}
'@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==}
@@ -2826,6 +2885,12 @@ packages:
'@types/lodash@4.17.23':
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
+ '@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==}
@@ -2881,6 +2946,12 @@ 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==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -2896,6 +2967,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:
@@ -3147,8 +3221,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
@@ -3190,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'}
@@ -3284,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==}
@@ -3345,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==}
@@ -3505,6 +3591,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'}
@@ -3517,6 +3606,18 @@ 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==}
+
+ 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'}
@@ -3570,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==}
@@ -3584,6 +3688,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'}
@@ -3786,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'}
@@ -3843,6 +3953,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==}
@@ -3982,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'}
@@ -3999,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'}
@@ -4030,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'}
@@ -4085,6 +4229,13 @@ 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'}
+
+ 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'}
@@ -4221,6 +4372,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4311,6 +4467,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'}
@@ -4345,6 +4505,18 @@ 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==}
+
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
@@ -4358,6 +4530,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'}
@@ -4438,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'}
@@ -4449,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'}
@@ -4468,6 +4652,13 @@ 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'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -4488,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'}
@@ -4776,6 +4974,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'}
@@ -4845,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
@@ -4884,10 +5089,70 @@ 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'}
+ hasBin: true
+
math-intrinsics@1.1.0:
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==}
@@ -4898,6 +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'}
@@ -5002,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'}
@@ -5111,6 +5487,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
@@ -5172,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'}
@@ -5261,6 +5646,16 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -5438,6 +5833,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'}
@@ -5641,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'}
@@ -5653,6 +6065,33 @@ 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==}
+
+ 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'}
@@ -5745,6 +6184,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
@@ -5789,6 +6232,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==}
@@ -5885,11 +6331,18 @@ 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'}
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'}
@@ -5952,6 +6405,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'}
@@ -5960,6 +6416,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'}
@@ -5980,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'}
@@ -6121,6 +6587,12 @@ packages:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
+ 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==}
@@ -6210,6 +6682,30 @@ 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==}
+
+ 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'}
@@ -6376,6 +6872,15 @@ 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==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
viem@2.45.0:
resolution: {integrity: sha512-iVA9qrAgRdtpWa80lCZ6Jri6XzmLOwwA1wagX2HnKejKeliFLpON0KOdyfqvcy+gUpBVP59LBxP2aKiL3aj8fg==}
peerDependencies:
@@ -6578,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'}
@@ -6649,6 +7159,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': {}
@@ -7614,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
@@ -7962,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
@@ -8672,6 +9221,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@polka/url@1.0.0-next.29': {}
'@popperjs/core@2.11.8': {}
@@ -9757,7 +10310,7 @@ snapshots:
'@sentry/core@8.55.0': {}
- '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)':
+ '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(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)(webpack@5.104.1)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
@@ -9770,7 +10323,7 @@ snapshots:
'@sentry/vercel-edge': 8.55.0
'@sentry/webpack-plugin': 2.22.7(webpack@5.104.1)
chalk: 3.0.0
- next: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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)
resolve: 1.22.8
rollup: 3.29.5
stacktrace-parser: 0.1.11
@@ -9867,7 +10420,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
- '@serwist/next@9.5.0(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)':
+ '@serwist/next@9.5.0(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)(typescript@5.9.3)(webpack@5.104.1)':
dependencies:
'@serwist/build': 9.5.0(typescript@5.9.3)
'@serwist/utils': 9.5.0
@@ -9876,7 +10429,7 @@ snapshots:
browserslist: 4.28.1
glob: 10.5.0
kolorist: 1.8.0
- next: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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
semver: 7.7.3
serwist: 9.5.0(typescript@5.9.3)
@@ -9905,6 +10458,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
@@ -10110,12 +10696,20 @@ 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':
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':
@@ -10143,6 +10737,12 @@ snapshots:
'@types/lodash@4.17.23': {}
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/mdx@2.0.13': {}
+
'@types/ms@2.1.0': {}
'@types/mysql@2.15.26':
@@ -10194,6 +10794,10 @@ 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': {}
'@types/validator@13.15.10': {}
@@ -10209,9 +10813,11 @@ snapshots:
'@types/node': 20.4.2
optional: true
- '@vercel/analytics@1.6.1(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
+ '@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)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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
'@wagmi/connectors@5.9.3(@types/react@18.3.27)(@wagmi/core@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)))(bufferutil@4.1.0)(immer@11.1.3)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(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)':
@@ -11085,15 +11691,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)
@@ -11132,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
@@ -11211,6 +11821,8 @@ snapshots:
dependencies:
tslib: 2.8.1
+ astring@1.9.0: {}
+
async-mutex@0.2.6:
dependencies:
tslib: 2.8.1
@@ -11303,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: {}
@@ -11447,6 +12061,8 @@ snapshots:
canvas-confetti@1.9.4: {}
+ ccount@2.0.1: {}
+
chalk@3.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -11459,6 +12075,14 @@ snapshots:
char-regex@1.0.2: {}
+ character-entities-html4@2.1.0: {}
+
+ 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
@@ -11513,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:
@@ -11525,6 +12151,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ comma-separated-tokens@2.0.3: {}
+
commander@12.0.0: {}
commander@2.20.3: {}
@@ -11717,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):
@@ -11758,6 +12390,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: {}
@@ -11907,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
@@ -11942,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
@@ -11980,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:
@@ -12090,6 +12775,12 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
+ extend@3.0.2: {}
+
extension-port-stream@3.0.0:
dependencies:
readable-stream: 3.6.2
@@ -12237,6 +12928,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -12337,6 +13031,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
@@ -12383,6 +13084,65 @@ 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
+ '@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-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
+
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
@@ -12399,6 +13159,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
@@ -12484,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
@@ -12507,6 +13278,10 @@ snapshots:
dependencies:
hasown: 2.0.2
+ is-decimal@2.0.1: {}
+
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -12525,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: {}
@@ -13019,6 +13798,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):
@@ -13090,6 +13871,8 @@ snapshots:
lodash@4.17.23: {}
+ longest-streak@3.1.0: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -13124,14 +13907,447 @@ 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-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ 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
+
+ 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
+
+ mdast-util-gfm-footnote@2.1.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
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ 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
@@ -13209,7 +14425,21 @@ snapshots:
netmask@2.0.2: {}
- next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ 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
'@swc/helpers': 0.5.15
@@ -13228,6 +14458,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.0.10
'@next/swc-win32-x64-msvc': 16.0.10
'@opentelemetry/api': 1.9.0
+ '@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -13255,12 +14486,12 @@ snapshots:
dependencies:
path-key: 3.1.1
- nuqs@2.8.6(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
+ nuqs@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):
dependencies:
'@standard-schema/spec': 1.0.0
react: 19.2.4
optionalDependencies:
- next: 16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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)
nwsapi@2.2.23: {}
@@ -13292,6 +14523,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):
@@ -13405,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
@@ -13492,6 +14741,14 @@ snapshots:
dependencies:
find-up: 4.1.0
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
pngjs@5.0.0: {}
pony-cause@2.1.11: {}
@@ -13517,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:
@@ -13600,6 +14858,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
@@ -13817,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
@@ -13828,6 +15117,65 @@ 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
+
+ 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: {}
@@ -13909,6 +15257,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: {}
@@ -13979,6 +15332,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:
@@ -14098,10 +15462,14 @@ snapshots:
source-map@0.6.1: {}
+ source-map@0.7.6: {}
+
source-map@0.8.0-beta.0:
dependencies:
whatwg-url: 7.1.0
+ space-separated-tokens@2.0.2: {}
+
split-on-first@1.1.0: {}
split2@4.2.0: {}
@@ -14173,6 +15541,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
@@ -14181,6 +15554,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: {}
@@ -14193,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
@@ -14231,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
@@ -14254,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
@@ -14364,6 +15747,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ 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):
@@ -14432,6 +15819,49 @@ 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
+
+ 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:
@@ -14547,6 +15977,21 @@ 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
+ 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
@@ -14801,6 +16246,8 @@ snapshots:
yaml@1.10.2: {}
+ yaml@2.8.2: {}
+
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
@@ -14860,3 +16307,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/badges/peanut-pioneer.png b/public/badges/peanut-pioneer.png
new file mode 100644
index 000000000..2848cc77b
Binary files /dev/null and b/public/badges/peanut-pioneer.png differ
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/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/scripts/validate-content.ts b/scripts/validate-content.ts
new file mode 100644
index 000000000..716ebc104
--- /dev/null
+++ b/scripts/validate-content.ts
@@ -0,0 +1,272 @@
+#!/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/sentry.client.config.ts b/sentry.client.config.ts
index 0011f71b6..2cea2703d 100644
--- a/sentry.client.config.ts
+++ b/sentry.client.config.ts
@@ -4,76 +4,7 @@
import * as Sentry from '@sentry/nextjs'
-/**
- * Patterns to filter out from Sentry reporting.
- * These are generally noise that doesn't require action.
- */
-const IGNORED_ERRORS = {
- // User-initiated cancellations (not bugs)
- userRejected: [
- 'User rejected',
- 'user rejected',
- 'User denied',
- 'not allowed by the user',
- 'User cancelled',
- 'user cancelled',
- 'Request rejected',
- 'AbortError',
- 'The operation was aborted',
- ],
-
- networkIssues: ['Network Error', 'Failed to fetch', 'Load failed'],
-
- // Browser/extension noise
- browserNoise: [
- 'ResizeObserver loop',
- 'ResizeObserver loop limit exceeded',
- 'Script error.',
- // Extension interference
- 'chrome-extension://',
- 'moz-extension://',
- 'safari-extension://',
- ],
-
- // Third-party scripts we don't control
- thirdParty: ['googletagmanager', 'gtag', 'analytics', 'hotjar', 'clarity', 'intercom', 'crisp'],
-}
-
-/**
- * Check if error message matches any ignored pattern
- */
-function shouldIgnoreError(event: Sentry.ErrorEvent): boolean {
- const message = event.message || ''
- const exceptionValue = event.exception?.values?.[0]?.value || ''
- const exceptionType = event.exception?.values?.[0]?.type || ''
- const culprit = (event as any).culprit || ''
-
- const searchText = `${message} ${exceptionValue} ${exceptionType} ${culprit}`.toLowerCase()
-
- // Check all ignore patterns
- for (const patterns of Object.values(IGNORED_ERRORS)) {
- for (const pattern of patterns) {
- if (searchText.includes(pattern.toLowerCase())) {
- return true
- }
- }
- }
-
- // Ignore errors from browser extensions
- const frames = event.exception?.values?.[0]?.stacktrace?.frames || []
- for (const frame of frames) {
- const filename = frame.filename || ''
- if (
- filename.includes('chrome-extension://') ||
- filename.includes('moz-extension://') ||
- filename.includes('safari-extension://')
- ) {
- return true
- }
- }
-
- return false
-}
+import { beforeSendHandler } from './sentry.utils'
if (process.env.NODE_ENV !== 'development') {
Sentry.init({
@@ -82,21 +13,7 @@ if (process.env.NODE_ENV !== 'development') {
tracesSampleRate: 1,
debug: false,
- beforeSend(event) {
- // Filter out noise
- if (shouldIgnoreError(event)) {
- return null
- }
-
- // Clean sensitive headers from client-side events
- if (event.request?.headers) {
- delete event.request.headers['Authorization']
- delete event.request.headers['api-key']
- delete event.request.headers['cookie']
- }
-
- return event
- },
+ beforeSend: beforeSendHandler,
integrations: [
Sentry.captureConsoleIntegration({
diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts
index 454eaedaa..76debe9b5 100644
--- a/sentry.edge.config.ts
+++ b/sentry.edge.config.ts
@@ -5,6 +5,8 @@
import * as Sentry from '@sentry/nextjs'
+import { beforeSendHandler } from './sentry.utils'
+
if (process.env.NODE_ENV !== 'development') {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
@@ -12,13 +14,7 @@ if (process.env.NODE_ENV !== 'development') {
tracesSampleRate: 1,
debug: false,
- beforeSend(event) {
- if (event.request?.headers) {
- delete event.request.headers['Authorization']
- delete event.request.headers['api-key']
- }
- return event
- },
+ beforeSend: beforeSendHandler,
integrations: [
Sentry.captureConsoleIntegration({
diff --git a/sentry.server.config.ts b/sentry.server.config.ts
index c1fe9ac64..94d7bc640 100644
--- a/sentry.server.config.ts
+++ b/sentry.server.config.ts
@@ -4,17 +4,23 @@
import * as Sentry from '@sentry/nextjs'
+import { beforeSendHandler } from './sentry.utils'
+
if (process.env.NODE_ENV !== 'development') {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: true,
-
- // Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
-
- // Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
+ beforeSend: beforeSendHandler,
+
+ integrations: [
+ Sentry.captureConsoleIntegration({
+ levels: ['error', 'warn'],
+ }),
+ ],
+
// Uncomment the line below to enable Spotlight (https://spotlightjs.com)
spotlight: false,
})
diff --git a/sentry.utils.ts b/sentry.utils.ts
new file mode 100644
index 000000000..31b89dfea
--- /dev/null
+++ b/sentry.utils.ts
@@ -0,0 +1,104 @@
+// Shared Sentry utilities for filtering noise across all configs
+// Used by: sentry.client.config.ts, sentry.edge.config.ts, sentry.server.config.ts
+
+import type { ErrorEvent } from '@sentry/nextjs'
+
+/**
+ * Patterns to filter out from Sentry reporting.
+ * These are generally noise that doesn't require action.
+ */
+const IGNORED_ERRORS = {
+ // User-initiated cancellations (not bugs)
+ userRejected: [
+ 'User rejected',
+ 'user rejected',
+ 'User denied',
+ 'not allowed by the user',
+ 'User cancelled',
+ 'user cancelled',
+ 'Request rejected',
+ 'AbortError',
+ 'The operation was aborted',
+ ],
+ perks: ['This payment is not eligible for a perk'],
+
+ networkIssues: ['Network Error', 'Failed to fetch', 'Load failed'],
+
+ // Browser/extension noise (mostly client-side, but included for consistency)
+ browserNoise: [
+ 'ResizeObserver loop',
+ 'ResizeObserver loop limit exceeded',
+ 'Script error.',
+ // Extension interference
+ 'chrome-extension://',
+ 'moz-extension://',
+ 'safari-extension://',
+ ],
+
+ // Third-party scripts we don't control
+ thirdParty: ['googletagmanager', 'gtag', 'analytics', 'hotjar', 'clarity', 'intercom', 'crisp'],
+
+ // Third-party SDK internal errors (not actionable)
+ thirdPartySdkErrors: [
+ 'IndexedDB:Set:InternalError', // Vercel Analytics storage - fails in private browsing, not actionable
+ 'Analytics SDK:', // Vercel Analytics errors
+ ],
+}
+
+/**
+ * Check if error message matches any ignored pattern
+ */
+export function shouldIgnoreError(event: ErrorEvent): boolean {
+ const message = event.message || ''
+ const exceptionValue = event.exception?.values?.[0]?.value || ''
+ const exceptionType = event.exception?.values?.[0]?.type || ''
+ const culprit = (event as any).culprit || ''
+
+ const searchText = `${message} ${exceptionValue} ${exceptionType} ${culprit}`.toLowerCase()
+
+ // Check all ignore patterns
+ for (const patterns of Object.values(IGNORED_ERRORS)) {
+ for (const pattern of patterns) {
+ if (searchText.includes(pattern.toLowerCase())) {
+ return true
+ }
+ }
+ }
+
+ // Ignore errors from browser extensions (client-side only, but safe to check everywhere)
+ const frames = event.exception?.values?.[0]?.stacktrace?.frames || []
+ for (const frame of frames) {
+ const filename = frame.filename || ''
+ if (
+ filename.includes('chrome-extension://') ||
+ filename.includes('moz-extension://') ||
+ filename.includes('safari-extension://')
+ ) {
+ return true
+ }
+ }
+
+ return false
+}
+
+/**
+ * Clean sensitive headers from events
+ */
+export function cleanSensitiveHeaders(event: ErrorEvent): void {
+ if (event.request?.headers) {
+ delete event.request.headers['Authorization']
+ delete event.request.headers['api-key']
+ delete event.request.headers['cookie']
+ }
+}
+
+/**
+ * Standard beforeSend handler for all Sentry configs
+ */
+export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null {
+ if (shouldIgnoreError(event)) {
+ return null
+ }
+ cleanSensitiveHeaders(event)
+ return event
+}
diff --git a/src/app/(mobile-ui)/card-payment/page.tsx b/src/app/(mobile-ui)/card-payment/page.tsx
new file mode 100644
index 000000000..984d9b8c3
--- /dev/null
+++ b/src/app/(mobile-ui)/card-payment/page.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useSearchParams, useRouter } from 'next/navigation'
+import { chargesApi } from '@/services/charges'
+import Loading from '@/components/Global/Loading'
+
+/**
+ * Card Payment Route (DEPRECATED)
+ *
+ * This page is kept for backwards compatibility with existing URLs/bookmarks.
+ * The card flow now navigates directly to the semantic URL from /card page,
+ * avoiding this intermediate loading state.
+ *
+ * Fetches charge and redirects to semantic URL with context=card-pioneer
+ */
+export default function CardPaymentPage() {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ useEffect(() => {
+ const chargeId = searchParams.get('chargeId')
+ if (!chargeId) {
+ router.push('/card')
+ return
+ }
+
+ const redirectToPayment = async () => {
+ try {
+ const charge = await chargesApi.get(chargeId)
+
+ // Build semantic URL from charge data
+ // Format: /recipient@chainId/amountTOKEN?chargeId=uuid&context=card-pioneer
+ // NOTE: Use chargeId parameter (not id) to match semantic request flow
+ const recipient = charge.requestLink.recipientAddress
+ const chain = charge.chainId ? `@${charge.chainId}` : ''
+ const amount = charge.tokenAmount
+ const token = charge.tokenSymbol
+ const uuid = charge.uuid
+
+ const semanticUrl = `/${recipient}${chain}/${amount}${token}?chargeId=${uuid}&context=card-pioneer`
+
+ router.push(semanticUrl)
+ } catch (err) {
+ console.error('Failed to load charge:', err)
+ router.push('/card')
+ }
+ }
+
+ redirectToPayment()
+ }, [searchParams, router])
+
+ return (
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/card/card-pioneer.e2e.test.ts b/src/app/(mobile-ui)/card/card-pioneer.e2e.test.ts
new file mode 100644
index 000000000..7ede09adc
--- /dev/null
+++ b/src/app/(mobile-ui)/card/card-pioneer.e2e.test.ts
@@ -0,0 +1,63 @@
+import { test } from '@playwright/test'
+
+/**
+ * Card Pioneer E2E Tests
+ *
+ * Tests navigation flow through card pioneer purchase journey.
+ * Does NOT test actual payments (requires real transactions).
+ *
+ * Note: Card Pioneer pages require authentication. Unauthenticated users
+ * are redirected to /setup for onboarding.
+ *
+ * Flow: info → details → geo → purchase → success
+ */
+
+test.describe('Card Pioneer Flow', () => {
+ test('should redirect unauthenticated users to setup', async ({ page }) => {
+ // navigate to card pioneer page without auth
+ await page.goto('/card')
+
+ // wait for client-side redirect to occur (useEffect-based auth redirect)
+ await page.waitForURL(/\/setup/, { timeout: 10000 })
+ })
+
+ test('should redirect direct step navigation to setup when unauthenticated', async ({ page }) => {
+ // try to directly navigate to details step without auth
+ await page.goto('/card?step=details')
+
+ // wait for client-side redirect to occur
+ await page.waitForURL(/\/setup/, { timeout: 10000 })
+ })
+
+ test('should redirect purchase step to setup when unauthenticated', async ({ page }) => {
+ // try to access purchase step directly without auth
+ await page.goto('/card?step=purchase')
+
+ // wait for client-side redirect to occur
+ await page.waitForURL(/\/setup/, { timeout: 10000 })
+ })
+
+ test('should skip geo step if user is already eligible', async () => {
+ // this test requires mocking the card info API response
+ // skip for now - E2E tests shouldn't mock APIs extensively
+ test.skip()
+ })
+})
+
+test.describe('Card Pioneer Auth Gating', () => {
+ test('should require authentication to access card pioneer flow', async ({ page }) => {
+ // attempt to access card page directly
+ await page.goto('/card')
+
+ // wait for client-side redirect to occur
+ await page.waitForURL(/\/setup/, { timeout: 10000 })
+ })
+
+ test('should require authentication to access purchase flow', async ({ page }) => {
+ // attempt to access purchase step directly
+ await page.goto('/card?step=purchase')
+
+ // wait for client-side redirect to occur
+ await page.waitForURL(/\/setup/, { timeout: 10000 })
+ })
+})
diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx
new file mode 100644
index 000000000..594a6a76d
--- /dev/null
+++ b/src/app/(mobile-ui)/card/page.tsx
@@ -0,0 +1,214 @@
+'use client'
+import { type FC, useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useQueryStates, parseAsStringEnum } from 'nuqs'
+import { useQuery } from '@tanstack/react-query'
+import { cardApi, type CardInfoResponse } from '@/services/card'
+import { useAuth } from '@/context/authContext'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
+
+// Screen components
+import CardInfoScreen from '@/components/Card/CardInfoScreen'
+import CardGeoScreen from '@/components/Card/CardGeoScreen'
+import CardDetailsScreen from '@/components/Card/CardDetailsScreen'
+import CardSuccessScreen from '@/components/Card/CardSuccessScreen'
+import Loading from '@/components/Global/Loading'
+import { Button } from '@/components/0_Bruddle/Button'
+import PageContainer from '@/components/0_Bruddle/PageContainer'
+
+// Step types for the card pioneer flow
+// Flow: info -> details -> geo -> (payment page) -> success
+// Geo screen handles KYC verification prompt or eligibility blocking
+type CardStep = 'info' | 'details' | 'geo' | 'success'
+
+const STEP_ORDER: CardStep[] = ['info', 'details', 'geo', 'success']
+
+const CardPioneerPage: FC = () => {
+ const router = useRouter()
+ const { user, fetchUser } = useAuth()
+
+ // URL state for step navigation
+ // Example: /card?step=info or /card?step=success
+ const [urlState, setUrlState] = useQueryStates(
+ {
+ step: parseAsStringEnum(['info', 'details', 'geo', 'success']),
+ // Debug params for testing
+ debugStep: parseAsStringEnum(['info', 'details', 'geo', 'success']),
+ },
+ { history: 'replace' } // Use replace so back button exits flow instead of cycling steps
+ )
+
+ // Derive current step from URL (debug takes priority)
+ const currentStep: CardStep = urlState.debugStep ?? urlState.step ?? 'info'
+
+ // Purchase error state
+ const [purchaseError, setPurchaseError] = useState(null)
+
+ // Fetch card info
+ const {
+ data: cardInfo,
+ isLoading,
+ error: fetchError,
+ refetch: refetchCardInfo,
+ } = useQuery({
+ queryKey: ['card-info', user?.user?.userId],
+ queryFn: () => cardApi.getInfo(),
+ enabled: !!user?.user?.userId,
+ staleTime: 30_000, // 30 seconds
+ })
+
+ // Step navigation helpers
+ const goToStep = (step: CardStep) => {
+ setUrlState({ step })
+ }
+
+ // Redirect to success if already purchased
+ useEffect(() => {
+ if (cardInfo?.hasPurchased && currentStep !== 'success') {
+ setUrlState({ step: 'success' })
+ }
+ }, [cardInfo?.hasPurchased, currentStep, setUrlState])
+
+ // Note: Auto-skip removed - user must explicitly click "Reserve my card" button
+ // This prevents automatic redirects and gives user control over the purchase flow
+
+ // Refetch user data when arriving at success screen
+ // This ensures badge and other user data is up-to-date after payment
+ useEffect(() => {
+ if (currentStep === 'success') {
+ fetchUser()
+ refetchCardInfo()
+ }
+ }, [currentStep, fetchUser, refetchCardInfo])
+
+ // feature flag: redirect to home if card pioneers is disabled
+ useEffect(() => {
+ if (underMaintenanceConfig.disableCardPioneers) {
+ router.replace('/home')
+ }
+ }, [router])
+
+ if (underMaintenanceConfig.disableCardPioneers) {
+ return null
+ }
+
+ const goToNextStep = () => {
+ const currentIndex = STEP_ORDER.indexOf(currentStep)
+ if (currentIndex < STEP_ORDER.length - 1) {
+ goToStep(STEP_ORDER[currentIndex + 1])
+ }
+ }
+
+ const goToPreviousStep = () => {
+ const currentIndex = STEP_ORDER.indexOf(currentStep)
+ if (currentIndex > 0) {
+ goToStep(STEP_ORDER[currentIndex - 1])
+ } else {
+ router.back()
+ }
+ }
+
+ // Initiate purchase and navigate to payment page
+ const handleInitiatePurchase = async () => {
+ setPurchaseError(null)
+ try {
+ const response = await cardApi.purchase()
+ // Build semantic URL directly from response (avoids extra API call + loading state)
+ // Format: /recipient@chainId/amountTOKEN?chargeId=uuid&context=card-pioneer
+ const { recipientAddress, chainId, tokenAmount, tokenSymbol, chargeUuid } = response
+ const semanticUrl = `/${recipientAddress}@${chainId}/${tokenAmount}${tokenSymbol}?chargeId=${chargeUuid}&context=card-pioneer`
+ router.push(semanticUrl)
+ } catch (err) {
+ const error = err as { code?: string; message?: string }
+ if (error.code === 'ALREADY_PURCHASED') {
+ // User already purchased, redirect to success
+ handlePurchaseComplete()
+ return
+ }
+ // Show error to user
+ console.error('Purchase initiation failed:', err)
+ setPurchaseError(error.message || 'Failed to initiate purchase. Please try again.')
+ }
+ }
+
+ // Handle purchase completion (called when user already purchased)
+ const handlePurchaseComplete = () => {
+ refetchCardInfo()
+ fetchUser()
+ goToStep('success')
+ }
+
+ // Loading state - also show loading if we haven't determined purchase status yet
+ // This prevents flashing the info screen for users who have already purchased
+ if ((isLoading && !cardInfo) || (cardInfo?.hasPurchased && currentStep !== 'success')) {
+ return (
+
+
+
+ )
+ }
+
+ // Error state
+ if (fetchError) {
+ return (
+
+
Failed to load card info. Please try again.
+
refetchCardInfo()} variant="purple" shadowSize="4">
+ Retry
+
+
+ )
+ }
+
+ // Render the appropriate screen based on current step
+ // Flow: info -> details -> (payment page) -> success
+ // Note: geo step only shown if user is ineligible
+ const renderScreen = () => {
+ switch (currentStep) {
+ case 'info':
+ return (
+ goToNextStep()}
+ hasPurchased={cardInfo?.hasPurchased ?? false}
+ slotsRemaining={cardInfo?.slotsRemaining}
+ recentPurchases={cardInfo?.recentPurchases}
+ />
+ )
+ case 'details':
+ return (
+ goToNextStep()}
+ onBack={() => goToPreviousStep()}
+ />
+ )
+ case 'geo':
+ return (
+ goToNextStep()}
+ onInitiatePurchase={handleInitiatePurchase}
+ onBack={() => goToPreviousStep()}
+ purchaseError={purchaseError}
+ />
+ )
+ case 'success':
+ return router.push('/badges')} />
+ default:
+ return (
+ goToNextStep()}
+ hasPurchased={cardInfo?.hasPurchased ?? false}
+ slotsRemaining={cardInfo?.slotsRemaining}
+ recentPurchases={cardInfo?.recentPurchases}
+ />
+ )
+ }
+ }
+
+ return {renderScreen()}
+}
+
+export default CardPioneerPage
diff --git a/src/app/(mobile-ui)/dev/components/page.tsx b/src/app/(mobile-ui)/dev/components/page.tsx
new file mode 100644
index 000000000..edac175e3
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/components/page.tsx
@@ -0,0 +1,1398 @@
+'use client'
+
+import { useState } from 'react'
+import NavHeader from '@/components/Global/NavHeader'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
+import Divider from '@/components/0_Bruddle/Divider'
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
+import GlobalCard from '@/components/Global/Card'
+import BaseInput from '@/components/0_Bruddle/BaseInput'
+import BaseSelect from '@/components/0_Bruddle/BaseSelect'
+import Checkbox from '@/components/0_Bruddle/Checkbox'
+import CopyField from '@/components/Global/CopyField'
+import Loading from '@/components/Global/Loading'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import EmptyState from '@/components/Global/EmptyStates/EmptyState'
+import NoDataEmptyState from '@/components/Global/EmptyStates/NoDataEmptyState'
+import StatusBadge from '@/components/Global/Badges/StatusBadge'
+import StatusPill from '@/components/Global/StatusPill'
+import { useToast } from '@/components/0_Bruddle/Toast'
+import FlowHeader from '@/components/Global/FlowHeader'
+import Modal from '@/components/Global/Modal'
+import ActionModal from '@/components/Global/ActionModal'
+import Title from '@/components/0_Bruddle/Title'
+import CopyToClipboard from '@/components/Global/CopyToClipboard'
+import AddressLink from '@/components/Global/AddressLink'
+import MoreInfo from '@/components/Global/MoreInfo'
+import { Section, PropTable, CopySnippet, StatusTag } from './showcase-utils'
+
+const TOC: { id: string; label: string; icon: IconName }[] = [
+ { id: 'guidelines', label: 'Guidelines', icon: 'docs' },
+ { id: 'buttons', label: 'Buttons', icon: 'switch' },
+ { id: 'cards', label: 'Cards', icon: 'docs' },
+ { id: 'inputs', label: 'Inputs', icon: 'clip' },
+ { id: 'feedback', label: 'Feedback', icon: 'meter' },
+ { id: 'navigation', label: 'Navigation', icon: 'link' },
+ { id: 'layouts', label: 'Layouts', icon: 'switch' },
+ { id: 'patterns', label: 'Patterns', icon: 'bulb' },
+]
+
+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 ComponentsPage() {
+ const [inputValue, setInputValue] = useState('')
+ const [selectValue, setSelectValue] = useState('')
+ const [checkboxValue, setCheckboxValue] = useState(false)
+ const [showPeanutLoading, setShowPeanutLoading] = useState(false)
+ const [showModal, setShowModal] = useState(false)
+ const [showActionModal, setShowActionModal] = useState(false)
+ const { success, error, info, warning } = useToast()
+
+ return (
+
+
+
+
+
+ {/* sticky TOC */}
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ GUIDELINES ━━━━━━━━━━━━━━━━━━ */}
+
+
Guidelines & Legend
+
+ {/* legend */}
+
+
status tags
+
+
+
+ stable, widely used
+
+
+
+ {'< 5 usages'}
+
+
+
+ 0 production usages
+
+
+
+ works but needs cleanup
+
+
+
+
+ {/* design rules */}
+
+
design rules
+
+
buttons
+
+
+ primary CTA: variant="purple" shadowSize="4" w-full — NO size
+ prop
+
+ secondary CTA: variant="stroke" w-full
+
+ default h-13 is tallest. size="large" is h-10 — never for primary CTAs
+
+
+
+
+
text & links
+
+ primary text: text-n-1 | secondary: text-grey-1
+ inline links: text-black underline — never text-purple-1
+
+
+
+
containers
+
+
+ standalone: Bruddle Card (named export) | stacked lists: Global Card (default
+ export)
+
+ shadows: always black #000 | border radius: always rounded-sm
+
+
+
+
modals
+
+
+ informational: Modal | user action/confirmation: ActionModal | mobile interaction:
+ Drawer
+
+
+
+
+
loading
+
+
+ inline spinner: Loading | page-level branded: PeanutLoading | with entertainment:
+ PeanutFactsLoading
+
+
+
+
+
messaging
+
+
+ card deposits: "starter balance" — never "card balance" or
+ "Peanut rewards"
+
+
+
+
+
+ {/* three-tier architecture */}
+
+
architecture (three tiers)
+
+
+ Bruddle primitives —
+ src/components/0_Bruddle/ — Button, Card, BaseInput, BaseSelect, Checkbox, Divider,
+ Title, Toast
+
+
+ Global shared — src/components/Global/ —
+ NavHeader, FlowHeader, Modal, ActionModal, Drawer, Loading, PeanutLoading, StatusBadge,
+ EmptyState, CopyField, Icon, AddressLink, MoreInfo, etc.
+
+
+ Tailwind classes — .row, .col, .shadow-2,
+ .shadow-4, .label-*, .brutal-border, .bg-peanut-repeat-*
+
+
+
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ BUTTONS ━━━━━━━━━━━━━━━━━━ */}
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ CARDS ━━━━━━━━━━━━━━━━━━ */}
+
+
+ standalone containers with optional shadow. named export.
+
+
+
+ no shadow
+
+ content`} />
+
+
+ shadowSize="4"
+
+ content`} />
+
+
+ shadowSize="6"
+
+
+ shadowSize="8"
+
+
+ with sub-components
+
+
+ Card Title
+ description text
+
+
+ body content
+
+
+
+
+ Title
+ Description
+
+ Content
+`}
+ />
+
+
+
+
+ for stacked list items with position-aware borders. default export. heavily used across the
+ app.
+
+ void', '(none)'],
+ ]}
+ />
+
+
+ position="single"
+
+ content`} />
+
+ stacked list
+
+
+ position="first"
+
+
+ position="middle"
+
+
+ position="middle"
+
+
+ position="last"
+
+
+
+ First
+ Middle
+ Last
+ `}
+ />
+
+
no border
+
+ border=false
+
+
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ INPUTS ━━━━━━━━━━━━━━━━━━ */}
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ FEEDBACK ━━━━━━━━━━━━━━━━━━ */}
+
+
+
+ simple css spinner. default h-4 w-4. clean, minimal, no deps.
+
+
+ {['h-4 w-4', 'h-8 w-8', 'h-12 w-12'].map((size) => (
+
+
+ {size}
+
+ ))}
+
+ `} />
+
+
+
+
+ branded loading with animated peanutman logo. optional fullscreen overlay and message.
+
+
+
+ `} />
+ {showPeanutLoading && }
+ {
+ setShowPeanutLoading(true)
+ setTimeout(() => setShowPeanutLoading(false), 2000)
+ }}
+ >
+ test fullscreen (2s)
+
+
+
+
+
+ context-based toast system. 4 types. auto-dismiss. clean API.
+
+
+ success('Operation successful!')}>
+ success
+
+ error('Something went wrong')}>
+ error
+
+ info('Did you know?')}>
+ info
+
+ warning('Check this out')}>
+ warning
+
+
+
+
+
+
+
+ color-coded text badge. 9 status types. 3 sizes. well-structured.
+
+
+ {(
+ [
+ 'completed',
+ 'pending',
+ 'processing',
+ 'failed',
+ 'cancelled',
+ 'refunded',
+ 'soon',
+ 'closed',
+ ] as const
+ ).map((s) => (
+
+ ))}
+
+ `} />
+ sizes
+
+
+
+
+
+
+
+
+
+ compact circular icon indicator. smaller than StatusBadge.
+
+
+ {(
+ [
+ 'completed',
+ 'pending',
+ 'processing',
+ 'failed',
+ 'cancelled',
+ 'refunded',
+ 'soon',
+ 'closed',
+ ] as const
+ ).map((s) => (
+
+
+ {s}
+
+ ))}
+
+ `} />
+
+
+
+ inline error message display. simple, clean.
+
+ `} />
+
+
+
+
+ structured empty state with icon, title, description, optional CTA.
+
+
+ `} />
+
+
+
+ branded empty state with crying peanutman animation.
+
+ `} />
+
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ NAVIGATION ━━━━━━━━━━━━━━━━━━ */}
+
+
+
+ primary navigation header. used on nearly every mobile screen.
+
+ void', '(none)'],
+ ['icon', 'IconName', 'chevron-up'],
+ ['disableBackBtn', 'boolean', 'false'],
+ ['showLogoutBtn', 'boolean', 'false'],
+ ]}
+ />
+
+ {}} />
+
+ router.back()} />`} />
+
+ {}} showLogoutBtn />
+
+
+
+
+
+ minimal header for multi-step flows. back button + optional right element.
+
+
+ {}}
+ rightElement={Step 2/3 }
+ />
+
+ Step 2/3} />`} />
+
+
+
+
+ base dialog wrapper using @headlessui. backdrop, transitions, close button.
+
+ setShowModal(true)}>
+ open modal
+
+ setShow(false)} title="Modal Title">
+ Content here
+`}
+ />
+ setShowModal(false)} title="Example Modal">
+
+ base modal component. has backdrop overlay, animated transitions, and close button.
+
+
+
+
+
+
+ enhanced modal with structured layout: icon + title + description + buttons + optional
+ checkbox.
+
+ setShowActionModal(true)}>
+ open action modal
+
+ setShow(false)}
+ title="Confirm Action"
+ description="Are you sure?"
+ icon="alert"
+ ctas={[
+ { text: 'Confirm', variant: 'purple', onClick: handleConfirm },
+ { text: 'Cancel', variant: 'stroke', onClick: () => setShow(false) },
+ ]}
+/>`}
+ />
+ setShowActionModal(false)}
+ title="Confirm Action"
+ description="are you sure you want to proceed?"
+ icon="alert"
+ ctas={[
+ {
+ text: 'Confirm',
+ variant: 'purple' as const,
+ onClick: () => setShowActionModal(false),
+ },
+ {
+ text: 'Cancel',
+ variant: 'stroke' as const,
+ onClick: () => setShowActionModal(false),
+ },
+ ]}
+ />
+
+
+
+
+ vaul-based bottom drawer. compound component API. mobile-optimized with snap points.
+
+
+
+ Open
+
+
+ Title
+ Content
+
+`}
+ />
+
+
+
+
+
+ Modal — base dialog (informational content)
+
+
+ ActionModal — structured
+ confirmations/actions
+
+
+ Drawer — bottom sheet (mobile-first
+ interactions)
+
+
+ InviteFriendsModal — referral sharing
+
+
+ ConfirmInviteModal — invite confirmation
+
+
+ GuestLoginModal — guest auth flow
+
+
+ KycVerifiedOrReviewModal — KYC status
+
+
+ BalanceWarningModal — low balance warning
+
+
+ TokenAndNetworkConfirmationModal — tx
+ confirmation
+
+
+
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ LAYOUTS ━━━━━━━━━━━━━━━━━━ */}
+
+
+
+ all mobile screens use min-h-[inherit] from the app
+ shell. these are the standard patterns for arranging NavHeader + content + CTA.
+
+
+
+ CTA buttons always go INSIDE the my-auto wrapper so they center as a group with the content.
+ never leave CTA as a sibling of the content div.
+
+
+
+
+
+ most common layout. content + CTA grouped and vertically centered. used in card flow,
+ confirmation screens, empty states.
+
+
+ {/* live demo */}
+
+
+
+
+
+ {/* content */}
+ ...
+ {/* CTA — inside my-auto wrapper */}
+
+ Continue
+
+
+ `}
+ />
+
+
+
+
+ CTA pinned to bottom, content centered above. used for success screens, landing pages where
+ CTA should always be visible.
+
+
+ {/* live demo */}
+
+
+
+
+
+ {/* content centers itself */}
+
+ {/* CTA pinned to bottom via justify-between */}
+
+ Done
+
+ `}
+ />
+
+
+
+
+ for long lists. CTA at bottom after content, no forced centering. used in history, settings,
+ transaction lists.
+
+
+ {/* live demo */}
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+
+ {items.map(item => ... )}
+
+ `}
+ />
+
+
+
+
+
+
+
+
CTA as sibling of my-auto div
+
+ button gets pushed to bottom by gap, not grouped with content
+
+
+
+
+
+
+
justify-between when you want grouped centering
+
+ pins CTA to bottom instead of keeping it close to content
+
+
+
+
+
+
+
using space-y-8 on outer div
+
conflicts with flex centering. use gap-8 instead
+
+
+
+
+
+
CTA inside my-auto wrapper
+
content + CTA center as one unit
+
+
+
+
+
+
+
+
+ {/* ━━━━━━━━━━━━━━━━━━ PATTERNS ━━━━━━━━━━━━━━━━━━ */}
+
+
+ material design icons. tap any icon name to copy.
+
+ {ALL_ICONS.map((name) => (
+ navigator.clipboard.writeText(name)}
+ className="flex flex-col items-center gap-0.5 rounded-sm border border-n-1/10 p-1.5 hover:border-n-1/40"
+ >
+
+ {name}
+
+ ))}
+
+ `} />
+
+
+
+
+ from tailwind.config.js — names can be misleading. tap to copy class name.
+
+
+ {[
+ ['purple-1', 'bg-purple-1', '#FF90E8', 'pink!'],
+ ['primary-3', 'bg-primary-3', '#EFE4FF', 'lavender'],
+ ['primary-4', 'bg-primary-4', '#D8C4F6', 'deeper lavender'],
+ ['yellow-1', 'bg-yellow-1', '#FFC900', 'peanut yellow'],
+ ['green-1', 'bg-green-1', '#98E9AB', 'success green'],
+ ['n-1', 'bg-n-1', '#000000', 'black'],
+ ['grey-1', 'bg-grey-1', '#6B6B6B', 'secondary text'],
+ ['teal-1', 'bg-teal-1', '#C3F5E4', 'teal'],
+ ['violet-1', 'bg-violet-1', '#A78BFA', 'violet'],
+ ['error-1', 'bg-error-1', '#FF6B6B', 'error red'],
+ ['success-3', 'bg-success-3', '#4ADE80', 'success bg'],
+ ['secondary-1', 'bg-secondary-1', '#FFC900', 'same as yellow-1'],
+ ].map(([name, bg, hex, note]) => (
+
navigator.clipboard.writeText(bg)}
+ className="flex items-center gap-2 rounded-sm border border-n-1/20 p-2 text-left hover:border-n-1/40"
+ >
+
+
+
{name}
+
+ {hex} · {note}
+
+
+
+ ))}
+
+
+
+
+
+
+ .bg-peanut-repeat-normal
+
+
+
+ .bg-peanut-repeat-large
+
+
+ .bg-peanut-repeat-small
+
+
+
+
+
+
+
+
+
+
+ CopyToClipboard
+
+
+
icon or button mode. 2s checkmark feedback.
+
+
+ icon mode (default)
+
+
`}
+ />
+
+
+
+ ShareButton
+
+
+
+ web share API with clipboard fallback. async URL generation.
+
+
`}
+ />
+
+
+
+
+
+
+
+
+ AddressLink
+
+
+
+ shortened address with ENS resolution and profile link.
+
+
+
`}
+ />
+
+
+
+ MoreInfo (Tooltip)
+
+
+
+ info icon with smart-positioned tooltip. portal-rendered.
+
+
+ some label
+
+
+
`}
+ />
+
+
+
+
+
+
+ countries are represented using flagcdn.com images + country data from AddMoney/consts.
+
+
+
+ CountryList — searchable country list with
+ geolocation sorting, flag images, and status badges.
+
+
+
+ CountryFlagAndName — single country display with
+ flag. supports multi-flag for bridge regions.
+
+
+
+ flag images pattern
+
+
`}
+ />
+
+
+
+
+
+
+
+
+
layout
+
.row — flex items-center gap-2
+
.col — flex flex-col gap-2
+
+
+
shadows
+
+
.shadow-2
+
.shadow-4
+
+
+
+
labels
+
+ {['label-stroke', 'label-purple', 'label-yellow', 'label-black', 'label-teal'].map(
+ (cls) => (
+
+ {cls.replace('label-', '')}
+
+ )
+ )}
+
+
+
+
borders
+
.brutal-border — 2px solid black
+
border border-n-1 — standard 1px black
+
rounded-sm — standard border radius
+
+
+
icon sizes
+
+ .icon-16 .icon-18 .icon-20 .icon-22 .icon-24 .icon-28
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/dev/components/showcase-utils.tsx b/src/app/(mobile-ui)/dev/components/showcase-utils.tsx
new file mode 100644
index 000000000..bfc2cd6b7
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/components/showcase-utils.tsx
@@ -0,0 +1,127 @@
+'use client'
+
+import { useState } from 'react'
+import { Icon } from '@/components/Global/Icons/Icon'
+
+// copy code snippet to clipboard with visual feedback
+export const CopySnippet = ({ code }: { code: string }) => {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+
+ {code}
+
+ {copied ? : }
+
+
+ )
+}
+
+// production readiness badge
+export const StatusTag = ({ status }: { status: 'production' | 'limited' | 'unused' | 'needs-refactor' }) => {
+ 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',
+ }
+ return (
+
+ {labels[status]}
+
+ )
+}
+
+// quality score stars (1-5)
+export const QualityScore = ({ score, label }: { score: 1 | 2 | 3 | 4 | 5; label?: string }) => {
+ const descriptions: Record = {
+ 1: 'needs rewrite',
+ 2: 'works but messy',
+ 3: 'decent',
+ 4: 'clean',
+ 5: 'elegant',
+ }
+ return (
+
+ {'★'.repeat(score)}
+ {'☆'.repeat(5 - score)}
+ {label && {label} }
+
+ )
+}
+
+// usage count badge
+export const UsageCount = ({ count }: { count: number }) => (
+
+ {count} usage{count !== 1 ? 's' : ''}
+
+)
+
+// section wrapper with title, status, quality, and usage count
+export const Section = ({
+ title,
+ id,
+ status,
+ quality,
+ usages,
+ importPath,
+ children,
+}: {
+ title: string
+ id?: string
+ status?: 'production' | 'limited' | 'unused' | 'needs-refactor'
+ quality?: 1 | 2 | 3 | 4 | 5
+ usages?: number
+ importPath?: string
+ children: React.ReactNode
+}) => (
+
+
+
{title}
+ {status && }
+ {quality && }
+ {usages !== undefined && }
+
+ {importPath &&
}
+ {children}
+
+)
+
+// props table
+export const PropTable = ({ rows }: { rows: [string, string, string][] }) => (
+
+
+
+
+ prop
+ options
+ default
+
+
+
+ {rows.map(([prop, options, def]) => (
+
+ {prop}
+ {options}
+ {def}
+
+ ))}
+
+
+
+)
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..17d29d2bc
--- /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..be340465e
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/CodeBlock.tsx
@@ -0,0 +1,37 @@
+'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..8de7cb3f9
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/DoDont.tsx
@@ -0,0 +1,33 @@
+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..217a0ca37
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/_components/PropsTable.tsx
@@ -0,0 +1,47 @@
+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..de94424e1
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/borders/page.tsx
@@ -0,0 +1,87 @@
+'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..3452e1d5d
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/colors/page.tsx
@@ -0,0 +1,124 @@
+'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..0bf1f13e2
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/icons/page.tsx
@@ -0,0 +1,148 @@
+'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..1bcc89a44
--- /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..bda11777a
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/shadows/page.tsx
@@ -0,0 +1,78 @@
+'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..167aba3d0
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/spacing/page.tsx
@@ -0,0 +1,87 @@
+'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..b16cbf5ae
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/foundations/typography/page.tsx
@@ -0,0 +1,99 @@
+'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..b9e5f57ff
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/page.tsx
@@ -0,0 +1,115 @@
+'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..3b4727980
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/amount-input/page.tsx
@@ -0,0 +1,223 @@
+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..99be830d4
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/cards-global/page.tsx
@@ -0,0 +1,201 @@
+'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..970bf8caf
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/copy-share/page.tsx
@@ -0,0 +1,286 @@
+'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..b2eeb3a1d
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/drawer/page.tsx
@@ -0,0 +1,178 @@
+'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..2a1f73973
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/feedback/page.tsx
@@ -0,0 +1,312 @@
+'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..32e70b78b
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/layouts/page.tsx
@@ -0,0 +1,289 @@
+'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..3929ad9ef
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/loading/page.tsx
@@ -0,0 +1,145 @@
+'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..e16c79855
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx
@@ -0,0 +1,327 @@
+'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..2905d1f34
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/patterns/navigation/page.tsx
@@ -0,0 +1,188 @@
+'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..023c23901
--- /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..28ba2c1ae
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-input/page.tsx
@@ -0,0 +1,105 @@
+'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..5b6b7d5fe
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/base-select/page.tsx
@@ -0,0 +1,83 @@
+'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..7044164ec
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/button/page.tsx
@@ -0,0 +1,342 @@
+'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..0676a85ec
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/card/page.tsx
@@ -0,0 +1,111 @@
+'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..07ca12c08
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/checkbox/page.tsx
@@ -0,0 +1,57 @@
+'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..fd1c7b495
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/divider/page.tsx
@@ -0,0 +1,47 @@
+'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..669fe696d
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/page-container/page.tsx
@@ -0,0 +1,52 @@
+'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..224b1c9c3
--- /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..c891baf94
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/title/page.tsx
@@ -0,0 +1,56 @@
+'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..dc280dc89
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/ds/primitives/toast/page.tsx
@@ -0,0 +1,75 @@
+'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/layout.tsx b/src/app/(mobile-ui)/dev/layout.tsx
index 087150b36..ee6026ff9 100644
--- a/src/app/(mobile-ui)/dev/layout.tsx
+++ b/src/app/(mobile-ui)/dev/layout.tsx
@@ -2,17 +2,20 @@
import { usePathname } from 'next/navigation'
import { notFound } from 'next/navigation'
-import { IS_DEV } from '@/constants/general.consts'
+import { BASE_URL } from '@/constants/general.consts'
-// Routes that are allowed in production (protected by API key / user check)
+// Routes allowed on peanut.me (production). All /dev routes are available elsewhere
+// (localhost, staging, Vercel preview deploys).
const PRODUCTION_ALLOWED_ROUTES = ['/dev/full-graph', '/dev/payment-graph']
+const IS_PROD_DOMAIN = BASE_URL === 'https://peanut.me'
+
export default function DevLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
- // In production, only allow specific routes (full-graph, payment-graph)
- // Other dev tools (leaderboard, shake-test, dev index) are dev-only
- if (!IS_DEV) {
+ // On peanut.me, only allow specific routes (full-graph, payment-graph)
+ // On staging, Vercel previews, and localhost, all /dev routes are accessible
+ if (IS_PROD_DOMAIN) {
const isAllowedInProd = PRODUCTION_ALLOWED_ROUTES.some((route) => pathname?.startsWith(route))
if (!isAllowedInProd) {
notFound()
diff --git a/src/app/(mobile-ui)/dev/page.tsx b/src/app/(mobile-ui)/dev/page.tsx
index 90088522b..e2f74bb67 100644
--- a/src/app/(mobile-ui)/dev/page.tsx
+++ b/src/app/(mobile-ui)/dev/page.tsx
@@ -3,86 +3,80 @@
import Card from '@/components/Global/Card'
import NavHeader from '@/components/Global/NavHeader'
import Link from 'next/link'
-import { Icon } from '@/components/Global/Icons/Icon'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
export default function DevToolsPage() {
- const tools = [
+ const tools: { name: string; description: string; path: string; icon: IconName }[] = [
{
name: 'Points Leaderboard',
description: 'Real-time leaderboard with customizable time filters for event competitions',
path: '/dev/leaderboard',
- icon: '🏆',
- status: 'active',
+ icon: 'trophy',
},
{
name: 'Full Graph',
description:
'Interactive force-directed graph visualization of all users, invites, and P2P activity (admin only)',
path: '/dev/full-graph',
- icon: '🕸️',
- status: 'active',
+ icon: 'globe-lock',
},
{
name: 'Payment Graph',
description: 'P2P payment flow visualization',
path: '/dev/payment-graph',
- icon: '💸',
- status: 'active',
+ icon: 'dollar',
},
{
- name: 'Shake Test',
- description: 'Test progressive shake animation and confetti for perk claiming',
- path: '/dev/shake-test',
- icon: '🧪',
- status: 'active',
+ name: 'Design System',
+ description: 'Foundations, primitives, patterns, and interactive playground',
+ path: '/dev/ds',
+ icon: 'docs',
},
- // Add more dev tools here in the future
]
return (
-
-
+
+
+
+
-
-
- Developer Tools
-
- Internal testing tools and components. Publicly accessible for multi-device testing.
-
-
+
+
+ Internal testing tools and components. Publicly accessible for multi-device testing.
+
-
+
{tools.map((tool) => (
-
+
-
-
{tool.icon}
+
+
+
+
-
{tool.name}
-
{tool.description}
- {tool.status === 'active' && (
-
- Active
-
- )}
+
{tool.name}
+
{tool.description}
-
+
))}
-
- ℹ️ Info
-
- • These tools are only available in development mode
- • Perfect for testing on multiple devices
- • Share the URL with team members for testing
+
+
+
+ Info
+
+
+ These tools are only available in development mode
+ Perfect for testing on multiple devices
+ Share the URL with team members for testing
-
+
)
diff --git a/src/app/(mobile-ui)/dev/perk-success-test/page.tsx b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx
new file mode 100644
index 000000000..0df4e22a9
--- /dev/null
+++ b/src/app/(mobile-ui)/dev/perk-success-test/page.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Card } from '@/components/0_Bruddle/Card'
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import GlobalCard from '@/components/Global/Card'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { SoundPlayer } from '@/components/Global/SoundPlayer'
+import { useHaptic } from 'use-haptic'
+import { shootDoubleStarConfetti } from '@/utils/confetti'
+import { extractInviteeName } from '@/utils/general.utils'
+
+type MockPerk = {
+ id: string
+ name: string
+ amountUsd: number
+ reason: string
+}
+
+const MOCK_PERKS: MockPerk[] = [
+ {
+ id: 'mock-1',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Alice became a Card Pioneer',
+ },
+ {
+ id: 'mock-2',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Bob became a Card Pioneer',
+ },
+ {
+ id: 'mock-3',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Charlie became a Card Pioneer',
+ },
+ {
+ id: 'mock-4',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 10,
+ reason: 'Diana became a Card Pioneer (bonus!)',
+ },
+ {
+ id: 'mock-5',
+ name: 'Card Pioneer Inviter Reward',
+ amountUsd: 5,
+ reason: 'Eve became a Card Pioneer',
+ },
+]
+
+export default function PerkSuccessTestPage() {
+ const [currentPerkIndex, setCurrentPerkIndex] = useState(0)
+ const [showSuccess, setShowSuccess] = useState(false)
+ const [canDismiss, setCanDismiss] = useState(false)
+ const [isExiting, setIsExiting] = useState(false)
+ const [playSound, setPlaySound] = useState(false)
+ const { triggerHaptic } = useHaptic()
+
+ const currentPerk = MOCK_PERKS[currentPerkIndex]
+
+ const handleShowSuccess = () => {
+ setShowSuccess(true)
+ setCanDismiss(false)
+ setIsExiting(false)
+ setPlaySound(true)
+ triggerHaptic()
+ shootDoubleStarConfetti({ origin: { x: 0.5, y: 0.4 } })
+
+ // Enable dismiss after 2 seconds
+ setTimeout(() => setCanDismiss(true), 2000)
+ }
+
+ const handleDismiss = () => {
+ if (!canDismiss) return
+
+ setIsExiting(true)
+ setTimeout(() => {
+ setShowSuccess(false)
+ setPlaySound(false)
+ // Move to next perk
+ setCurrentPerkIndex((prev) => (prev + 1) % MOCK_PERKS.length)
+ }, 400)
+ }
+
+ const inviteeName = extractInviteeName(currentPerk.reason)
+
+ return (
+
+
+
+
+ {/* Instructions */}
+
+ Test the perk claim success screen
+
+ 1. Click "Trigger Success" to show the success screen
+ 2. Wait 2 seconds before you can dismiss (debounce)
+ 3. Tap to dismiss and load next mock perk
+
+
+
+ {/* Current Perk Info */}
+
+
+ Current Mock Perk ({currentPerkIndex + 1}/{MOCK_PERKS.length})
+
+ ID: {currentPerk.id}
+ Amount: ${currentPerk.amountUsd}
+ Reason: {currentPerk.reason}
+
+
+ {/* Trigger Button */}
+ {!showSuccess && (
+
+ Trigger Success
+
+ )}
+
+ {/* Success Screen Preview */}
+ {showSuccess && (
+
+
+ SUCCESS SCREEN PREVIEW (tap to dismiss when ready)
+
+
+
+ {playSound &&
}
+
+ {/* Success card - full width, matches PaymentSuccessView */}
+
+ {/* Check icon */}
+
+
+
+
+ {/* Text content */}
+
+
You received
+
+${currentPerk.amountUsd}
+
+
+ {inviteeName}
+ joined Pioneers
+
+
+
+
+ {/* Tap to continue - fades in when ready */}
+
+ Tap to continue
+
+
+
+ )}
+
+ {/* Quick Actions */}
+
+ setCurrentPerkIndex((prev) => (prev + 1) % MOCK_PERKS.length)}
+ className="flex-1"
+ >
+ Next Perk
+
+ {
+ setShowSuccess(false)
+ setPlaySound(false)
+ setCurrentPerkIndex(0)
+ }}
+ className="flex-1"
+ >
+ Reset
+
+
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index f3a542d54..e4ed39a98 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -27,12 +27,14 @@ import { useClaimBankFlow } from '@/context/ClaimBankFlowContext'
import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
import { useNotifications } from '@/hooks/useNotifications'
import useKycStatus from '@/hooks/useKycStatus'
+import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo'
import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA'
import InvitesIcon from '@/components/Home/InvitesIcon'
import NavigationArrow from '@/components/Global/NavigationArrow'
import { updateUserById } from '@/app/actions/users'
import { useHaptic } from 'use-haptic'
import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
// Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size
// Components are only loaded when user triggers them
@@ -43,6 +45,7 @@ const NoMoreJailModal = lazy(() => import('@/components/Global/NoMoreJailModal')
const EarlyUserModal = lazy(() => import('@/components/Global/EarlyUserModal'))
const KycCompletedModal = lazy(() => import('@/components/Home/KycCompletedModal'))
const IosPwaInstallModal = lazy(() => import('@/components/Global/IosPwaInstallModal'))
+const CardPioneerModal = lazy(() => import('@/components/Card/CardPioneerModal'))
const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500')
const BALANCE_WARNING_EXPIRY = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_EXPIRY ?? '1814400') // 21 days in seconds
@@ -64,6 +67,11 @@ export default function Home() {
const { isFetchingUser, fetchUser } = useAuth()
const { isUserKycApproved } = useKycStatus()
+ const {
+ hasPurchased: hasCardPioneerPurchased,
+ isLoading: isCardInfoLoading,
+ error: cardInfoError,
+ } = useCardPioneerInfo()
const username = user?.user.username
const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false)
@@ -71,6 +79,13 @@ export default function Home() {
const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false)
const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false)
+ // Track if this is a fresh signup session - captured once on mount so it persists
+ // even after NoMoreJailModal clears the sessionStorage key
+ const [isPostSignupSession] = useState(() => {
+ if (typeof window === 'undefined') return false
+ return sessionStorage.getItem('showNoMoreJailModal') === 'true'
+ })
+
// sync modal state with user data when it changes
useEffect(() => {
if (user?.user.showKycCompletedModal !== undefined) {
@@ -260,6 +275,25 @@ 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 and card info loaded successfully */}
+ {!underMaintenanceConfig.disableCardPioneers &&
+ !isCardInfoLoading &&
+ !cardInfoError &&
+ !showBalanceWarningModal &&
+ !showPermissionModal &&
+ !showKycModal &&
+ !isPostSignupActionModalVisible &&
+ !user?.showEarlyUserModal &&
+ !isPostSignupSession && (
+
+
+
+
+
+ )}
+
{/* Referral Campaign Modal - DISABLED FOR NOW */}
{/*
{
const pathName = usePathname()
// Allow access to public paths without authentication
- const isPublicPath = PUBLIC_ROUTES_REGEX.test(pathName)
+ // Dev test pages (gift-test, shake-test) are only public in dev mode
+ const isPublicPath = isPublicRoute(pathName, IS_DEV)
- const { isFetchingUser, user } = useAuth()
+ const { isFetchingUser, user, userFetchError } = useAuth()
const [isReady, setIsReady] = useState(false)
const isUserLoggedIn = !!user?.user.userId || false
const isHome = pathName === '/home'
const isHistory = pathName === '/history'
const isSupport = pathName === '/support'
+ const isDev = pathName?.startsWith('/dev') ?? false
const alignStart = isHome || isHistory || isSupport
const router = useRouter()
const { showIosPwaInstallScreen } = useSetupStore()
@@ -99,6 +103,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
return
}
+ // show backend error screen when user fetch fails after retries
+ // user can retry or force logout to clear stale state
+ if (userFetchError && !isFetchingUser && !isPublicPath) {
+ return
+ }
+
// For public paths, skip user loading and just show content when ready
if (isPublicPath) {
if (!isReady) {
@@ -135,22 +145,26 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Sidebar - Fixed on desktop */}
-
-
+ )}
{/* Main content area */}
{/* Banner component handles maintenance and feedback banners */}
-
+ {!isDev &&
}
{/* Fixed top navbar */}
-
-
-
+ {!isDev && (
+
+
+
+ )}
{/* Scrollable content area */}
{
'relative flex-1 overflow-y-auto bg-background p-6 pb-24 md:pb-6',
!!isSupport && 'p-0 pb-20 md:p-6',
!!isHome && 'p-0 md:p-6 md:pr-0',
- isUserLoggedIn ? 'pb-24' : 'pb-4'
+ isUserLoggedIn ? 'pb-24' : 'pb-4',
+ isDev && 'p-0 pb-0'
)
)}
>
@@ -170,7 +185,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
'flex w-full items-center justify-center md:ml-auto md:w-[calc(100%-160px)]',
alignStart && 'items-start',
isSupport && 'h-full',
- isUserLoggedIn ? 'min-h-[calc(100dvh-160px)]' : 'min-h-[calc(100dvh-64px)]'
+ isUserLoggedIn ? 'min-h-[calc(100dvh-160px)]' : 'min-h-[calc(100dvh-64px)]',
+ isDev && 'min-h-[100dvh] items-start justify-start md:ml-0 md:w-full'
)}
>
{children}
@@ -179,9 +195,11 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Mobile navigation */}
-
-
-
+ {!isDev && (
+
+
+
+ )}
diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx
index 949777c86..3a543e76f 100644
--- a/src/app/(mobile-ui)/points/invites/page.tsx
+++ b/src/app/(mobile-ui)/points/invites/page.tsx
@@ -16,10 +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 { 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,
@@ -32,6 +39,17 @@ const InvitesPage = () => {
enabled: !!user?.user.userId,
})
+ const totalPointsEarned =
+ invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
+ return sum + (invite.contributedPoints ?? 0)
+ }, 0) || 0
+
+ const animatedTotal = useCountUp(totalPointsEarned, {
+ storageKey: 'invites_total',
+ duration: 1.8,
+ enabled: !isLoading && !isError,
+ })
+
if (isLoading) {
return
}
@@ -45,12 +63,6 @@ const InvitesPage = () => {
)
}
- // Calculate total points earned (20% of each invitee's points)
- const totalPointsEarned =
- invites?.invitees?.reduce((sum: number, invite: PointsInvite) => {
- return sum + Math.floor(invite.totalPoints * 0.2)
- }, 0) || 0
-
return (
router.back()} />
@@ -62,7 +74,7 @@ const InvitesPage = () => {
- {totalPointsEarned} {totalPointsEarned === 1 ? 'Point' : 'Points'}
+ {formatPoints(animatedTotal)} {totalPointsEarned === 1 ? 'Point' : 'Points'}
@@ -70,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 * 0.2)
+ const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
@@ -103,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 6f6ec33ec..7690531a5 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -3,17 +3,15 @@
import PageContainer from '@/components/0_Bruddle/PageContainer'
import Card from '@/components/Global/Card'
import { getCardPosition } from '@/components/Global/Card/card.utils'
-import CopyToClipboard from '@/components/Global/CopyToClipboard'
import { Icon } from '@/components/Global/Icons/Icon'
import NavHeader from '@/components/Global/NavHeader'
import NavigationArrow from '@/components/Global/NavigationArrow'
import PeanutLoading from '@/components/Global/PeanutLoading'
-import ShareButton from '@/components/Global/ShareButton'
import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionAvatarBadge'
import { VerifiedUserLabel } from '@/components/UserHeader'
import { useAuth } from '@/context/authContext'
import { invitesApi } from '@/services/invites'
-import { generateInviteCodeLink, generateInvitesShareText, getInitialsFromName } from '@/utils/general.utils'
+import { getInitialsFromName } from '@/utils/general.utils'
import { useQuery } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { STAR_STRAIGHT_ICON, TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE } from '@/assets'
@@ -21,13 +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 } from 'react'
+import { useEffect, useRef, useState } from 'react'
import InvitesGraph from '@/components/Global/InvitesGraph'
-import { IS_DEV } from '@/constants/general.consts'
+import { CashCard } from '@/components/Points/CashCard'
+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]
@@ -55,18 +62,31 @@ const PointsPage = () => {
enabled: !!user?.user.userId,
})
- // In dev mode, show graph for all users. In production, only for Seedling badge holders.
- const hasSeedlingBadge = user?.user?.badges?.some((badge) => badge.code === 'SEEDLING_DEVCONNECT_BA_2025')
+ // Referral graph is now available for all users
const { data: myGraphResult } = useQuery({
queryKey: ['myInviteGraph', user?.user.userId],
queryFn: () => pointsApi.getUserInvitesGraph(),
- enabled: !!user?.user.userId && (IS_DEV || hasSeedlingBadge),
+ enabled: !!user?.user.userId,
+ })
+
+ // Cash status (comprehensive earnings tracking)
+ const { data: cashStatus } = useQuery({
+ queryKey: ['cashStatus', user?.user.userId],
+ queryFn: () => pointsApi.getCashStatus(),
+ enabled: !!user?.user.userId,
})
+
const username = user?.user.username
- const { inviteCode, inviteLink } = generateInviteCodeLink(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()
}, [])
@@ -89,95 +109,85 @@ const PointsPage = () => {
router.back()} />
-
-
+ {/* consolidated points and cash card */}
+
+ {/* points section */}
+
- {tierInfo.data.totalPoints} {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
+ {(() => {
+ const { number, suffix } = shortenPoints(animatedTotal)
+ return (
+ <>
+ {number}
+ {suffix && {suffix} }
+ >
+ )
+ })()}{' '}
+ {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
- {/* Progressive progress bar */}
-
-
-
-
= 2
- ? 100
- : Math.pow(
- Math.min(
- 1,
- tierInfo.data.nextTierThreshold > 0
- ? tierInfo.data.totalPoints / tierInfo.data.nextTierThreshold
- : 0
- ),
- 0.6
- ) * 100
- }%`,
- }}
+ {/* de-emphasized tier progress - smaller and flatter */}
+
+
+
+
+
= 2
+ ? 100
+ : Math.pow(
+ Math.min(
+ 1,
+ tierInfo.data.nextTierThreshold > 0
+ ? tierInfo.data.totalPoints /
+ tierInfo.data.nextTierThreshold
+ : 0
+ ),
+ 0.6
+ ) * 100
+ }%`,
+ }}
+ />
+
+ {tierInfo?.data.currentTier < 2 && (
+
+ )}
{tierInfo?.data.currentTier < 2 && (
-
- )}
-
-
-
-
You're at tier {tierInfo?.data.currentTier}.
- {tierInfo?.data.currentTier < 2 ? (
-
- {tierInfo.data.pointsToNextTier}{' '}
- {tierInfo.data.pointsToNextTier === 1 ? 'point' : 'points'} needed to level up
+
+ {formatPoints(tierInfo.data.pointsToNextTier)}{' '}
+ {tierInfo.data.pointsToNextTier === 1 ? 'point' : 'points'} to next tier
- ) : (
-
You've reached the max tier!
)}
-
- {user?.invitedBy ? (
-
- router.push(`/${user.invitedBy}`)}
- className="inline-flex cursor-pointer items-center gap-1 font-bold"
- >
- {user.invitedBy}
- {' '}
- invited you and earned points. Now it's your turn! Invite friends and get 20% of their points.
-
- ) : (
-
-
-
- Do stuff on Peanut and get points. Invite friends and pocket 20% of their points, too.
-
-
- )}
-
Invite friends with your code
-
-
- {`${inviteCode}`}
-
-
-
+ {/* cash section */}
+ {cashStatus?.success && cashStatus.data && (
+
+ )}
+
- {/* User Graph - shows user, their inviter, and points flow regardless of invites */}
+ {/* invite graph with consolidated explanation */}
{myGraphResult?.data && (
<>
-
+
{
showUsernames
/>
-
-
-
- {IS_DEV
- ? 'Experimental. Enabled for all users in dev mode.'
- : 'Experimental. Only available for Seedlings badge holders.'}
-
-
+
+ {user?.invitedBy && (
+ <>
+ router.push(`/${user.invitedBy}`)}
+ className="inline-flex cursor-pointer items-center gap-1 font-bold"
+ >
+ {user.invitedBy}
+ {' '}
+ invited you.{' '}
+ >
+ )}
+ You earn rewards whenever your friends use Peanut!
+
>
)}
- {invites && invites?.invitees && invites.invitees.length > 0 && (
+ {/* if user has invites: show button above people list */}
+ {invites && invites?.invitees && invites.invitees.length > 0 ? (
<>
- Promise.resolve(generateInvitesShareText(inviteLink))}
- title="Share your invite link"
+ setIsInviteModalOpen(true)}
+ className="!mt-8 w-full"
>
Share Invite link
-
+
+
+ {/* people you invited */}
router.push('/points/invites')}
>
People you invited
-
- {invites.invitees?.map((invite: PointsInvite, i: number) => {
+
+ {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 * 0.2)
+ const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
return (
router.push(`/${username}`)}
className="cursor-pointer"
>
@@ -246,35 +267,43 @@ const PointsPage = () => {
isVerified={isVerified}
/>
-
- +{pointsEarned} {pointsEarned === 1 ? 'pt' : 'pts'}
-
+
)
})}
>
- )}
-
- {invites?.invitees?.length === 0 && (
-
-
-
-
- No invites yet
+ ) : (
+ <>
+ {/* if user has no invites: show empty state with modal button */}
+
+
+
+
+ No invites yet
-
- Send your invite link to start earning more rewards
-
- Promise.resolve(generateInvitesShareText(inviteLink))}
- title="Share your invite link"
- >
- Share Invite link
-
-
+
+ Send your invite link to start earning more rewards
+
+ setIsInviteModalOpen(true)}
+ className="w-full"
+ >
+ Share Invite link
+
+
+ >
)}
+
+ {/* Invite Modal */}
+
setIsInviteModalOpen(false)}
+ username={username ?? ''}
+ />
)
diff --git a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
index 6f1b7a965..4e194b62a 100644
--- a/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
+++ b/src/app/(mobile-ui)/profile/exchange-rate/page.tsx
@@ -7,11 +7,10 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { printableUsdc } from '@/utils/balance.utils'
import { getExchangeRateWidgetRedirectRoute } from '@/utils/exchangeRateWidget.utils'
import { useRouter } from 'next/navigation'
-import { useEffect } from 'react'
export default function ExchangeRatePage() {
const router = useRouter()
- const { fetchBalance, balance } = useWallet()
+ const { balance } = useWallet()
const handleCtaAction = (sourceCurrency: string, destinationCurrency: string) => {
const formattedBalance = parseFloat(printableUsdc(balance ?? 0n))
@@ -20,11 +19,6 @@ export default function ExchangeRatePage() {
router.push(redirectRoute)
}
- useEffect(() => {
- // Fetch latest balance
- fetchBalance()
- }, [])
-
return (
router.replace('/profile')} />
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 07c05ca46..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
@@ -100,6 +103,9 @@ export default function WithdrawBankPage() {
case AccountType.CLABE:
countryId = 'MX'
break
+ case AccountType.GB:
+ countryId = 'GB'
+ break
default:
return {
currency: '',
@@ -124,6 +130,8 @@ export default function WithdrawBankPage() {
return bankAccount.routingNumber?.toUpperCase() ?? 'N/A'
} else if (bankAccount && bankAccount.type === AccountType.CLABE) {
return bankAccount.identifier?.toUpperCase() ?? 'N/A'
+ } else if (bankAccount && bankAccount.type === AccountType.GB) {
+ return bankAccount.sortCode ?? 'N/A'
}
return 'N/A'
@@ -259,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()
}
}}
@@ -281,8 +293,8 @@ export default function WithdrawBankPage() {
tokenSymbol={PEANUT_WALLET_TOKEN_SYMBOL}
/>
- {/* Warning for non-EUR SEPA countries */}
- {isNonEuroSepa && (
+ {/* Warning for non-EUR SEPA countries (not UK — UK uses Faster Payments with GBP) */}
+ {isNonEuroSepa && bankAccount?.type !== AccountType.GB && (
>
+ ) : bankAccount?.type === AccountType.GB ? (
+ <>
+
+
+ >
) : (
<>
@@ -364,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 5af73db77..d97db5f86 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -274,6 +274,7 @@ export default function WithdrawCryptoPage() {
txHash: finalTxHash,
tokenAddress: PEANUT_WALLET_TOKEN,
payerAddress: address as Address,
+ squidQuoteId: xChainRoute?.rawResponse?.route?.quoteId,
})
setTransactionHash(finalTxHash)
@@ -381,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
}
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index f808f8de6..2732fee92 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -10,7 +10,9 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { formatAmount } from '@/utils/general.utils'
-import { getCountryFromAccount } from '@/utils/bridge.utils'
+import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils'
+import useGetExchangeRate from '@/hooks/useGetExchangeRate'
+import { AccountType } from '@/interfaces'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react'
import { formatUnits } from 'viem'
@@ -82,6 +84,43 @@ export default function WithdrawPage() {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
}, [balance])
+ // derive country and account type for minimum amount validation
+ const { countryIso2, rateAccountType } = useMemo(() => {
+ if (selectedBankAccount) {
+ const country = getCountryFromAccount(selectedBankAccount)
+ return { countryIso2: country?.iso2 || '', rateAccountType: selectedBankAccount.type as AccountType }
+ }
+ if (selectedMethod?.countryPath) {
+ const country = getCountryFromPath(selectedMethod.countryPath)
+ const iso2 = country?.iso2 || ''
+ let accountType: AccountType = AccountType.IBAN
+ if (iso2 === 'US') accountType = AccountType.US
+ else if (iso2 === 'GB') accountType = AccountType.GB
+ else if (iso2 === 'MX') accountType = AccountType.CLABE
+ return { countryIso2: iso2, rateAccountType: accountType }
+ }
+ return { countryIso2: '', rateAccountType: AccountType.US }
+ }, [selectedBankAccount, selectedMethod])
+
+ // fetch exchange rate for non-USD countries to convert local minimum to USD
+ const { exchangeRate } = useGetExchangeRate({
+ accountType: rateAccountType,
+ enabled: rateAccountType !== AccountType.US && countryIso2 !== '',
+ })
+
+ // compute minimum withdrawal in USD using the exchange rate
+ const minUsdAmount = useMemo(() => {
+ const localMin = getMinimumAmount(countryIso2)
+ // for US or unknown, minimum is already in USD
+ if (!countryIso2 || countryIso2 === 'US') return localMin
+ // for EUR countries, €1 ≈ $1
+ if (localMin === 1) return 1
+ // convert local minimum to USD: sellRate = local currency per 1 USD
+ const rate = parseFloat(exchangeRate || '0')
+ if (rate <= 0) return 1 // fallback while rate is loading
+ return Math.ceil(localMin / rate)
+ }, [countryIso2, exchangeRate])
+
// validate against user's limits for bank withdrawals
// note: crypto withdrawals don't have fiat limits
const limitsValidation = useLimitsValidation({
@@ -136,19 +175,22 @@ export default function WithdrawPage() {
return false
}
- // convert the entered token amount to USD to enforce the $1 min rule
+ // convert the entered token amount to USD
const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below
const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD
- if (usdEquivalent >= 1 && amount <= maxDecimalAmount) {
+ if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) {
setError({ showError: false, errorMessage: '' })
return true
}
// determine message
let message = ''
- if (usdEquivalent < 1) {
- message = isFromSendFlow ? 'Minimum send amount is $1.' : 'Minimum withdrawal is $1.'
+ if (usdEquivalent < minUsdAmount) {
+ const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}`
+ message = isFromSendFlow
+ ? `Minimum send amount is ${minDisplay}.`
+ : `Minimum withdrawal is ${minDisplay}.`
} else if (amount > maxDecimalAmount) {
message = 'Amount exceeds your wallet balance.'
} else {
@@ -157,7 +199,7 @@ export default function WithdrawPage() {
setError({ showError: true, errorMessage: message })
return false
},
- [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow]
+ [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount]
)
const handleTokenAmountChange = useCallback(
@@ -252,10 +294,10 @@ export default function WithdrawPage() {
if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true
const usdEq = (selectedTokenData?.price ?? 1) * numericAmount
- if (usdEq < 1) return true // below $1 min
+ if (usdEq < minUsdAmount) return true // below country-specific minimum
return numericAmount > maxDecimalAmount || error.showError
- }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price])
+ }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount])
if (step === 'inputAmount') {
// only show limits card for bank/manteca withdrawals, not crypto
diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx
new file mode 100644
index 000000000..a2daeda93
--- /dev/null
+++ b/src/app/[locale]/(marketing)/[country]/page.tsx
@@ -0,0 +1,70 @@
+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 { 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 }>
+}
+
+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
+ if (!isValidLocale(locale)) return {}
+
+ const seo = COUNTRIES_SEO[country]
+ if (!seo) return {}
+
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'countries',
+ country,
+ locale
+ )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
+
+ return {
+ ...metadataHelper({
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
+ 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()
+
+ 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)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
new file mode 100644
index 000000000..418455970
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx
@@ -0,0 +1,147 @@
+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'
+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 }>
+}
+
+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)
+ if (posts.length === 0) posts = getAllPosts('en')
+ 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 {
+ 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 i18n = getTranslations(locale)
+
+ 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}`,
+ }
+
+ // 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}
+ )}
+
+ ))}
+
+
+
+
+
+ >
+ )
+}
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..71a911f53
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
@@ -0,0 +1,101 @@
+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() {
+ if (process.env.NODE_ENV === 'production') return []
+ 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 const dynamicParams = false
+
+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..e30220e20
--- /dev/null
+++ b/src/app/[locale]/(marketing)/blog/page.tsx
@@ -0,0 +1,98 @@
+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() {
+ if (process.env.NODE_ENV === 'production') return []
+ 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..b595c1199
--- /dev/null
+++ b/src/app/[locale]/(marketing)/compare/[slug]/page.tsx
@@ -0,0 +1,164 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { COMPETITORS } from '@/data/seo'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { Section } from '@/components/Marketing/Section'
+import { ComparisonTable } from '@/components/Marketing/ComparisonTable'
+import { FAQSection } from '@/components/Marketing/FAQSection'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t, localizedPath } from '@/i18n'
+import { RelatedPages } from '@/components/Marketing/RelatedPages'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
+
+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}` })))
+}
+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 {
+ 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 {}
+
+ // 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 {
+ ...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()
+
+ // 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()
+
+ 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)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
new file mode 100644
index 000000000..36d004e36
--- /dev/null
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -0,0 +1,194 @@
+import { notFound } from 'next/navigation'
+import { type Metadata } from 'next'
+import { generateMetadata as metadataHelper } from '@/app/metadata'
+import { EXCHANGES } from '@/data/seo'
+import { MarketingHero } from '@/components/Marketing/MarketingHero'
+import { MarketingShell } from '@/components/Marketing/MarketingShell'
+import { Section } from '@/components/Marketing/Section'
+import { Steps } from '@/components/Marketing/Steps'
+import { FAQSection } from '@/components/Marketing/FAQSection'
+import { JsonLd } from '@/components/Marketing/JsonLd'
+import { Card } from '@/components/0_Bruddle/Card'
+import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t, localizedPath } from '@/i18n'
+import { RelatedPages } from '@/components/Marketing/RelatedPages'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
+
+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}` }))
+ )
+}
+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 {
+ 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 {}
+
+ // 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 {
+ ...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()
+
+ // 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) => ({
+ 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)/error.tsx b/src/app/[locale]/(marketing)/error.tsx
new file mode 100644
index 000000000..938173d6a
--- /dev/null
+++ b/src/app/[locale]/(marketing)/error.tsx
@@ -0,0 +1,33 @@
+'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/[locale]/(marketing)/layout.tsx b/src/app/[locale]/(marketing)/layout.tsx
new file mode 100644
index 000000000..a232087b7
--- /dev/null
+++ b/src/app/[locale]/(marketing)/layout.tsx
@@ -0,0 +1,29 @@
+import { notFound } from 'next/navigation'
+import { SUPPORTED_LOCALES } from '@/i18n/types'
+import { isValidLocale } from '@/i18n/config'
+import Footer from '@/components/LandingPage/Footer'
+
+interface LayoutProps {
+ children: React.ReactNode
+ params: Promise<{ locale: string }>
+}
+
+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
+
+ if (!isValidLocale(locale)) {
+ notFound()
+ }
+
+ return (
+
+ {children}
+
+
+ )
+}
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..9fec9dd8a
--- /dev/null
+++ b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
@@ -0,0 +1,70 @@
+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 { 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 }>
+}
+
+export async function generateStaticParams() {
+ 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
+ if (!isValidLocale(locale)) return {}
+
+ const pm = PAYMENT_METHODS[method]
+ if (!pm) return {}
+
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'pay-with',
+ method,
+ locale
+ )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
+
+ return {
+ ...metadataHelper({
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
+ 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()
+
+ 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
new file mode 100644
index 000000000..e650c1652
--- /dev/null
+++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx
@@ -0,0 +1,74 @@
+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'
+import { ContentPage } from '@/components/Marketing/ContentPage'
+import { readPageContentLocalized } from '@/lib/content'
+import { renderContent } from '@/lib/mdx'
+
+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 const dynamicParams = false
+
+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()
+
+ // 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
new file mode 100644
index 000000000..0ec5bf995
--- /dev/null
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -0,0 +1,68 @@
+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 { 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 }>
+}
+
+export async function generateStaticParams() {
+ 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
+ if (!isValidLocale(locale)) return {}
+
+ if (!CORRIDORS.some((c) => c.from === from && c.to === to)) return {}
+
+ const mdxContent = readCorridorContentLocalized(to, from, locale)
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
+
+ const fm = mdxContent.frontmatter as { title?: string; description?: string }
+ if (!fm.title || !fm.description) return {}
+
+ return {
+ ...metadataHelper({
+ title: fm.title,
+ description: fm.description,
+ 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()
+
+ 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
new file mode 100644
index 000000000..3e09b2f79
--- /dev/null
+++ b/src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
@@ -0,0 +1,70 @@
+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, localizedPath } from '@/i18n/config'
+import type { Locale } from '@/i18n/types'
+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 }>
+}
+
+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
+ if (!isValidLocale(locale)) return {}
+
+ const seo = COUNTRIES_SEO[country]
+ if (!seo) return {}
+
+ const mdxContent = readPageContentLocalized<{ title: string; description: string; published?: boolean }>(
+ 'send-to',
+ country,
+ locale
+ )
+ if (!mdxContent || mdxContent.frontmatter.published === false) return {}
+
+ 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),
+ },
+ }
+}
+
+export default async function SendMoneyToCountryPageLocalized({ params }: PageProps) {
+ const { locale, country } = await params
+ if (!isValidLocale(locale)) notFound()
+
+ const mdxSource = readPageContentLocalized('send-to', 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)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx
new file mode 100644
index 000000000..5f523c846
--- /dev/null
+++ b/src/app/[locale]/(marketing)/team/page.tsx
@@ -0,0 +1,128 @@
+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() {
+ if (process.env.NODE_ENV === 'production') return []
+ 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/actions/card.ts b/src/app/actions/card.ts
new file mode 100644
index 000000000..b6436ea3d
--- /dev/null
+++ b/src/app/actions/card.ts
@@ -0,0 +1,105 @@
+'use server'
+
+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
+if (!API_KEY) {
+ throw new Error('PEANUT_API_KEY environment variable is not set')
+}
+
+export interface CardInfoResponse {
+ hasPurchased: boolean
+ chargeStatus?: string
+ chargeUuid?: string
+ paymentUrl?: string
+ isEligible: boolean
+ eligibilityReason?: string
+ price: number
+ currentTier: number
+ slotsRemaining?: number
+ recentPurchases?: number
+}
+
+export interface CardPurchaseResponse {
+ chargeUuid: string
+ paymentUrl: string
+ price: number
+ // Semantic URL components for direct navigation (avoids extra API call)
+ recipientAddress: string
+ chainId: string
+ tokenAmount: string
+ tokenSymbol: string
+}
+
+export interface CardErrorResponse {
+ error: string
+ message: string
+ chargeUuid?: string
+}
+
+/**
+ * Get card pioneer info for the authenticated user
+ */
+export const getCardInfo = async (): Promise<{ data?: CardInfoResponse; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ if (!jwtToken) {
+ return { error: 'Authentication required' }
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/card`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ return { error: errorData.message || 'Failed to get card info' }
+ }
+
+ const data = await response.json()
+ return { data }
+ } catch (e: any) {
+ return { error: e.message || 'An unexpected error occurred' }
+ }
+}
+
+/**
+ * Initiate card pioneer purchase
+ */
+export const purchaseCard = async (): Promise<{ data?: CardPurchaseResponse; error?: string; errorCode?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ if (!jwtToken) {
+ return { error: 'Authentication required', errorCode: 'NOT_AUTHENTICATED' }
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/card/purchase`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({}),
+ })
+
+ if (!response.ok) {
+ const errorData: CardErrorResponse = await response.json()
+ return {
+ error: errorData.message || 'Failed to initiate purchase',
+ errorCode: errorData.error,
+ }
+ }
+
+ const data = await response.json()
+ return { data }
+ } catch (e: any) {
+ return { error: e.message || 'An unexpected error occurred' }
+ }
+}
diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts
index 7be1c2a88..39a1d84be 100644
--- a/src/app/actions/currency.ts
+++ b/src/app/actions/currency.ts
@@ -12,12 +12,14 @@ export const getCurrencyPrice = async (currencyCode: string): Promise<{ buy: num
if (currencyCode === 'USD') {
buy = 1
sell = 1
- } else if (['EUR', 'MXN'].includes(currencyCode)) {
+ } else if (['EUR', 'MXN', 'GBP'].includes(currencyCode)) {
let accountType: AccountType
if (currencyCode === 'EUR') {
accountType = AccountType.IBAN
} else if (currencyCode === 'MXN') {
accountType = AccountType.CLABE
+ } else if (currencyCode === 'GBP') {
+ accountType = AccountType.GB
} else {
throw new Error('Invalid currency code')
}
diff --git a/src/app/actions/types/users.types.ts b/src/app/actions/types/users.types.ts
index 79811164a..63ca3b0f8 100644
--- a/src/app/actions/types/users.types.ts
+++ b/src/app/actions/types/users.types.ts
@@ -3,6 +3,8 @@ export enum BridgeEndorsementType {
BASE = 'base',
SEPA = 'sepa',
SPEI = 'spei',
+ PIX = 'pix',
+ FASTER_PAYMENTS = 'faster_payments',
}
// this type represents the detailed response from our initiate-kyc endpoint
@@ -24,6 +26,7 @@ export enum BridgeAccountType {
IBAN = 'iban',
US = 'us',
CLABE = 'clabe',
+ GB = 'gb', // uk bank accounts (sort code + account number)
}
// matches the BridgeAccountOwnerType enum on the backend
@@ -53,4 +56,5 @@ export interface AddBankAccountPayload {
}
bic?: string
routingNumber?: string
+ sortCode?: string // uk bank accounts
}
diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts
index 8131a2493..3c061ba43 100644
--- a/src/app/api/exchange-rate/route.ts
+++ b/src/app/api/exchange-rate/route.ts
@@ -42,8 +42,8 @@ export async function GET(request: NextRequest) {
if (
MANTECA_CURRENCIES.has(fromUc) ||
MANTECA_CURRENCIES.has(toUc) ||
- ['EUR', 'MXN'].includes(fromUc) ||
- ['EUR', 'MXN'].includes(toUc)
+ ['EUR', 'MXN', 'GBP'].includes(fromUc) ||
+ ['EUR', 'MXN', 'GBP'].includes(toUc)
) {
const currencyPriceRate = await fetchFromCurrencyPrice(fromUc, toUc)
if (currencyPriceRate !== null) {
@@ -105,8 +105,8 @@ async function getExchangeRate(from: string, to: string): Promise
if (
MANTECA_CURRENCIES.has(from) ||
MANTECA_CURRENCIES.has(to) ||
- ['EUR', 'MXN'].includes(from) ||
- ['EUR', 'MXN'].includes(to)
+ ['EUR', 'MXN', 'GBP'].includes(from) ||
+ ['EUR', 'MXN', 'GBP'].includes(to)
) {
return await fetchFromCurrencyPrice(from, to)
}
@@ -122,7 +122,7 @@ async function getExchangeRate(from: string, to: string): Promise
async function fetchFromCurrencyPrice(from: string, to: string): Promise {
console.log('Fetching from getCurrencyPrice')
try {
- if (from === 'USD' && (MANTECA_CURRENCIES.has(to) || ['EUR', 'MXN'].includes(to))) {
+ if (from === 'USD' && (MANTECA_CURRENCIES.has(to) || ['EUR', 'MXN', 'GBP'].includes(to))) {
// USD → other currency: use sell rate (selling USD to get other currency)
const { sell } = await getCurrencyPrice(to)
if (!isFinite(sell) || sell <= 0) {
@@ -130,7 +130,7 @@ async function fetchFromCurrencyPrice(from: string, to: string): Promise ({
+ 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')
+ })
+})
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: {
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/api/proxy/[...slug]/route.ts b/src/app/api/proxy/[...slug]/route.ts
index 773951e2f..7eeb41e90 100644
--- a/src/app/api/proxy/[...slug]/route.ts
+++ b/src/app/api/proxy/[...slug]/route.ts
@@ -26,8 +26,6 @@ export async function POST(request: NextRequest) {
})
}
- jsonToPass.apiKey = process.env.PEANUT_API_KEY!
-
const userIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip')
const headersToPass = {
'Content-Type': 'application/json',
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 73ec69d1c..da454dad8 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',
@@ -62,16 +111,19 @@ const sniglet = Sniglet({
const knerdOutline = localFont({
src: '../assets/fonts/knerd-outline.ttf',
variable: '--font-knerd-outline',
+ display: 'swap',
})
const knerdFilled = localFont({
src: '../assets/fonts/knerd-filled.ttf',
variable: '--font-knerd-filled',
+ display: 'swap',
})
const robotoFlexBold = localFont({
src: '../assets/fonts/roboto-flex-bold.ttf',
variable: '--font-roboto-flex-bold',
+ display: 'swap',
})
export const viewport: Viewport = {
@@ -91,6 +143,12 @@ 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
new file mode 100644
index 000000000..913f9eb40
--- /dev/null
+++ b/src/app/lp/card/CardLandingPage.tsx
@@ -0,0 +1,865 @@
+'use client'
+import { motion } from 'framer-motion'
+import Image from 'next/image'
+import { Button } from '@/components/0_Bruddle/Button'
+import { FAQsPanel } from '@/components/Global/FAQs'
+import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
+import { Marquee } from '@/components/LandingPage'
+import { useAuth } from '@/context/authContext'
+import { useRouter } from 'next/navigation'
+import { Star, HandThumbsUp } from '@/assets'
+import { useEffect } from 'react'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
+
+const faqQuestions = [
+ {
+ id: '0',
+ question: 'What is Card Pioneers?',
+ answer: 'Card Pioneers is the early-access program for the Peanut Card. Reserve a spot, get priority rollout access, and unlock Pioneer perks like $5 for every friend you refer.',
+ },
+ {
+ id: '1',
+ question: 'How does it work?',
+ answer: '1. Reserve your spot by adding $10 in starter card balance. 2. Share your Peanut invite link. 3. When someone joins Card Pioneers through your invite, you earn $5 instantly, plus rewards every time they spend - forever.',
+ },
+ {
+ id: '2',
+ question: 'Is the $10 refundable?',
+ answer: "Your $10 becomes starter balance when your card launches. If you're found not eligible at launch (for example: your region isn't supported, or you can't complete required verification), you can request a refund.",
+ },
+ {
+ id: '3',
+ question: 'Where is the card available?',
+ answer: "We're rolling out by region in stages: US, Latin America, and Africa. You'll see eligibility details during signup.",
+ },
+ {
+ id: '4',
+ question: "Do I earn from my invites' invites too?",
+ answer: 'Yes! You earn a smaller part for your entire invite tree. So if you invite someone who becomes a power-referrer, you earn from everyone they bring in too.',
+ },
+]
+
+const CardLandingPage = () => {
+ const { user } = useAuth()
+ const router = useRouter()
+ // feature flag: redirect to landing if card pioneers is disabled
+ useEffect(() => {
+ if (underMaintenanceConfig.disableCardPioneers) {
+ router.replace('/')
+ }
+ }, [router])
+
+ if (underMaintenanceConfig.disableCardPioneers) {
+ return null
+ }
+
+ const handleCTA = () => {
+ if (user) {
+ router.push('/card')
+ } else {
+ router.push('/setup?redirect_uri=/card')
+ }
+ }
+
+ // Marquee copy from CARD_coremessaging.md
+ const marqueeProps = {
+ visible: true,
+ message: ['EARLY = EARN', 'BUILD YOUR TREE', 'ONE LINK', 'LIFETIME UPSIDE', '$5 PER INVITE', 'EARN FOREVER'],
+ }
+
+ return (
+ <>
+ {/* Hero Section - Yellow with card */}
+
+
+
+
+
+
+ YOUR DOLLARS.
+
+ EVERYWHERE.
+
+
+
+ Pay with the peanut card. Earn with every purchase, yours or your friends.
+
+
+ Self-custodial. Best rates. No hidden fees.
+
+
+
+
+
+
+
+
+ JOIN PIONEERS
+
+
+ $10 starter balance = your spot secured
+
+
+
+
+
+
+
+
+ {/* How it works - Cream */}
+
+
+
+
+
+ HOW IT WORKS
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Earn Forever - Cream Background */}
+
+
+
+
+ {/* Visual - Simplified Invite Visual */}
+
+
+ {/*
+ LAYOUT - Calculated with Python trigonometry
+ Container: 340x380
+
+ L0 (YOU): center (170, 190), 80x80px
+ L1 nodes: 48x48px, 120px from YOU
+ - Top: center (170, 70) - outward angle -90°
+ - Bottom-left: center (70, 310) - outward angle 129.8°
+ - Bottom-right: center (270, 310) - outward angle 50.2°
+
+ L2 nodes: 32x32px, 55px from parent L1 center, fanning at -45°, 0°, +45° from outward direction
+ Top L1 (170,70): (131,31), (170,15), (209,31)
+ Bottom-left L1 (70,310): (75,365), (35,352), (15,315)
+ Bottom-right L1 (270,310): (325,315), (305,352), (265,365)
+ */}
+
+ {/* Connection lines */}
+
+ {/* L0 to L1 edges */}
+
+
+
+
+ {/* Top L1 (170,70) to L2 */}
+
+
+
+
+ {/* Bottom-left L1 (70,310) to L2 */}
+
+
+
+
+ {/* Bottom-right L1 (270,310) to L2 */}
+
+
+
+
+
+ {/* L0: YOU node - center (170,190), top-left (130,150) */}
+
+ YOU
+
+
+ {/* +$5 BADGES - at visual midpoint between node edges
+ Top edge: YOU bottom (y=150) to L1 top (y=94) -> visual mid = (150+94)/2 = 122, badge top = 114
+ Bottom edges: at midpoint of line between centers
+ */}
+
+ +$5
+
+
+ +$5
+
+
+ +$5
+
+
+ {/* L1: Top primary - center (170,70), top-left (146,46) */}
+
+
+
+
+ {/* L1: Bottom-left primary - center (70,310), top-left (46,286) */}
+
+
+
+
+ {/* L1: Bottom-right primary - center (270,310), top-left (246,286) */}
+
+
+
+
+ {/* L2 NODES - positioned directly at calculated centers
+ Each node is 32x32, so top-left = center - 16
+ Top L1 (170,70): L2 at (131,31), (170,15), (209,31)
+ Bottom-left L1 (70,310): L2 at (75,365), (35,352), (15,315)
+ Bottom-right L1 (270,310): L2 at (325,315), (305,352), (265,365)
+ */}
+
+ {/* Top L1's children - labels 2px gap from node edge */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+ {/* Bottom-left L1's children */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+ {/* Bottom-right L1's children */}
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+
+
+ +%
+
+
+
+
+ {/* Copy */}
+
+
+ INVITE ONCE.
+
+ EARN FOREVER.
+
+
+
+
+
+
+
+
+
+ START EARNING
+
+
+
+
+
+
+
+
+ {/* Coverage - Yellow */}
+
+
+
+
+
+ ROLLING OUT
+
+ GLOBALLY
+
+
+
+ Starting with US , Latin America , and Africa
+
+
+
+ {/* Individual country flags */}
+ {[
+ { name: 'United States', code: 'us' },
+ { name: 'Brazil', code: 'br' },
+ { name: 'Argentina', code: 'ar' },
+ { name: 'Mexico', code: 'mx' },
+ { name: 'Nigeria', code: 'ng' },
+ { name: 'Kenya', code: 'ke' },
+ { name: 'South Africa', code: 'za' },
+ ].map((country, i) => (
+
+
+ {country.name}
+
+ ))}
+
+ {/* Region pills without flags */}
+
+ Latin America
+
+
+ Africa
+
+
+ + more
+
+
+
+
+
+
+
+ {/* FAQ - Cream */}
+
+
+
+
+ {/* Final CTA - Secondary Yellow */}
+
+
+
+
+
+
+ Early access is open
+
+
+
+ READY TO
+
+ JOIN?
+
+
+ $10 reserves your spot. And for every friend you invite, earn forever.
+
+
+
+ BECOME A PIONEER
+
+
+
+
+
+
+ >
+ )
+}
+
+// Floating stars component
+const FloatingStars = () => {
+ const starConfigs = [
+ { className: 'absolute left-12 top-10', delay: 0.2 },
+ { className: 'absolute left-56 top-1/2', delay: 0.2 },
+ { className: 'absolute bottom-20 left-20', delay: 0.2 },
+ { className: 'absolute -top-16 right-20 md:top-10', delay: 0.6 },
+ { className: 'absolute bottom-20 right-44', delay: 0.6 },
+ ]
+
+ return (
+ <>
+ {starConfigs.map((config, index) => (
+
+ ))}
+ >
+ )
+}
+
+// Step card component
+const StepCard = ({
+ num,
+ title,
+ desc,
+ color,
+ textLight,
+ delay,
+}: {
+ num: string
+ title: string
+ desc: string
+ color: string
+ textLight?: boolean
+ delay: number
+}) => (
+
+
+ {num}
+
+ {title}
+ {desc}
+
+)
+
+// Reward item component
+const RewardItem = ({ amount, label }: { amount: string; label: string }) => (
+
+
+ {amount}
+
+ {label}
+
+)
+
+export default CardLandingPage
diff --git a/src/app/lp/card/page.tsx b/src/app/lp/card/page.tsx
new file mode 100644
index 000000000..5446cf17c
--- /dev/null
+++ b/src/app/lp/card/page.tsx
@@ -0,0 +1,21 @@
+import { generateMetadata as generateMeta } from '@/app/metadata'
+import { LandingPageShell } from '@/components/LandingPage/LandingPageShell'
+import Footer from '@/components/LandingPage/Footer'
+import CardLandingPage from './CardLandingPage'
+
+export const metadata = generateMeta({
+ title: 'Card Pioneers | Get Early Access to Peanut Card',
+ description:
+ 'Join Card Pioneers for early access to the Peanut Card. Reserve your spot with $10, earn $5 for every friend who joins, and spend your dollars globally.',
+ keywords:
+ 'peanut card, card pioneers, crypto card, digital dollars, global spending, early access, referral rewards, international card',
+})
+
+export default function CardLPPage() {
+ 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
new file mode 100644
index 000000000..fe98c7c1e
--- /dev/null
+++ b/src/app/lp/page.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+/**
+ * /lp route - Landing page that is ALWAYS accessible regardless of auth state.
+ * This allows logged-in users to view the marketing landing page.
+ * Uses Layout (client) instead of LandingPageShell since SSR doesn't matter here.
+ */
+
+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..64c4cbb50 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,
@@ -22,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 }],
},
@@ -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/not-found.tsx b/src/app/not-found.tsx
index 7a66bb1ad..b8ba35d04 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,19 +1,20 @@
-import Link from 'next/link'
import PageContainer from '@/components/0_Bruddle/PageContainer'
import PEANUTMAN_CRY from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_05.gif'
import Image from 'next/image'
export default function NotFound() {
return (
-
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f36aa3352..cbdb5e6c6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,220 +1,34 @@
-'use client'
-
-import Layout from '@/components/Global/Layout'
-import {
- DropLink,
- FAQs,
- Hero,
- Marquee,
- NoFees,
- SecurityBuiltIn,
- SendInSeconds,
- YourMoney,
- RegulatedRails,
-} 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 { 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.\n This means your account is yours alone. Neither Peanut nor anyone else can freeze or seize it — because we never hold your keys. 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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ }
+ regulatedRailsSlot={ }
+ yourMoneySlot={ }
+ securitySlot={ }
+ sendInSecondsSlot={ }
+ footerSlot={}
+ />
+
+
)
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index d9d154d42..9ce741e3f 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -1,27 +1,85 @@
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'
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)
+ ...SUPPORTED_LOCALES.map((l) => `/${l}/`),
+ ],
+ 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..5da5b172c 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,19 +1,115 @@
import { type MetadataRoute } from 'next'
+import { BASE_URL } from '@/constants/general.consts'
+import { COUNTRIES_SEO, CORRIDORS, COMPETITORS, EXCHANGES, PAYMENT_METHOD_SLUGS } from '@/data/seo'
+import { SUPPORTED_LOCALES } from '@/i18n/config'
+
+// 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
+
+/** Build date used for non-content pages that don't have their own date. */
+const BUILD_DATE = new Date()
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']
+ lastModified?: Date
+ }
- const staticPages = ['', '/about', '/jobs']
+ const pages: SitemapEntry[] = [
+ // Homepage
+ { path: '', priority: 1.0, changeFrequency: 'weekly' },
- // generate entries for static pages
- const staticEntries = staticPages.map((page) => ({
- url: `${baseUrl}${page}`,
- lastModified: new Date(),
- changeFrequency: 'weekly' as const,
- priority: 1.0,
- }))
+ // 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' })
+ }
+
+ // Send-money-to country pages
+ for (const country of Object.keys(COUNTRIES_SEO)) {
+ pages.push({
+ path: `/${locale}/send-money-to/${country}`,
+ priority: 0.8 * basePriority,
+ changeFrequency: 'weekly',
+ })
+ }
- return staticEntries
+ // 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',
+ })
+ }
+
+ // 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',
+ })
+ }
+
+ // 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_METHOD_SLUGS) {
+ pages.push({
+ path: `/${locale}/pay-with/${method}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'monthly',
+ })
+ }
+
+ // Blog and team pages excluded from production sitemap (not yet launched)
+ }
+
+ return pages.map((page) => ({
+ url: `${BASE_URL}${page.path}`,
+ lastModified: page.lastModified ?? BUILD_DATE,
+ changeFrequency: page.changeFrequency,
+ priority: page.priority,
+ }))
}
export default generateSitemap
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/assets/badges/index.ts b/src/assets/badges/index.ts
index 3c9667660..f75d53628 100644
--- a/src/assets/badges/index.ts
+++ b/src/assets/badges/index.ts
@@ -1,3 +1,5 @@
+// TODO: consolidate these with public/badges - we have duplicate badge systems
+// These tier badges should probably move to public/badges and use CODE_TO_PATH in badge.utils.ts
export { default as TIER_0_BADGE } from './tier0.svg'
export { default as TIER_1_BADGE } from './tier1.svg'
export { default as TIER_2_BADGE } from './tier2.svg'
diff --git a/src/assets/cards/Cart Gradient 10.svg b/src/assets/cards/Cart Gradient 10.svg
new file mode 100644
index 000000000..c4c030cb6
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 10.svg
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/Cart Gradient 4.svg b/src/assets/cards/Cart Gradient 4.svg
new file mode 100644
index 000000000..605eb3a40
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 4.svg
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/Cart Gradient 5.png b/src/assets/cards/Cart Gradient 5.png
new file mode 100644
index 000000000..abd139106
Binary files /dev/null and b/src/assets/cards/Cart Gradient 5.png differ
diff --git a/src/assets/cards/Cart Gradient 9.svg b/src/assets/cards/Cart Gradient 9.svg
new file mode 100644
index 000000000..77a1bf628
--- /dev/null
+++ b/src/assets/cards/Cart Gradient 9.svg
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cards/index.ts b/src/assets/cards/index.ts
new file mode 100644
index 000000000..1b79ed297
--- /dev/null
+++ b/src/assets/cards/index.ts
@@ -0,0 +1,4 @@
+export { default as CARD_GRADIENT_4 } from './Cart Gradient 4.svg'
+export { default as CARD_GRADIENT_5 } from './Cart Gradient 5.png'
+export { default as CARD_GRADIENT_9 } from './Cart Gradient 9.svg'
+export { default as CARD_GRADIENT_10 } from './Cart Gradient 10.svg'
diff --git a/src/assets/index.ts b/src/assets/index.ts
index 6cadbffb7..9fee1067e 100644
--- a/src/assets/index.ts
+++ b/src/assets/index.ts
@@ -1,5 +1,6 @@
export * from './badges'
export * from './bg'
+export * from './cards'
export * from './chains'
export * from './exchanges'
export * from './icons'
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/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx
index 01412bb3f..c9a585839 100644
--- a/src/components/AddMoney/UserDetailsForm.tsx
+++ b/src/components/AddMoney/UserDetailsForm.tsx
@@ -1,5 +1,5 @@
'use client'
-import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
+import { forwardRef, useEffect, useImperativeHandle } from 'react'
import { useForm, Controller } from 'react-hook-form'
import BaseInput from '@/components/0_Bruddle/BaseInput'
import ErrorAlert from '@/components/Global/ErrorAlert'
@@ -18,8 +18,6 @@ interface UserDetailsFormProps {
export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>(
({ onSubmit, onValidChange, initialData }, ref) => {
- const [submissionError, setSubmissionError] = useState(null)
-
const {
control,
handleSubmit,
@@ -36,13 +34,10 @@ export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDeta
onValidChange?.(isValid)
}, [isValid, onValidChange])
+ // Note: Submission errors are handled by the parent component
useImperativeHandle(ref, () => ({
handleSubmit: handleSubmit(async (data) => {
- setSubmissionError(null)
- const result = await onSubmit(data)
- if (result?.error) {
- setSubmissionError(result.error)
- }
+ await onSubmit(data)
}),
}))
@@ -81,13 +76,6 @@ export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDeta
- {submissionError && }
diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
index 58adcebd2..fe8631a4c 100644
--- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx
+++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx
@@ -135,11 +135,12 @@ export default function AddMoneyBankDetails({ flow = 'add-money' }: IAddMoneyBan
return formatCurrencyAmount(amount, onrampCurrency)
}, [amount, onrampCurrency, flow])
+ const isUk = currentCountryDetails?.id === 'GB' || currentCountryDetails?.iso3 === 'GBR'
+
const generateBankDetails = async () => {
const formattedAmount = formattedCurrencyAmount
const isMexico = currentCountryDetails?.id === 'MX'
const isUs = currentCountryDetails?.id === 'US'
- const isEuro = !isUs && !isMexico
let bankDetails = `Bank Transfer Details:
Amount: ${formattedAmount}
@@ -152,7 +153,7 @@ Beneficiary Address: ${onrampData?.depositInstructions?.bankBeneficiaryAddress}
`
}
- if (isEuro || isMexico) {
+ if (!isUs && !isMexico && !isUk) {
bankDetails += `
Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
`
@@ -161,11 +162,19 @@ Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
// for mexico, include clabe
if (isMexico) {
bankDetails += `
+Account Holder Name: ${onrampData?.depositInstructions?.accountHolderName}
CLABE: ${onrampData?.depositInstructions?.clabe || 'Loading...'}`
}
- // only include bank address and account details for non-mexico countries
- if (!isMexico) {
+ // uk faster payments
+ if (isUk) {
+ bankDetails += `
+Sort Code: ${onrampData?.depositInstructions?.sortCode || 'Loading...'}
+Account Number: ${onrampData?.depositInstructions?.accountNumber || 'Loading...'}`
+ }
+
+ // us and sepa countries
+ if (!isMexico && !isUk) {
bankDetails += `
Bank Address: ${onrampData?.depositInstructions?.bankAddress || 'Loading...'}`
@@ -297,45 +306,60 @@ Please use these details to complete your bank transfer.`
allowCopy={!!onrampData?.depositInstructions?.clabe}
hideBottomBorder
/>
+ ) : isUk ? (
+ <>
+
+
+ >
) : (
-
+
- )}
- {currentCountryDetails?.id !== 'MX' && (
-
+
+ onrampData?.depositInstructions?.bic ||
+ 'N/A'
+ }
+ allowCopy={
+ !!(
+ onrampData?.depositInstructions?.bankRoutingNumber ||
+ onrampData?.depositInstructions?.bic
+ )
+ }
+ hideBottomBorder
+ />
+ >
)}
{isNonUsdCurrency && (
+
Account details
{depositAddress && (
diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts
index 2858c4019..b368d057e 100644
--- a/src/components/AddMoney/consts/index.ts
+++ b/src/components/AddMoney/consts/index.ts
@@ -2843,7 +2843,9 @@ export const NON_EUR_SEPA_ALPHA2 = new Set(
!!c.iso3 &&
BRIDGE_ALPHA3_TO_ALPHA2[c.iso3] &&
// exclude usa explicitly; bridge map includes it but it's not sepa
- c.iso3 !== 'USA'
+ c.iso3 !== 'USA' &&
+ // exclude uk explicitly; uses faster payments, not sepa
+ c.iso3 !== 'GBR'
)
.map((c) => ({ alpha2: BRIDGE_ALPHA3_TO_ALPHA2[c.iso3!], currency: c.currency }))
.filter((x) => x.alpha2 && x.currency && x.currency !== 'EUR')
diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
index 649ebba9b..375479a18 100644
--- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx
+++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
@@ -98,6 +98,7 @@ export const AddWithdrawRouterView: FC = ({
acc.type === AccountType.IBAN ||
acc.type === AccountType.US ||
acc.type === AccountType.CLABE ||
+ acc.type === AccountType.GB ||
acc.type === AccountType.MANTECA
) ?? []
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
index a2ffab55d..c94b48157 100644
--- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx
+++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
@@ -8,7 +8,13 @@ import BaseInput from '@/components/0_Bruddle/BaseInput'
import BaseSelect from '@/components/0_Bruddle/BaseSelect'
import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { useParams, useRouter } from 'next/navigation'
-import { validateIban, validateBic, isValidRoutingNumber } from '@/utils/bridge-accounts.utils'
+import {
+ validateIban,
+ validateBic,
+ isValidRoutingNumber,
+ isValidSortCode,
+ isValidUKAccountNumber,
+} from '@/utils/bridge-accounts.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { getBicFromIban } from '@/app/actions/ibanToBic'
import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard'
@@ -35,6 +41,7 @@ export type IBankAccountDetails = {
accountNumber: string
bic: string
routingNumber: string
+ sortCode: string // uk bank accounts
clabe: string
street: string
city: string
@@ -71,7 +78,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
) => {
const isMx = country.toUpperCase() === 'MX'
const isUs = country.toUpperCase() === 'USA'
- const isIban = isUs || isMx ? false : isIBANCountry(country)
+ const isUk = country.toUpperCase() === 'GB' || country.toUpperCase() === 'GBR'
+ const isIban = isUs || isMx || isUk ? false : isIBANCountry(country)
const { user } = useAuth()
const dispatch = useAppDispatch()
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -107,6 +115,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
accountNumber: '',
bic: '',
routingNumber: '',
+ sortCode: '', // uk bank accounts
clabe: '',
street: '',
city: '',
@@ -162,12 +171,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
const isUs = country.toUpperCase() === 'USA'
const isMx = country.toUpperCase() === 'MX'
- const isIban = isUs || isMx ? false : isIBANCountry(country)
+ const isUk = country.toUpperCase() === 'GB' || country.toUpperCase() === 'GBR'
+ const isIban = isUs || isMx || isUk ? false : isIBANCountry(country)
let accountType: BridgeAccountType
if (isIban) accountType = BridgeAccountType.IBAN
else if (isUs) accountType = BridgeAccountType.US
else if (isMx) accountType = BridgeAccountType.CLABE
+ else if (isUk) accountType = BridgeAccountType.GB
else throw new Error('Unsupported country')
const accountNumber = isMx ? data.clabe : data.accountNumber
@@ -193,9 +204,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
let bic = data.bic || getValues('bic')
const iban = data.iban || getValues('iban')
+ // uk account numbers may be 6-7 digits, pad to 8 for bridge api
+ const cleanedAccountNumber = isUk
+ ? accountNumber.replace(/\s/g, '').padStart(8, '0')
+ : accountNumber.replace(/\s/g, '')
+
const payload: Partial = {
accountType,
- accountNumber: accountNumber.replace(/\s/g, ''),
+ accountNumber: cleanedAccountNumber,
countryCode: isUs ? 'USA' : country.toUpperCase(),
countryName: selectedCountry,
accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL,
@@ -217,11 +233,16 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
payload.routingNumber = data.routingNumber
}
+ if (isUk && data.sortCode) {
+ payload.sortCode = data.sortCode.replace(/[-\s]/g, '')
+ }
+
const result = await onSuccess(payload as AddBankAccountPayload, {
...data,
iban: isIban ? data.accountNumber || iban || '' : '',
accountNumber: isIban ? '' : data.accountNumber,
bic: bic,
+ sortCode: isUk ? data.sortCode : '',
country,
firstName: firstName.trim(),
lastName: lastName.trim(),
@@ -458,16 +479,27 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
}
}
)
- : renderInput(
- 'accountNumber',
- 'Account Number',
- {
- required: 'Account number is required',
- validate: async (value: string) =>
- validateUSBankAccount(value).isValid || 'Invalid account number',
- },
- 'text'
- )}
+ : isUk
+ ? renderInput(
+ 'accountNumber',
+ 'Account Number',
+ {
+ required: 'Account number is required',
+ validate: (value: string) =>
+ isValidUKAccountNumber(value) || 'Account number must be 6-8 digits',
+ },
+ 'text'
+ )
+ : renderInput(
+ 'accountNumber',
+ 'Account Number',
+ {
+ required: 'Account number is required',
+ validate: async (value: string) =>
+ validateUSBankAccount(value).isValid || 'Invalid account number',
+ },
+ 'text'
+ )}
{isIban &&
renderInput(
@@ -503,8 +535,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
validate: async (value: string) =>
(await isValidRoutingNumber(value)) || 'Invalid routing number',
})}
+ {isUk &&
+ renderInput('sortCode', 'Sort Code', {
+ required: 'Sort code is required',
+ validate: (value: string) => isValidSortCode(value) || 'Sort code must be 6 digits',
+ })}
- {!isIban && (
+ {!isIban && !isUk && (
<>
{renderInput(
'street',
diff --git a/src/components/Auth/auth.e2e.test.ts b/src/components/Auth/auth.e2e.test.ts
new file mode 100644
index 000000000..a80979f24
--- /dev/null
+++ b/src/components/Auth/auth.e2e.test.ts
@@ -0,0 +1,106 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Authentication Flow E2E Tests
+ *
+ * Tests basic auth UI flows without actual wallet connections.
+ * Does NOT test MetaMask/WalletConnect popups (external dependencies).
+ *
+ * Focus: UI rendering, navigation, error states
+ */
+
+test.describe('Auth UI Flow', () => {
+ test('should show auth options when not logged in', async ({ page }) => {
+ await page.goto('/')
+
+ // look for common auth UI elements
+ // adjust selectors based on actual implementation
+ const authElements = [
+ page.getByRole('button', { name: /connect|sign in|log in/i }),
+ page.locator('text=/wallet|authenticate/i'),
+ ]
+
+ // Check if any auth element is visible
+ let foundAuthElement = false
+ for (const el of authElements) {
+ const visible = await el.isVisible().catch(() => false)
+ if (visible) {
+ foundAuthElement = true
+ break
+ }
+ }
+
+ // If no auth UI visible, user might already be logged in or auth is elsewhere
+ // This is a soft assertion - real implementation varies
+ // We just log and don't fail since auth UI location varies by implementation
+ })
+
+ test('should open auth modal/drawer when connect clicked', async ({ page }) => {
+ await page.goto('/')
+
+ // find and click connect button
+ const connectButton = page.getByRole('button', { name: /connect|sign in|log in/i }).first()
+
+ if (await connectButton.isVisible()) {
+ await connectButton.click()
+
+ // should show modal or drawer with wallet options
+ // look for common wallet names
+ await expect(page.locator('text=/metamask|walletconnect|coinbase|rainbow/i').first()).toBeVisible({
+ timeout: 5000,
+ })
+ }
+ })
+
+ test('should close auth modal when close button clicked', async ({ page }) => {
+ await page.goto('/')
+
+ // open auth modal
+ const connectButton = page.getByRole('button', { name: /connect|sign in|log in/i }).first()
+
+ if (await connectButton.isVisible()) {
+ await connectButton.click()
+
+ // wait for modal to appear
+ await page.waitForSelector('text=/metamask|walletconnect/i', { timeout: 5000 })
+
+ // find and click close button
+ const closeButton = page.getByRole('button', { name: /close|cancel|×/i }).first()
+
+ if (await closeButton.isVisible()) {
+ await closeButton.click()
+
+ // modal should be closed
+ await expect(page.locator('text=/metamask|walletconnect/i').first()).not.toBeVisible()
+ }
+ }
+ })
+})
+
+test.describe('Auth State Persistence', () => {
+ test('should maintain auth state across page navigation', async ({ page }) => {
+ // this test requires actual auth - skip for now
+ // real auth requires wallet connection which is external dependency
+ test.skip()
+ })
+
+ test('should handle auth state on page refresh', async ({ page }) => {
+ // skip - requires actual wallet connection
+ test.skip()
+ })
+})
+
+test.describe('Protected Routes', () => {
+ test('should redirect unauthenticated users from protected routes', async ({ page }) => {
+ // try to access a protected route
+ // adjust route based on actual protected pages
+ await page.goto('/profile')
+
+ // wait for client-side redirect to occur (useEffect-based auth redirect)
+ await page.waitForURL(/\/setup|\/home|^\/$/, { timeout: 10000 })
+
+ // verify user is NOT on the protected route
+ const url = page.url()
+ expect(url).not.toContain('/profile')
+ })
+})
diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts
index 65c82761f..58461b552 100644
--- a/src/components/Badges/badge.utils.ts
+++ b/src/components/Badges/badge.utils.ts
@@ -13,6 +13,7 @@ const CODE_TO_PATH: Record = {
BIGGEST_REQUEST_POT: '/badges/biggest_request_pot.svg',
SEEDLING_DEVCONNECT_BA_2025: '/badges/seedlings_devconnect.svg',
ARBIVERSE_DEVCONNECT_BA_2025: '/badges/arbiverse_devconnect.svg',
+ CARD_PIONEER: '/badges/peanut-pioneer.png',
}
// public-facing descriptions for badges (third-person perspective)
@@ -28,6 +29,7 @@ const PUBLIC_DESCRIPTIONS: Record = {
BIGGEST_REQUEST_POT: 'High Roller or Master Beggar? They created the pot with the highest number of contributors.',
SEEDLING_DEVCONNECT_BA_2025: 'Peanut Ambassador. They spread the word and brought others into the ecosystem.',
ARBIVERSE_DEVCONNECT_BA_2025: 'Peanut 🤝 Arbiverse. They joined us at the amazing Arbiverse booth.',
+ CARD_PIONEER: 'A true Card Pioneer. Among the first to pay everywhere with Peanut.',
}
export function getBadgeIcon(code?: string) {
diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx
index b97b044eb..b63dfa4f7 100644
--- a/src/components/Badges/index.tsx
+++ b/src/components/Badges/index.tsx
@@ -9,18 +9,25 @@ import { getCardPosition } from '../Global/Card/card.utils'
import EmptyState from '../Global/EmptyStates/EmptyState'
import { Icon } from '../Global/Icons/Icon'
import ActionModal from '../Global/ActionModal'
-import { useMemo, useState } from 'react'
+import { useMemo, useState, useEffect } from 'react'
import { useUserStore } from '@/redux/hooks'
import { ActionListCard } from '../ActionListCard'
+import { useAuth } from '@/context/authContext'
type BadgeView = { title: string; description: string; logo: string | StaticImageData }
export const Badges = () => {
const router = useRouter()
const { user: authUser } = useUserStore()
+ const { fetchUser } = useAuth()
const [isBadgeModalOpen, setIsBadgeModalOpen] = useState(false)
const [selectedBadge, setSelectedBadge] = useState(null)
+ // TODO: fetchUser from context may not be memoized - could cause unnecessary re-renders
+ useEffect(() => {
+ fetchUser()
+ }, [fetchUser])
+
// map api badges to view badges
const badges: BadgeView[] = useMemo(() => {
// get badges from user object and map to card fields
diff --git a/src/components/Card/CardDetailsScreen.tsx b/src/components/Card/CardDetailsScreen.tsx
new file mode 100644
index 000000000..42c556c52
--- /dev/null
+++ b/src/components/Card/CardDetailsScreen.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import NavHeader from '@/components/Global/NavHeader'
+import Image from 'next/image'
+import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
+
+interface CardDetailsScreenProps {
+ price: number
+ currentTier: number
+ onContinue: () => void
+ onBack: () => void
+}
+
+const CardDetailsScreen = ({ price, currentTier, onContinue, onBack }: CardDetailsScreenProps) => {
+ const isDiscounted = currentTier >= 2
+ const originalPrice = 10
+
+ return (
+
+
+
+
+ {/* Peanut mascot background - matches PaymentSuccessView sizing */}
+
+
+ {/* Steps */}
+
+
+
+ 1. You deposit{' '}
+ {isDiscounted ? (
+ <>
+ ${originalPrice} {' '}
+ ${price}
+ >
+ ) : (
+ ${price}
+ )}{' '}
+ now to reserve your card
+ {isDiscounted && (
+ (because you're tier {currentTier})
+ )}
+
+
+
+
+ 2. You'll be first to get your card on April 14th
+
+
+
+
+ 3. Once you get your Peanut Card, the ${price} becomes
+ your starter balance!
+
+
+
+
+ 4. Invite people: you get rewarded for every person you
+ invite, now and forever.
+
+
+
+
+ {/* FAQ Link */}
+
+ For full conditions,{' '}
+
+ read the FAQ
+
+
+
+ {/* CTA Button */}
+
+ Continue
+
+
+
+ )
+}
+
+export default CardDetailsScreen
diff --git a/src/components/Card/CardGeoScreen.tsx b/src/components/Card/CardGeoScreen.tsx
new file mode 100644
index 000000000..5f177be90
--- /dev/null
+++ b/src/components/Card/CardGeoScreen.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import { Icon } from '@/components/Global/Icons/Icon'
+import Card from '@/components/Global/Card'
+import InfoCard from '@/components/Global/InfoCard'
+import { useRouter } from 'next/navigation'
+import { saveRedirectUrl } from '@/utils/general.utils'
+
+interface CardGeoScreenProps {
+ isEligible: boolean
+ eligibilityReason?: string
+ onContinue: () => void
+ onInitiatePurchase: () => void
+ onBack: () => void
+ purchaseError?: string | null
+}
+
+const CardGeoScreen = ({
+ isEligible,
+ eligibilityReason,
+ onContinue,
+ onInitiatePurchase,
+ onBack,
+ purchaseError,
+}: CardGeoScreenProps) => {
+ const router = useRouter()
+
+ // State 3: KYC approved but couldn't fetch country - show warning but allow proceeding
+ const hasKycButNoCountry = !isEligible && eligibilityReason === 'KYC_APPROVED_NO_COUNTRY'
+
+ // State 1 & 2: No KYC or KYC in progress - show verification prompt
+ // TODO: Replace string matching with structured eligibility codes from backend (e.g., NEEDS_KYC, KYC_IN_PROGRESS)
+ const needsKycVerification =
+ !isEligible &&
+ !hasKycButNoCountry &&
+ (eligibilityReason?.toLowerCase().includes('country information not available') ||
+ eligibilityReason?.toLowerCase().includes('please complete kyc'))
+
+ const handleStartVerification = () => {
+ saveRedirectUrl()
+ // TODO: Path says "europe" but Bridge covers all regions - consider renaming route or using generic path
+ router.push('/profile/identity-verification/europe/bridge')
+ }
+
+ return (
+
+
+
+
+ {isEligible ? (
+ <>
+ {/* Eligible State */}
+
+
+
+
+
+
You're Eligible!
+
+ Great news! Card Pioneers is available in your region. Continue to see how the
+ program works.
+
+
+
+ >
+ ) : hasKycButNoCountry ? (
+ <>
+ {/* State 3: KYC approved but couldn't fetch country - show warning but allow proceeding */}
+
+
+
+
+
+
Verification Complete
+
+ Your identity has been verified. You can proceed with your card reservation.
+
+
+
+
+ {/* Warning banner - country data not synced yet */}
+
+ >
+ ) : needsKycVerification ? (
+ <>
+ {/* Needs KYC Verification State */}
+
+
+
+
+
+
Verification Required
+
Card Purchase requires identity verification.
+
+
+
+ {/*
+
+
Verification helps us determine your region eligibility.
+
*/}
+ >
+ ) : (
+ <>
+ {/* Not Eligible State */}
+
+
+
+
+
+
Not Available Yet
+
+ Card Pioneers isn't available in your region yet. We're working hard to expand
+ coverage.
+
+
+
+
+
+
+
+ We'll notify you when we launch in your area. In the meantime, keep using Peanut to earn
+ points!
+
+
+ >
+ )}
+
+ {purchaseError &&
}
+
+ {/* CTA Buttons */}
+ {isEligible || hasKycButNoCountry ? (
+
+ Reserve my card
+
+ ) : needsKycVerification ? (
+
+ Start Verification
+
+ ) : (
+
+ Go Back
+
+ )}
+
+
+ )
+}
+
+export default CardGeoScreen
diff --git a/src/components/Card/CardInfoScreen.tsx b/src/components/Card/CardInfoScreen.tsx
new file mode 100644
index 000000000..07bfe5e93
--- /dev/null
+++ b/src/components/Card/CardInfoScreen.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState, useRef } from 'react'
+
+interface CardInfoScreenProps {
+ onContinue: () => void
+ hasPurchased: boolean
+ slotsRemaining?: number
+ recentPurchases?: number
+}
+
+// Rolling digit component - animates a single digit sliding down using CSS keyframes
+const RollingDigit = ({ digit, duration = 400 }: { digit: string; duration?: number }) => {
+ const [currentDigit, setCurrentDigit] = useState(digit)
+ const [prevDigit, setPrevDigit] = useState(null)
+ const [animationKey, setAnimationKey] = useState(0)
+ const prevDigitRef = useRef(digit)
+
+ useEffect(() => {
+ if (digit !== prevDigitRef.current) {
+ setPrevDigit(prevDigitRef.current)
+ setCurrentDigit(digit)
+ setAnimationKey((k) => k + 1)
+ prevDigitRef.current = digit
+
+ // Clear prevDigit after animation
+ const timer = setTimeout(() => {
+ setPrevDigit(null)
+ }, duration)
+
+ return () => clearTimeout(timer)
+ }
+ }, [digit, duration])
+
+ const animationStyle = `
+ @keyframes slideOut {
+ from { transform: translateY(0); opacity: 1; }
+ to { transform: translateY(-100%); opacity: 0; }
+ }
+ @keyframes slideIn {
+ from { transform: translateY(100%); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+ }
+ `
+
+ return (
+
+
+ {/* Previous digit - slides out */}
+ {prevDigit !== null && (
+
+ {prevDigit}
+
+ )}
+ {/* Current digit - slides in (or static if no animation) */}
+
+ {currentDigit}
+
+
+ )
+}
+
+// Rolling number display - splits number into digits and animates each
+const RollingNumber = ({ value, duration = 400 }: { value: number; duration?: number }) => {
+ const digits = String(value).split('')
+
+ return (
+
+ {digits.map((digit, index) => (
+
+ ))}
+
+ )
+}
+
+const CardInfoScreen = ({ onContinue, hasPurchased, slotsRemaining, recentPurchases }: CardInfoScreenProps) => {
+ const router = useRouter()
+ const [displayValue, setDisplayValue] = useState(null)
+ const timeoutRef = useRef(null)
+ const hasAnimated = useRef(false)
+
+ // Realistic slot decrement: first tick after 4-12s, then every 15-40s
+ useEffect(() => {
+ if (slotsRemaining === undefined) return
+
+ // Update display value on refetch without re-triggering the animation
+ if (hasAnimated.current) {
+ setDisplayValue(slotsRemaining)
+ return
+ }
+
+ hasAnimated.current = true
+ setDisplayValue(slotsRemaining)
+
+ const scheduleTick = (isFirst: boolean) => {
+ const delay = isFirst
+ ? 2000 + Math.random() * 3000 // 2-5 seconds for first tick
+ : 8000 + Math.random() * 12000 // 8-20 seconds for subsequent ticks
+ timeoutRef.current = setTimeout(() => {
+ setDisplayValue((prev) => {
+ if (prev === null || prev <= 1) return prev
+ return prev - 1
+ })
+ scheduleTick(false)
+ }, delay)
+ }
+
+ scheduleTick(true)
+
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [slotsRemaining])
+
+ return (
+
+
router.back()} />
+
+
+ {/* Description and FAQ link */}
+
+
+ Get access to the best card in the world. Spend globally at the best rates, and get rewarded for
+ every spend of you and your friends.
+
+
+ Have a question? Read the FAQ
+
+
+
+ {/* Card Hero with 3D effect */}
+
+
+ {/* Slots remaining counter */}
+ {displayValue !== null && (
+
+
+
+ slots left
+
+
+ {recentPurchases && recentPurchases > 0
+ ? `${recentPurchases} ${recentPurchases === 1 ? 'person' : 'people'} joined in the last 24h`
+ : 'Join the pioneers today'}
+
+
+ )}
+
+ {/* CTA Button */}
+ {hasPurchased ? (
+
+ Already a Pioneer
+
+ ) : (
+
+ Join Now
+
+ )}
+
+
+ )
+}
+
+export default CardInfoScreen
diff --git a/src/components/Card/CardPioneerModal.tsx b/src/components/Card/CardPioneerModal.tsx
new file mode 100644
index 000000000..9e76f1952
--- /dev/null
+++ b/src/components/Card/CardPioneerModal.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/0_Bruddle/Button'
+import BaseModal from '@/components/Global/Modal'
+import PioneerCard3D from '@/components/LandingPage/PioneerCard3D'
+
+const STORAGE_KEY = 'card-pioneer-modal-dismissed'
+const DISMISS_DURATION_DAYS = 3
+
+interface CardPioneerModalProps {
+ hasPurchased: boolean
+}
+
+/**
+ * Popup modal shown to eligible users who haven't purchased Card Pioneer yet.
+ * Shown on app open, can be dismissed by closing the modal (re-shows after X days).
+ */
+const CardPioneerModal = ({ hasPurchased }: CardPioneerModalProps) => {
+ const router = useRouter()
+ const [isVisible, setIsVisible] = useState(false)
+
+ // Check if modal should be shown
+ useEffect(() => {
+ // Don't show if already purchased
+ // Note: Eligibility check happens during the flow (geo screen), not here
+ if (hasPurchased) {
+ return
+ }
+
+ // Check localStorage for dismissal
+ const dismissedAt = localStorage.getItem(STORAGE_KEY)
+ if (dismissedAt) {
+ const dismissedDate = new Date(dismissedAt)
+ const now = new Date()
+ const daysSinceDismissed = (now.getTime() - dismissedDate.getTime()) / (1000 * 60 * 60 * 24)
+
+ if (daysSinceDismissed < DISMISS_DURATION_DAYS) {
+ return
+ }
+ }
+
+ // Show modal with a small delay for better UX
+ const timer = setTimeout(() => {
+ setIsVisible(true)
+ }, 1000)
+
+ return () => clearTimeout(timer)
+ }, [hasPurchased])
+
+ const handleDismiss = () => {
+ localStorage.setItem(STORAGE_KEY, new Date().toISOString())
+ setIsVisible(false)
+ }
+
+ const handleJoinNow = () => {
+ setIsVisible(false)
+ router.push('/card')
+ }
+
+ return (
+
+
+ {/* Title */}
+
Become a Pioneer
+
+ {/* Description */}
+
+ Join the Peanut Card Pioneers now to earn rewards for every purchase of you and your friends!
+
+
+ {/* Card Hero - scaled down for popup */}
+
+
+ {/* CTA */}
+
+
+ Get Early Access
+
+
+
+
+ )
+}
+
+export default CardPioneerModal
diff --git a/src/components/Card/CardPurchaseScreen.tsx b/src/components/Card/CardPurchaseScreen.tsx
new file mode 100644
index 000000000..4c1b7f7ca
--- /dev/null
+++ b/src/components/Card/CardPurchaseScreen.tsx
@@ -0,0 +1,216 @@
+'use client'
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { Button } from '@/components/0_Bruddle/Button'
+import NavHeader from '@/components/Global/NavHeader'
+import { Icon } from '@/components/Global/Icons/Icon'
+import Card from '@/components/Global/Card'
+import { cardApi, CardPurchaseError } from '@/services/card'
+import Loading from '@/components/Global/Loading'
+
+interface CardPurchaseScreenProps {
+ price: number
+ existingChargeUuid?: string | null
+ existingPaymentUrl?: string | null
+ onPurchaseInitiated: (chargeUuid: string, paymentUrl: string) => void
+ onPurchaseComplete: () => void
+ onBack: () => void
+}
+
+type PurchaseState = 'idle' | 'creating' | 'awaiting_payment' | 'error'
+
+const CardPurchaseScreen = ({
+ price,
+ existingChargeUuid,
+ existingPaymentUrl,
+ onPurchaseInitiated,
+ onPurchaseComplete,
+ onBack,
+}: CardPurchaseScreenProps) => {
+ const [purchaseState, setPurchaseState] = useState(existingChargeUuid ? 'awaiting_payment' : 'idle')
+ const [chargeUuid, setChargeUuid] = useState(existingChargeUuid || null)
+ const [paymentUrl, setPaymentUrl] = useState(existingPaymentUrl || null)
+ const [error, setError] = useState(null)
+
+ // Guard against double-submit race condition (React state updates are async,
+ // so rapid clicks could trigger multiple API calls before state updates)
+ const isInitiatingRef = useRef(false)
+
+ // Initialize purchase with debounce guard
+ const initiatePurchase = useCallback(async () => {
+ if (isInitiatingRef.current) return
+ isInitiatingRef.current = true
+
+ setPurchaseState('creating')
+ setError(null)
+
+ try {
+ const response = await cardApi.purchase()
+ setChargeUuid(response.chargeUuid)
+ setPaymentUrl(response.paymentUrl)
+ onPurchaseInitiated(response.chargeUuid, response.paymentUrl)
+ setPurchaseState('awaiting_payment')
+ } catch (err) {
+ if (err instanceof CardPurchaseError) {
+ if (err.code === 'ALREADY_PURCHASED') {
+ // User already purchased, redirect to success
+ onPurchaseComplete()
+ return
+ }
+ setError(err.message)
+ } else {
+ setError('Failed to initiate purchase. Please try again.')
+ }
+ setPurchaseState('error')
+ } finally {
+ isInitiatingRef.current = false
+ }
+ }, [onPurchaseInitiated, onPurchaseComplete])
+
+ // Open payment URL in new tab
+ const openPaymentUrl = useCallback(() => {
+ if (paymentUrl) {
+ window.open(paymentUrl, '_blank', 'noopener,noreferrer')
+ }
+ }, [paymentUrl])
+
+ // Poll for payment completion with timeout
+ useEffect(() => {
+ if (purchaseState !== 'awaiting_payment' || !chargeUuid) return
+
+ let attempts = 0
+ const maxAttempts = 40 // 40 attempts * 3s = 2 minutes max
+
+ const pollInterval = setInterval(async () => {
+ attempts++
+
+ // Check for timeout
+ if (attempts > maxAttempts) {
+ clearInterval(pollInterval)
+ setError('Payment verification timed out. Please check your transaction status.')
+ setPurchaseState('error')
+ return
+ }
+
+ try {
+ const info = await cardApi.getInfo()
+ if (info.hasPurchased) {
+ clearInterval(pollInterval)
+ onPurchaseComplete()
+ }
+ } catch {
+ // Ignore polling errors - will retry on next interval
+ }
+ }, 3000)
+
+ return () => clearInterval(pollInterval)
+ }, [purchaseState, chargeUuid, onPurchaseComplete])
+
+ return (
+
+
+
+
+ {purchaseState === 'idle' && (
+ <>
+
+
+
+
+
+
Confirm Purchase
+
+ You're about to reserve your Card Pioneer spot for ${price}. This amount will become
+ your starter balance when the card launches.
+
+
+
+
+ {/* Price Summary */}
+
+
+ Pioneer Reservation
+ ${price}
+
+
+ >
+ )}
+
+ {purchaseState === 'creating' && (
+
+
+
+
Creating Payment...
+
Setting up your purchase. Please wait.
+
+
+ )}
+
+ {purchaseState === 'awaiting_payment' && (
+ <>
+
+
+
+
+
+
Complete Payment
+
+ Click below to open the payment page and complete your Pioneer reservation.
+
+
+
+
+
+ Open Payment Page
+
+
+
+
+ Waiting for payment confirmation...
+
+ >
+ )}
+
+ {purchaseState === 'error' && (
+
+
+
+
+
+
Something Went Wrong
+
+ {error || 'An error occurred while processing your purchase.'}
+
+
+
+ )}
+
+ {/* CTA Buttons */}
+ {purchaseState === 'idle' && (
+
+ Pay ${price}
+
+ )}
+
+ {purchaseState === 'error' && (
+
+
+ Try Again
+
+
+ Go Back
+
+
+ )}
+
+
+ )
+}
+
+export default CardPurchaseScreen
diff --git a/src/components/Card/CardSuccessScreen.tsx b/src/components/Card/CardSuccessScreen.tsx
new file mode 100644
index 000000000..bf8c3bd3c
--- /dev/null
+++ b/src/components/Card/CardSuccessScreen.tsx
@@ -0,0 +1,144 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import { Icon } from '@/components/Global/Icons/Icon'
+import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
+import { SoundPlayer } from '@/components/Global/SoundPlayer'
+import { shootStarConfetti } from '@/utils/confetti'
+import { useAuth } from '@/context/authContext'
+import Image from 'next/image'
+import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
+
+interface CardSuccessScreenProps {
+ onViewBadges: () => void
+}
+
+const CardSuccessScreen = ({ onViewBadges }: CardSuccessScreenProps) => {
+ const [showConfetti, setShowConfetti] = useState(false)
+ const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
+ const { user } = useAuth()
+ const router = useRouter()
+
+ // Trigger star confetti on mount
+ useEffect(() => {
+ if (!showConfetti) {
+ setShowConfetti(true)
+ const duration = 2000
+ const end = Date.now() + duration
+ let cancelled = false
+
+ const frame = () => {
+ if (cancelled) return
+
+ shootStarConfetti({
+ particleCount: 20,
+ origin: { x: 0, y: 0.8 },
+ spread: 55,
+ startVelocity: 30,
+ ticks: 100,
+ })
+ shootStarConfetti({
+ particleCount: 20,
+ origin: { x: 1, y: 0.8 },
+ spread: 55,
+ startVelocity: 30,
+ ticks: 100,
+ })
+
+ if (Date.now() < end) {
+ requestAnimationFrame(frame)
+ }
+ }
+ frame()
+
+ return () => {
+ cancelled = true
+ }
+ }
+ }, [showConfetti])
+
+ return (
+ <>
+
+
+
+
+ {/* Peanut mascot background - matches PaymentSuccessView */}
+
+
+ {/* Success card */}
+
+
+
+
+
+
You're a Pioneer!
+ Card Reserved
+
+
+
+ {/* What you unlocked */}
+
+
+
+
+
+ Pioneer badge added to your profile
+
+
+
+
+
+ Priority access during launch
+
+
+
+
+
+ $5 for every friend who joins
+
+
+
+
+
+ Earn forever on every purchase
+
+
+
+ {/* CTAs */}
+
+ setIsInviteModalOpen(true)}
+ className="w-full"
+ >
+
+ Share Invite Link
+
+
+ View Your Badges
+
+
+
+
+
+ setIsInviteModalOpen(false)}
+ username={user?.user?.username ?? ''}
+ />
+ >
+ )
+}
+
+export default CardSuccessScreen
diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx
index f19e1ccd5..2e6519249 100644
--- a/src/components/Claim/Link/views/BankFlowManager.view.tsx
+++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx
@@ -286,7 +286,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
? addBankAccountResponse.data.identifier || ''
: '',
accountNumber:
- addBankAccountResponse.data.type === 'us'
+ addBankAccountResponse.data.type === 'us' || addBankAccountResponse.data.type === 'gb'
? addBankAccountResponse.data.identifier || ''
: '',
country: addBankAccountResponse.data.details.countryCode,
@@ -294,6 +294,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
bridgeAccountId: addBankAccountResponse.data.bridgeAccountId,
bic: addBankAccountResponse.data.bic ?? '',
routingNumber: addBankAccountResponse.data.routingNumber ?? '',
+ sortCode: addBankAccountResponse.data.sortCode ?? '',
firstName: addBankAccountResponse.data.firstName || rawData.firstName,
lastName: addBankAccountResponse.data.lastName || rawData.lastName,
email: user?.user.email ?? '',
@@ -365,6 +366,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
accountNumber: externalAccountResponse.account_number ?? rawData.accountNumber,
bic: externalAccountResponse?.iban?.bic ?? rawData.bic,
routingNumber: externalAccountResponse?.account?.routing_number ?? rawData.routingNumber,
+ sortCode: externalAccountResponse?.account?.sort_code ?? rawData.sortCode ?? '',
clabe: externalAccountResponse?.clabe?.account_number ?? rawData.clabe,
street: externalAccountResponse?.address?.street_line_1 ?? rawData.street,
city: externalAccountResponse?.address?.city ?? rawData.city,
@@ -420,12 +422,14 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
name: account.details.accountOwnerName || user?.user.fullName || '',
iban: account.type === 'iban' ? account.identifier || '' : '',
clabe: account.type === 'clabe' ? account.identifier || '' : '',
- accountNumber: account.type === 'us' ? account.identifier || '' : '',
+ accountNumber:
+ account.type === 'us' || account.type === 'gb' ? account.identifier || '' : '',
country: account.details.countryCode,
id: account.id,
bridgeAccountId: account.bridgeAccountId,
bic: account.bic ?? '',
routingNumber: account.routingNumber ?? '',
+ sortCode: account.sortCode ?? '',
firstName: firstName,
lastName: lastName,
email: user?.user.email ?? '',
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/Global/AnimateOnView.tsx b/src/components/Global/AnimateOnView.tsx
new file mode 100644
index 000000000..64f07753b
--- /dev/null
+++ b/src/components/Global/AnimateOnView.tsx
@@ -0,0 +1,52 @@
+'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/BackendErrorScreen/index.tsx b/src/components/Global/BackendErrorScreen/index.tsx
new file mode 100644
index 000000000..12ad64952
--- /dev/null
+++ b/src/components/Global/BackendErrorScreen/index.tsx
@@ -0,0 +1,103 @@
+'use client'
+
+import { useAuth } from '@/context/authContext'
+import { Button } from '@/components/0_Bruddle/Button'
+
+// inline peanut icon svg to ensure it works without needing to fetch external assets
+const PeanutIcon = ({ className }: { className?: string }) => (
+
+ {/* peanut shape */}
+
+
+ {/* eye lines */}
+
+
+ {/* left eye */}
+
+
+ {/* right eye */}
+
+
+ {/* right pupil */}
+
+
+ {/* left pupil */}
+
+
+ {/* smile */}
+
+
+)
+
+/**
+ * full-page error screen shown when backend requests fail after retries
+ * displays peanut logo and options to retry or log out
+ */
+export default function BackendErrorScreen() {
+ const { logoutUser, isLoggingOut } = useAuth()
+
+ const handleRetry = () => {
+ window.location.reload()
+ }
+
+ const handleForceLogout = () => {
+ // Use skipBackendCall since backend is likely down (that's why we're on this screen)
+ logoutUser({ skipBackendCall: true })
+ }
+
+ return (
+
+
+
+
Something went wrong
+
We're having trouble connecting to our servers.
+
+
+
+ Try Again
+
+
+ {isLoggingOut ? 'Logging out...' : 'Log out'}
+
+
+
+ )
+}
diff --git a/src/components/Global/Banner/index.tsx b/src/components/Global/Banner/index.tsx
index 1204c9b1e..0330cac21 100644
--- a/src/components/Global/Banner/index.tsx
+++ b/src/components/Global/Banner/index.tsx
@@ -18,8 +18,9 @@ export function Banner() {
return
}
- // don't show beta feedback banner on landing page, setup page, or quests pages
- if (pathname === '/' || pathname === '/setup' || pathname.startsWith('/quests')) return null
+ // don't show beta feedback banner on landing pages, setup page, or quests pages
+ if (pathname === '/' || pathname === '/setup' || pathname.startsWith('/quests') || pathname.startsWith('/lp'))
+ return null
// show beta feedback banner when not in maintenance
return
diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx
index de73f8c00..406353b48 100644
--- a/src/components/Global/FAQs/index.tsx
+++ b/src/components/Global/FAQs/index.tsx
@@ -53,7 +53,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
return (
-
+
(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/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx
index 4128a277d..faf8bafb9 100644
--- a/src/components/Global/Icons/Icon.tsx
+++ b/src/components/Global/Icons/Icon.tsx
@@ -18,6 +18,7 @@ import {
CurrencyExchangeRounded,
LocalOfferOutlined,
CardGiftcardRounded,
+ CreditCardRounded,
HomeRounded,
SearchRounded,
AccountBalanceWalletRounded,
@@ -150,6 +151,7 @@ export type IconName =
| 'upload-cloud'
| 'alert-filled'
| 'paste'
+ | 'credit-card'
export interface IconProps extends SVGProps {
name: IconName
size?: number | string
@@ -290,6 +292,7 @@ const iconComponents: Record>> =
'invite-heart': InviteHeartIcon,
'alert-filled': (props) => ,
paste: (props) => ,
+ 'credit-card': (props) => ,
}
export const Icon: FC = ({ name, size = 24, width, height, ...props }) => {
diff --git a/src/components/Global/InviteFriendsModal/index.tsx b/src/components/Global/InviteFriendsModal/index.tsx
new file mode 100644
index 000000000..85c45468c
--- /dev/null
+++ b/src/components/Global/InviteFriendsModal/index.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import ActionModal from '@/components/Global/ActionModal'
+import Card from '@/components/Global/Card'
+import CopyToClipboard from '@/components/Global/CopyToClipboard'
+import ShareButton from '@/components/Global/ShareButton'
+import { generateInviteCodeLink, generateInvitesShareText } from '@/utils/general.utils'
+import QRCode from 'react-qr-code'
+
+interface InviteFriendsModalProps {
+ visible: boolean
+ onClose: () => void
+ username: string
+}
+
+/**
+ * Shared modal for inviting friends to Peanut.
+ * Shows QR code, invite code, and share button.
+ *
+ * Used in: CardSuccessScreen, Profile, PointsPage
+ */
+export default function InviteFriendsModal({ visible, onClose, username }: InviteFriendsModalProps) {
+ const { inviteCode, inviteLink } = generateInviteCodeLink(username)
+
+ return (
+
+ {inviteLink && (
+
+
+
+ )}
+
+
+
+ {inviteCode}
+
+
+
+
+ Promise.resolve(generateInvitesShareText(inviteLink))}
+ title="Share your invite link"
+ >
+ Share Invite Link
+
+ >
+ }
+ />
+ )
+}
diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx
index 736d19e51..60dfddc9c 100644
--- a/src/components/Global/InvitesGraph/index.tsx
+++ b/src/components/Global/InvitesGraph/index.tsx
@@ -184,7 +184,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const {
width,
height,
- backgroundColor = '#f9fafb',
+ backgroundColor = '#FAF4F0',
showUsernames: initialShowUsernames = true,
topNodes: initialTopNodes = DEFAULT_TOP_NODES,
activityFilter: initialActivityFilter = DEFAULT_ACTIVITY_FILTER,
@@ -253,6 +253,12 @@ export default function InvitesGraph(props: InvitesGraphProps) {
// topNodes: limit to top N by points (0 = all). Backend-filtered, triggers refetch.
const [topNodes, setTopNodes] = useState(initialTopNodes)
+ // Particle arrival popups for user mode (+1 pt animations)
+ // Map: linkId → { timestamp, x, y, nodeId }
+ const particleArrivalsRef = useRef>(
+ new Map()
+ )
+
// Use passed data in minimal mode, fetched data otherwise
// Note: topNodes filtering is now done by backend, no client-side pruning needed
// Performance mode: frontend filter to top 1000 without refetch
@@ -564,6 +570,17 @@ export default function InvitesGraph(props: InvitesGraphProps) {
return map
}, [filteredGraphData])
+ // Build set of inviter node IDs (nodes that have outgoing invite edges)
+ // Used in minimal/user mode to show heart icon next to inviter usernames
+ const inviterNodes = useMemo(() => {
+ if (!filteredGraphData) return new Set()
+ const set = new Set()
+ filteredGraphData.edges.forEach((edge) => {
+ set.add(edge.source) // source = inviter
+ })
+ return set
+ }, [filteredGraphData])
+
// Build set of node IDs that participate in P2P (for payment mode coloring)
// A node is "P2P active" if it's the source or target of any P2P edge
const p2pActiveNodes = useMemo(() => {
@@ -893,6 +910,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
visibilityConfig,
externalNodesConfig,
p2pActiveNodes,
+ inviterNodes,
})
useEffect(() => {
displaySettingsRef.current = {
@@ -904,6 +922,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
visibilityConfig,
externalNodesConfig,
p2pActiveNodes,
+ inviterNodes,
}
}, [
showUsernames,
@@ -914,6 +933,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
visibilityConfig,
externalNodesConfig,
p2pActiveNodes,
+ inviterNodes,
])
// Helper to determine user activity status
@@ -967,11 +987,11 @@ export default function InvitesGraph(props: InvitesGraphProps) {
// Colors by type
const colors: Record = {
- WALLET: '#f59e0b', // Orange/amber
- BANK: '#3b82f6', // Blue
- MERCHANT: '#10b981', // Green/emerald
+ WALLET: '#FFC900', // secondary-1 (yellow)
+ BANK: '#90A8ED', // secondary-3 (blue)
+ MERCHANT: '#BA8BFF', // primary-4 (purple)
}
- const fillColor = colors[node.externalType] || '#6b7280'
+ const fillColor = colors[node.externalType] || '#9CA3AF'
ctx.globalAlpha = 0.8
ctx.fillStyle = fillColor
@@ -1063,23 +1083,23 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const { p2pActiveNodes: p2pNodes } = displaySettingsRef.current
if (currentMode === 'user') {
- // User mode: all nodes same pleasant purple
- fillColor = 'rgba(139, 92, 246, 0.9)' // Solid purple for all
+ // User mode: all nodes same pink (primary-1 #FF90E8), fully opaque
+ fillColor = 'rgb(255, 144, 232)' // primary-1
} else if (currentMode === 'payment') {
// Payment mode: color by P2P participation (sending or receiving)
const hasP2PActivity = p2pNodes.has(node.id)
fillColor = hasP2PActivity
- ? 'rgba(139, 92, 246, 0.85)' // Purple for P2P active
+ ? 'rgba(255, 144, 232, 0.85)' // primary-1 for P2P active
: 'rgba(156, 163, 175, 0.5)' // Grey for no P2P
} else if (!filter.enabled) {
// No filter - simple active/inactive by access
- fillColor = hasAccess ? 'rgba(139, 92, 246, 0.85)' : 'rgba(156, 163, 175, 0.85)'
+ fillColor = hasAccess ? 'rgba(255, 144, 232, 0.85)' : 'rgba(156, 163, 175, 0.85)'
} else {
// Activity filter enabled - three states
if (activityStatus === 'new') {
- fillColor = 'rgba(16, 185, 129, 0.85)' // Green for new signups
+ fillColor = 'rgba(144, 168, 237, 0.85)' // secondary-3 #90A8ED for new signups
} else if (activityStatus === 'active') {
- fillColor = 'rgba(139, 92, 246, 0.85)' // Purple for active
+ fillColor = 'rgba(255, 144, 232, 0.85)' // primary-1 for active
} else {
// Inactive - exponential time bands with distinct shades
const now = Date.now()
@@ -1120,7 +1140,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
ctx.globalAlpha = 1
if (isSelected) {
// Selected: golden outline
- ctx.strokeStyle = '#fbbf24'
+ ctx.strokeStyle = '#FFC900'
ctx.lineWidth = 3
ctx.stroke()
} else if (!hasAccess) {
@@ -1136,11 +1156,76 @@ export default function InvitesGraph(props: InvitesGraphProps) {
if (showNames && (minimal || globalScale > 1.2)) {
const label = node.username
const fontSize = minimal ? 4 : 12 / globalScale
+ const { inviterNodes: inviterNodesSet } = displaySettingsRef.current
+ const isInviter = inviterNodesSet && inviterNodesSet.has(node.id)
+
ctx.font = `600 ${fontSize}px Inter, system-ui, -apple-system, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = activityStatus === 'inactive' && filter.enabled ? 'rgba(17, 24, 39, 0.3)' : '#111827'
- ctx.fillText(label, node.x, node.y + size + fontSize + 2)
+
+ const labelY = node.y + size + fontSize + 2
+
+ // Render username
+ ctx.fillText(label, node.x, labelY)
+
+ // Add heart icon for inviters in minimal/user mode
+ if (minimal && isInviter) {
+ // Measure text to position heart after it
+ const textWidth = ctx.measureText(label).width
+ const heartX = node.x + textWidth / 2 + fontSize * 0.6
+ const heartY = labelY
+ const heartSize = fontSize * 0.7
+
+ // Draw simple heart shape (pink/magenta)
+ ctx.save()
+ ctx.fillStyle = '#FF90E8'
+ ctx.beginPath()
+ // Heart shape using two circles and a triangle
+ const topY = heartY - heartSize * 0.3
+ ctx.arc(heartX - heartSize * 0.25, topY, heartSize * 0.3, 0, Math.PI, true)
+ ctx.arc(heartX + heartSize * 0.25, topY, heartSize * 0.3, 0, Math.PI, true)
+ ctx.lineTo(heartX + heartSize * 0.5, topY)
+ ctx.lineTo(heartX, heartY + heartSize * 0.3)
+ ctx.lineTo(heartX - heartSize * 0.5, topY)
+ ctx.closePath()
+ ctx.fill()
+ ctx.restore()
+ }
+ }
+
+ // Render "+1" popups for particle arrivals in user mode
+ // currentMode is already defined above, reuse it
+ if (currentMode === 'user' && minimal) {
+ const now = performance.now()
+ const popupDuration = 1500 // 1.5 seconds
+ const arrivals = particleArrivalsRef.current
+
+ // Clean up old arrivals and render active ones
+ const toDelete: string[] = []
+ arrivals.forEach((arrival, linkId) => {
+ const age = now - arrival.timestamp
+ if (age > popupDuration) {
+ toDelete.push(linkId)
+ } else {
+ // Render popup with fade-out - start from node center, rise up
+ const progress = age / popupDuration
+ const alpha = 1 - progress
+ const yOffset = -progress * 15 // Rise up 15px from node center
+
+ ctx.save()
+ ctx.globalAlpha = alpha
+ ctx.font = 'bold 5px Inter, system-ui, -apple-system, sans-serif'
+ ctx.fillStyle = '#fbbf24' // Gold color
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText('+1 point', arrival.x, arrival.y + yOffset)
+ ctx.restore()
+ }
+ })
+
+ // Clean up expired arrivals
+ toDelete.forEach((linkId) => arrivals.delete(linkId))
}
},
[getUserActivityStatus]
@@ -1211,14 +1296,14 @@ export default function InvitesGraph(props: InvitesGraphProps) {
// Get target node type for color
const extType = target.externalType || 'WALLET'
const lineColors: Record = {
- WALLET: 'rgba(245, 158, 11, 0.25)', // Orange
- BANK: 'rgba(59, 130, 246, 0.25)', // Blue
- MERCHANT: 'rgba(16, 185, 129, 0.25)', // Green
+ WALLET: 'rgba(255, 201, 0, 0.25)', // secondary-1
+ BANK: 'rgba(144, 168, 237, 0.25)', // secondary-3
+ MERCHANT: 'rgba(186, 139, 255, 0.25)', // primary-4
}
const particleColors: Record = {
- WALLET: 'rgba(245, 158, 11, 0.8)', // Orange
- BANK: 'rgba(59, 130, 246, 0.8)', // Blue
- MERCHANT: 'rgba(16, 185, 129, 0.8)', // Green
+ WALLET: 'rgba(255, 201, 0, 0.8)', // secondary-1
+ BANK: 'rgba(144, 168, 237, 0.8)', // secondary-3
+ MERCHANT: 'rgba(186, 139, 255, 0.8)', // primary-4
}
// Convert frequency/volume labels to numeric values for rendering
@@ -1233,7 +1318,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const lineWidth = Math.min(0.4 + txCount * 0.25, 3.0)
// Draw base line
- ctx.strokeStyle = lineColors[extType] || 'rgba(107, 114, 128, 0.25)'
+ ctx.strokeStyle = lineColors[extType] || 'rgba(156, 163, 175, 0.25)'
ctx.lineWidth = lineWidth
ctx.beginPath()
ctx.moveTo(source.x, source.y)
@@ -1280,7 +1365,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
// P2P: Draw line with animated particles (scaled by activity & volume)
// Supports both full mode (count/totalUsd) and anonymized mode (frequency/volume labels)
const baseAlpha = inactive ? 0.08 : 0.25
- ctx.strokeStyle = `rgba(6, 182, 212, ${baseAlpha})`
+ ctx.strokeStyle = `rgba(144, 168, 237, ${baseAlpha})`
// Convert frequency/volume labels to numeric values for rendering
// Full mode: use actual values; Anonymized mode: map labels to ranges
@@ -1314,7 +1399,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const particleSize = 1.5 + logUsd * 2.25
const isBidirectional = link.bidirectional === true
- ctx.fillStyle = 'rgba(6, 182, 212, 0.85)'
+ ctx.fillStyle = 'rgba(144, 168, 237, 0.85)'
for (let i = 0; i < particleCount; i++) {
// Forward direction (source → target)
@@ -1339,7 +1424,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
} else {
// Invite: Draw line with multiple arrows along the edge
const isDirect = link.type === 'DIRECT'
- const baseColor = isDirect ? [139, 92, 246] : [236, 72, 153]
+ const baseColor = isDirect ? [255, 144, 232] : [186, 139, 255]
const alpha = inactive ? 0.12 : 0.35
const arrowAlpha = inactive ? 0.2 : 0.6
const { mode: currentMode } = displaySettingsRef.current
@@ -1362,7 +1447,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const particleSize = 3
// Gold color for points
- ctx.fillStyle = 'rgba(251, 191, 36, 0.9)' // #fbbf24 with alpha
+ ctx.fillStyle = 'rgba(255, 201, 0, 0.9)' // secondary-1 #FFC900 with alpha
for (let i = 0; i < particleCount; i++) {
// Flow direction: source → target (invitee → inviter)
@@ -1372,6 +1457,22 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const t = (time * baseSpeed + i / particleCount) % 1
const px = source.x + (target.x - source.x) * t
const py = source.y + (target.y - source.y) * t
+
+ // Detect arrival: when particle is close to target (t > 0.95)
+ // Track arrival to show "+1 pt" popup
+ if (t > 0.95 && t < 0.99) {
+ const linkId = `${link.source.id}_${link.target.id}_${i}`
+ const arrivals = particleArrivalsRef.current
+ if (!arrivals.has(linkId)) {
+ arrivals.set(linkId, {
+ timestamp: time,
+ x: target.x,
+ y: target.y,
+ nodeId: link.target.id,
+ })
+ }
+ }
+
ctx.beginPath()
ctx.arc(px, py, particleSize, 0, 2 * Math.PI)
ctx.fill()
@@ -1465,7 +1566,13 @@ export default function InvitesGraph(props: InvitesGraphProps) {
return
}
- // User node → Select (camera follows) - click again to open Grafana
+ // User mode: Navigate to user profile in new tab
+ if (isMinimal && node.username) {
+ window.open(`/${node.username}`, '_blank')
+ return
+ }
+
+ // Full/Payment mode: User node → Select (camera follows) - click again to open Grafana
if (selectedUserId === node.id) {
// Already selected - open Grafana
const username = node.username || node.id
@@ -1478,7 +1585,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
setSelectedUserId(node.id)
}
},
- [selectedUserId]
+ [selectedUserId, isMinimal]
)
// Right-click selects the node (camera follows)
@@ -1796,6 +1903,34 @@ export default function InvitesGraph(props: InvitesGraphProps) {
return () => clearTimeout(timeout)
}, [filteredGraphData])
+ // Continuous zoom tracking in minimal mode during simulation settling
+ useEffect(() => {
+ if (!isMinimal || !filteredGraphData || !graphRef.current) return
+
+ let frameId: number | null = null
+ const startTime = Date.now()
+ const trackDuration = 4000 // Track for 4 seconds (simulation should settle by then)
+
+ const continuousZoom = () => {
+ const elapsed = Date.now() - startTime
+ if (elapsed > trackDuration || !graphRef.current) return
+
+ // Zoom to fit every frame during settling - fast animation
+ graphRef.current.zoomToFit(100, 40)
+ frameId = requestAnimationFrame(continuousZoom)
+ }
+
+ // Start tracking immediately after graph mounts
+ const timeout = setTimeout(() => {
+ frameId = requestAnimationFrame(continuousZoom)
+ }, 100)
+
+ return () => {
+ if (frameId) cancelAnimationFrame(frameId)
+ clearTimeout(timeout)
+ }
+ }, [isMinimal, filteredGraphData])
+
// Center on selected node - track continuously as it moves
useEffect(() => {
if (!selectedUserId || !graphRef.current) return
@@ -1998,8 +2133,8 @@ export default function InvitesGraph(props: InvitesGraphProps) {
enableZoomInteraction={true}
cooldownTicks={Infinity}
warmupTicks={0}
- d3AlphaDecay={0.005}
- d3VelocityDecay={0.6}
+ d3AlphaDecay={isMinimal ? 0.03 : 0.005}
+ d3VelocityDecay={isMinimal ? 0.8 : 0.6}
d3AlphaMin={0.001}
onEngineStop={handleEngineStop}
backgroundColor={backgroundColor}
@@ -2346,7 +2481,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
d3VelocityDecay={0.6}
d3AlphaMin={0.001}
onEngineStop={handleEngineStop}
- backgroundColor="#f9fafb"
+ backgroundColor="#FAF4F0"
width={graphWidth}
height={graphHeight}
autoPauseRedraw={false}
diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx
index 043c75029..31b80098a 100644
--- a/src/components/Global/NavHeader/index.tsx
+++ b/src/components/Global/NavHeader/index.tsx
@@ -62,7 +62,7 @@ const NavHeader = ({
{showLogoutBtn && (
logoutUser()}
loading={isLoggingOut}
variant="stroke"
icon="logout"
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
index 70794417e..88d185867 100644
--- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
+++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
@@ -23,6 +23,8 @@ interface CarouselCTAProps {
isPermissionDenied?: boolean
secondaryIcon?: StaticImageData | string
iconSize?: number
+ // Perk claim indicator - shows pink dot instead of X close button
+ isPerkClaim?: boolean
}
const CarouselCTA = ({
@@ -37,6 +39,7 @@ const CarouselCTA = ({
secondaryIcon,
iconSize = 22,
logoSize = 36,
+ isPerkClaim,
}: CarouselCTAProps) => {
const [showPermissionDeniedModal, setShowPermissionDeniedModal] = useState(false)
const { triggerHaptic } = useHaptic()
@@ -80,18 +83,24 @@ const CarouselCTA = ({
onClick={handleClick}
className="embla__slide relative flex flex-row items-center justify-around px-2 py-2 md:py-3"
>
- {/* Close button - consistent positioning and size */}
-
-
-
+ {/* Close button or pink dot indicator for perk claims */}
+ {isPerkClaim ? (
+
+ ) : (
+
+
+
+ )}
{/* Icon container */}
{
const { carouselCTAs, setCarouselCTAs } = useHomeCarouselCTAs()
+ const { user } = useAuth()
+ const queryClient = useQueryClient()
+
+ // Perk claim modal state
+ const [selectedPerk, setSelectedPerk] = useState
(null)
+ const [claimedPerkIds, setClaimedPerkIds] = useState>(new Set())
+
+ useEffect(() => {
+ setClaimedPerkIds(new Set())
+ }, [user?.user.userId])
+
+ // Fetch pending perks
+ const { data: pendingPerksData } = useQuery({
+ queryKey: ['pendingPerks', user?.user.userId],
+ queryFn: () => perksApi.getPendingPerks(),
+ enabled: !!user?.user.userId,
+ })
+
+ // Listen for real-time perk notifications via WebSocket
+ useWebSocket({
+ username: user?.user.username ?? undefined,
+ onPendingPerk: useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: ['pendingPerks'] })
+ }, [queryClient]),
+ })
+
+ // Filter for Card Pioneer inviter rewards that haven't been claimed
+ const cardPioneerPerks = useMemo(() => {
+ if (underMaintenanceConfig.disableCardPioneers) return []
+ return (
+ pendingPerksData?.perks?.filter(
+ (p) => p.name === 'Card Pioneer Inviter Reward' && !claimedPerkIds.has(p.id)
+ ) || []
+ )
+ }, [pendingPerksData?.perks, claimedPerkIds])
+
+ // Convert perks to carousel CTAs (these come first!)
+ const perkCTAs: CarouselCTAType[] = useMemo(() => {
+ return cardPioneerPerks.map((perk) => {
+ const inviteeName = extractInviteeName(perk.reason)
+ return {
+ id: `perk-${perk.id}`,
+ title: (
+
+ +${perk.amountUsd} reward ready!
+
+ ),
+ description: (
+
+ {inviteeName} joined Pioneers. Tap to claim.
+
+ ),
+ icon: 'gift' as IconName,
+ iconContainerClassName: 'bg-primary-1',
+ onClick: () => setSelectedPerk(perk),
+ isPerkClaim: true,
+ iconSize: 16,
+ }
+ })
+ }, [cardPioneerPerks])
+
+ // Combine perk CTAs (first) with regular CTAs
+ const allCTAs = useMemo(() => {
+ return [...perkCTAs, ...carouselCTAs]
+ }, [perkCTAs, carouselCTAs])
+
+ const handlePerkClaimed = useCallback((perkId: string) => {
+ setClaimedPerkIds((prev) => new Set(prev).add(perkId))
+ }, [])
+
+ const handleModalClose = useCallback(() => {
+ setSelectedPerk(null)
+ }, [])
// don't render carousel if there are no CTAs
- if (!carouselCTAs.length) return null
+ if (!allCTAs.length) return null
return (
-
- {carouselCTAs.map((cta) => (
- {
- // Use cta.onClose if provided (for notification prompt), otherwise filter from list
- if (cta.onClose) {
- cta.onClose()
- } else {
- setCarouselCTAs((prev) => prev.filter((c) => c.id !== cta.id))
- }
- }}
- onClick={cta.onClick}
- logo={cta.logo}
- iconContainerClassName={cta.iconContainerClassName}
- isPermissionDenied={cta.isPermissionDenied}
- secondaryIcon={cta.secondaryIcon}
- iconSize={16}
- logoSize={cta.logoSize}
+ <>
+
+ {allCTAs.map((cta) => (
+ {
+ // Use cta.onClose if provided (for notification prompt), otherwise filter from list
+ if (cta.onClose) {
+ cta.onClose()
+ } else {
+ setCarouselCTAs((prev) => prev.filter((c) => c.id !== cta.id))
+ }
+ }}
+ onClick={cta.onClick}
+ logo={cta.logo}
+ iconContainerClassName={cta.iconContainerClassName}
+ isPermissionDenied={cta.isPermissionDenied}
+ secondaryIcon={cta.secondaryIcon}
+ iconSize={16}
+ logoSize={cta.logoSize}
+ isPerkClaim={cta.isPerkClaim}
+ />
+ ))}
+
+
+ {/* Perk Claim Modal */}
+ {selectedPerk && (
+
- ))}
-
+ )}
+ >
)
}
diff --git a/src/components/Home/PerkClaimModal.tsx b/src/components/Home/PerkClaimModal.tsx
new file mode 100644
index 000000000..5a694fdce
--- /dev/null
+++ b/src/components/Home/PerkClaimModal.tsx
@@ -0,0 +1,350 @@
+'use client'
+
+import { useState, useCallback, useRef, useEffect } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { perksApi, type PendingPerk } from '@/services/perks'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { useHoldToClaim } from '@/hooks/useHoldToClaim'
+import { getShakeClass } from '@/utils/perk.utils'
+import { extractInviteeName } from '@/utils/general.utils'
+import { shootDoubleStarConfetti } from '@/utils/confetti'
+import { SoundPlayer } from '@/components/Global/SoundPlayer'
+import { useHaptic } from 'use-haptic'
+import ActionModal from '@/components/Global/ActionModal'
+
+type ClaimPhase = 'idle' | 'holding' | 'opening' | 'revealed' | 'exiting'
+
+interface PerkClaimModalProps {
+ perk: PendingPerk
+ visible: boolean
+ onClose: () => void
+ onClaimed: (perkId: string) => void
+}
+
+/**
+ * Modal for claiming perks with gift box animation.
+ * Contains the shake/hold interaction, confetti, and success state.
+ * Uses ActionModal for consistent styling with other modals.
+ */
+export function PerkClaimModal({ perk, visible, onClose, onClaimed }: PerkClaimModalProps) {
+ const queryClient = useQueryClient()
+ const [claimPhase, setClaimPhase] = useState('idle')
+ const [lastClaimedPerk, setLastClaimedPerk] = useState(null)
+ const apiCallRef = useRef | null>(null)
+ const revealTimerRef = useRef | null>(null)
+ const dismissTimerRef = useRef | null>(null)
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ if (revealTimerRef.current) clearTimeout(revealTimerRef.current)
+ if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
+ }
+ }, [])
+
+ // Reset state when modal opens with new perk
+ useEffect(() => {
+ if (visible) {
+ setClaimPhase('idle')
+ setLastClaimedPerk(null)
+ }
+ }, [visible, perk.id])
+
+ // Optimistic claim: trigger animation immediately, API call in background
+ const handleHoldComplete = useCallback(async () => {
+ // Phase 1: Opening animation (gift shakes on its own, builds anticipation)
+ setClaimPhase('opening')
+
+ // Fire API call in background - don't await it
+ apiCallRef.current = (async () => {
+ try {
+ const result = await perksApi.claimPerk(perk.id)
+ if (result.success) {
+ onClaimed(perk.id)
+ queryClient.invalidateQueries({ queryKey: ['pendingPerks'] })
+ queryClient.invalidateQueries({ queryKey: ['transactions'] })
+ }
+ } catch (error) {
+ console.error('Failed to claim perk:', error)
+ }
+ })()
+
+ // Phase 2: After 600ms of autonomous shaking, burst into confetti
+ revealTimerRef.current = setTimeout(() => {
+ // Haptic burst feedback
+ if ('vibrate' in navigator) {
+ navigator.vibrate([100, 50, 100, 50, 200])
+ }
+
+ // Confetti explosion!
+ shootDoubleStarConfetti({ origin: { x: 0.5, y: 0.4 } })
+
+ // Phase 3: Show revealed state
+ setLastClaimedPerk(perk)
+ setClaimPhase('revealed')
+ }, 600)
+ }, [perk, queryClient, onClaimed])
+
+ // Handle dismissing the success message
+ const handleDismissSuccess = useCallback(() => {
+ setClaimPhase('exiting')
+ dismissTimerRef.current = setTimeout(() => {
+ onClose()
+ }, 400)
+ }, [onClose])
+
+ // Handle modal close based on current phase
+ const handleModalClose = useCallback(() => {
+ if (claimPhase === 'revealed') {
+ handleDismissSuccess()
+ } else if (claimPhase === 'idle') {
+ onClose()
+ }
+ // Don't allow closing during opening/exiting phases
+ }, [claimPhase, handleDismissSuccess, onClose])
+
+ if (!visible) return null
+
+ const isSuccessPhase = (claimPhase === 'revealed' || claimPhase === 'exiting') && !!lastClaimedPerk
+
+ // Use ActionModal's native props for success phase, custom content for gift box phase
+ if (isSuccessPhase) {
+ return (
+
+ )
+ }
+
+ return (
+ }
+ />
+ )
+}
+
+interface SuccessModalProps {
+ perk: PendingPerk
+ claimPhase: ClaimPhase
+ onClose: () => void
+ onDismiss: () => void
+}
+
+/**
+ * Success modal using ActionModal's native layout for consistent design system styling.
+ * Uses icon/title/description props for standard vertical centered layout.
+ */
+function SuccessModal({ perk, claimPhase, onClose, onDismiss }: SuccessModalProps) {
+ const inviteeName = extractInviteeName(perk.reason)
+ const { triggerHaptic } = useHaptic()
+ const [canDismiss, setCanDismiss] = useState(false)
+ const isExiting = claimPhase === 'exiting'
+
+ useEffect(() => {
+ triggerHaptic()
+ const dismissTimer = setTimeout(() => setCanDismiss(true), 2000)
+ return () => clearTimeout(dismissTimer)
+ }, [triggerHaptic])
+
+ return (
+
+ +${perk.amountUsd}
+
+
+ {inviteeName}
+ joined Pioneers
+
+
+ }
+ ctas={canDismiss ? [{ text: 'Done', onClick: onDismiss, variant: 'purple' as const }] : undefined}
+ content={ }
+ />
+ )
+}
+
+interface GiftBoxContentProps {
+ perk: PendingPerk
+ onHoldComplete: () => void
+ claimPhase: ClaimPhase
+}
+
+/**
+ * Gift box with hold-to-claim interaction
+ */
+function GiftBoxContent({ perk, onHoldComplete, claimPhase }: GiftBoxContentProps) {
+ const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({
+ onComplete: onHoldComplete,
+ disabled: claimPhase !== 'idle',
+ enableTapMode: true,
+ tapProgress: 12,
+ holdProgressPerSec: 80,
+ decayRate: 8,
+ })
+
+ // Ribbon opens outward based on hold progress (max 30deg spread)
+ const ribbonSpread = (holdProgress / 100) * 30
+
+ // Determine animation classes based on phase
+ const getAnimationClass = () => {
+ if (claimPhase === 'opening') {
+ return 'animate-gift-opening'
+ }
+ if (isShaking) {
+ return getShakeClass(isShaking, shakeIntensity)
+ }
+ return ''
+ }
+
+ const inviteeName = extractInviteeName(perk.reason)
+
+ return (
+
+ {/* Title */}
+
+
+ {inviteeName} joined Pioneers!
+
+
+ {/* Gift box wrapper - only this shakes */}
+
+ {/* Glow effect behind gift */}
+
+
+ {/* Gift box container */}
+
+ {/* Gift box */}
+
0 ? 'scale-[0.98]' : ''}`}
+ >
+ {/* Vertical ribbon */}
+
+
+ {/* Horizontal ribbon */}
+
+
+ {/* Light rays from center */}
+
+
+ {/* Cracks appearing with progress */}
+ {holdProgress > 20 && (
+
+ )}
+ {holdProgress > 40 && (
+
+ )}
+ {holdProgress > 60 && (
+
+ )}
+
+ {/* Gift icon */}
+
+
30 ? 'animate-bounce' : ''}`}
+ >
+
+
+
+
+
+ {/* Ribbon bow */}
+
+
+ {/* Left ribbon tail */}
+
+ {/* Right ribbon tail */}
+
+ {/* Left loop */}
+
+ {/* Right loop */}
+
+ {/* Center knot */}
+
+
+
+
+ {/* Particles flying out */}
+ {holdProgress > 30 && (
+ <>
+
+ ✨
+
+
+ ✨
+
+ >
+ )}
+ {holdProgress > 60 && (
+ <>
+
+ ⭐
+
+
+ ⭐
+
+ >
+ )}
+
+
+
+ {/* Instructions */}
+
Hold to unwrap your reward
+
+ )
+}
+
+export default PerkClaimModal
diff --git a/src/components/IdentityVerification/StartVerificationModal.tsx b/src/components/IdentityVerification/StartVerificationModal.tsx
index 54b53b841..d5a14a63b 100644
--- a/src/components/IdentityVerification/StartVerificationModal.tsx
+++ b/src/components/IdentityVerification/StartVerificationModal.tsx
@@ -54,8 +54,8 @@ const StartVerificationModal = ({
return (
- To make international money transfers, you must verify your identity using a government-issued
- ID.
+ To send money to and from bank accounts and local payment methods, verify your identity with a
+ government-issued ID.
)
}
@@ -64,9 +64,7 @@ const StartVerificationModal = ({
{
+ const router = useRouter()
+ const [screenWidth, setScreenWidth] = useState(1200)
+
+ useEffect(() => {
+ const handleResize = () => setScreenWidth(window.innerWidth)
+ handleResize() // Set actual width on mount
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ const isMobile = screenWidth < 768
+
+ const handleCTA = () => {
+ router.push('/lp/card')
+ }
+
+ return (
+
+ {!isMobile && }
+
+ {/* Card on left */}
+
+
+ {/* Copy on right */}
+
+
+ PAY EVERYWHERE.
+
+
+
+ Get the Peanut Card and pay anywhere in the world.
+
+
+
+
+
+ Best rates - no hidden fees
+
+
+
+ Earn forever for every invite
+
+
+
+ Self-custodial - your funds, your control
+
+
+
+
+
+ GET MY CARD
+
+
+
+
+
+ )
+}
+
+// Animated stars - matches Manteca.tsx pattern
+const Stars = () => (
+ <>
+
+
+
+ >
+)
+
+export { CardPioneers }
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..ef9566466
--- /dev/null
+++ b/src/components/LandingPage/LandingPageClient.tsx
@@ -0,0 +1,217 @@
+'use client'
+
+import { useFooterVisibility } from '@/context/footerVisibility'
+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'
+
+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)
+ const touchStartY = useRef(0)
+
+ // Use refs to avoid re-attaching listeners on every state change
+ const isScrollFrozenRef = useRef(isScrollFrozen)
+ const animationCompleteRef = useRef(animationComplete)
+ const shrinkingPhaseRef = useRef(shrinkingPhase)
+ const hasGrownRef = useRef(hasGrown)
+ isScrollFrozenRef.current = isScrollFrozen
+ animationCompleteRef.current = animationComplete
+ shrinkingPhaseRef.current = shrinkingPhase
+ hasGrownRef.current = hasGrown
+
+ useEffect(() => {
+ if (isFooterVisible) {
+ setButtonVisible(false)
+ } else {
+ setButtonVisible(true)
+ }
+ }, [isFooterVisible])
+
+ // Shared logic: accumulate virtual scroll delta and animate the button scale
+ const handleScrollDelta = useCallback((deltaY: number) => {
+ if (!isScrollFrozenRef.current || animationCompleteRef.current) return
+ if (deltaY <= 0) return
+
+ virtualScrollY.current += deltaY
+
+ const maxVirtualScroll = 500
+ const newScale = Math.min(1.5, 1 + (virtualScrollY.current / maxVirtualScroll) * 0.5)
+ setButtonScale(newScale)
+
+ if (newScale >= 1.5) {
+ setAnimationComplete(true)
+ setHasGrown(true)
+ document.body.style.overflow = ''
+ setIsScrollFrozen(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleScroll = () => {
+ if (sendInSecondsRef.current) {
+ const targetElement = document.getElementById('sticky-button-target')
+ if (!targetElement) return
+
+ const targetRect = targetElement.getBoundingClientRect()
+ const currentScrollY = window.scrollY
+
+ const stickyButtonTop = window.innerHeight - 16 - 52
+ const stickyButtonBottom = window.innerHeight - 16
+
+ const shouldFreeze =
+ targetRect.top <= stickyButtonBottom - 60 &&
+ targetRect.bottom >= stickyButtonTop - 60 &&
+ !animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ !hasGrownRef.current
+
+ if (shouldFreeze && !isScrollFrozenRef.current) {
+ setIsScrollFrozen(true)
+ frozenScrollY.current = currentScrollY
+ virtualScrollY.current = 0
+ document.body.style.overflow = 'hidden'
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ window.scrollTo(0, frozenScrollY.current)
+ } else if (
+ animationCompleteRef.current &&
+ !shrinkingPhaseRef.current &&
+ currentScrollY > frozenScrollY.current + 50
+ ) {
+ setShrinkingPhase(true)
+ } else if (shrinkingPhaseRef.current) {
+ const shrinkDistance = Math.max(0, currentScrollY - (frozenScrollY.current + 50))
+ const maxShrinkDistance = 200
+ const shrinkProgress = Math.min(1, shrinkDistance / maxShrinkDistance)
+ const newScale = 1.5 - shrinkProgress * 0.5
+ setButtonScale(Math.max(1, newScale))
+ } else if (animationCompleteRef.current && currentScrollY < frozenScrollY.current - 100) {
+ setAnimationComplete(false)
+ setShrinkingPhase(false)
+ setButtonScale(1)
+ setHasGrown(false)
+ }
+ }
+ }
+
+ const handleWheel = (event: WheelEvent) => {
+ if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ event.preventDefault()
+ handleScrollDelta(event.deltaY)
+ }
+ }
+
+ const handleTouchStart = (event: TouchEvent) => {
+ touchStartY.current = event.touches[0].clientY
+ }
+
+ const handleTouchMove = (event: TouchEvent) => {
+ if (isScrollFrozenRef.current && !animationCompleteRef.current) {
+ event.preventDefault()
+ const deltaY = touchStartY.current - event.touches[0].clientY
+ touchStartY.current = event.touches[0].clientY
+ handleScrollDelta(deltaY)
+ }
+ }
+
+ window.addEventListener('scroll', handleScroll)
+ window.addEventListener('wheel', handleWheel, { passive: false })
+ window.addEventListener('touchstart', handleTouchStart, { passive: true })
+ window.addEventListener('touchmove', handleTouchMove, { passive: false })
+ handleScroll()
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll)
+ window.removeEventListener('wheel', handleWheel)
+ window.removeEventListener('touchstart', handleTouchStart)
+ window.removeEventListener('touchmove', handleTouchMove)
+ document.body.style.overflow = ''
+ }
+ }, [handleScrollDelta])
+
+ const marqueeProps = { visible: true, message: marqueeMessages }
+
+ 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..94a848860
--- /dev/null
+++ b/src/components/LandingPage/LandingPageShell.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from 'react'
+import { FooterVisibilityObserver } from '@/components/Global/FooterVisibilityObserver'
+
+export function LandingPageShell({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/LandingPage/Manteca.tsx b/src/components/LandingPage/Manteca.tsx
index 3b574e500..8b600b256 100644
--- a/src/components/LandingPage/Manteca.tsx
+++ b/src/components/LandingPage/Manteca.tsx
@@ -1,57 +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) => (
+
+
+
+ ))}
+
@@ -71,24 +55,22 @@ const Manteca = () => {
- {isMobile && (
-
-
+ {/* Mobile layout */}
+
- {!isMobile && (
-
-
-
-
-
- )}
+ {/* Desktop layout */}
+
+
+
+
+
)
}
diff --git a/src/components/LandingPage/PioneerCard3D.tsx b/src/components/LandingPage/PioneerCard3D.tsx
new file mode 100644
index 000000000..dd53dcc05
--- /dev/null
+++ b/src/components/LandingPage/PioneerCard3D.tsx
@@ -0,0 +1,158 @@
+'use client'
+import { motion, useMotionValue, useTransform, useSpring, useMotionTemplate } from 'framer-motion'
+import Image from 'next/image'
+import { CARD_GRADIENT_4, CARD_GRADIENT_5, CARD_GRADIENT_9, CARD_GRADIENT_10 } from '@/assets/cards'
+import { useRef, useState, useEffect, useCallback } from 'react'
+
+const CARD_BACKGROUNDS = [CARD_GRADIENT_4, CARD_GRADIENT_9, CARD_GRADIENT_10, CARD_GRADIENT_5]
+const CYCLE_INTERVAL = 3000
+
+interface PioneerCard3DProps {
+ className?: string
+}
+
+const PioneerCard3D = ({ className }: PioneerCard3DProps) => {
+ const cardRef = useRef
(null)
+ const [activeIndex, setActiveIndex] = useState(0)
+ const timerRef = useRef(null)
+
+ const x = useMotionValue(0)
+ const y = useMotionValue(0)
+ const isInteractingRef = useRef(false)
+ const resumeTimerRef = useRef | null>(null)
+
+ const rotateX = useTransform(y, [-100, 100], [12, -12])
+ const rotateY = useTransform(x, [-100, 100], [-12, 12])
+
+ const shadowX = useTransform(x, [-100, 100], [14, -2])
+ const shadowY = useTransform(y, [-100, 100], [14, -2])
+
+ const springRotateX = useSpring(rotateX, { stiffness: 200, damping: 25 })
+ const springRotateY = useSpring(rotateY, { stiffness: 200, damping: 25 })
+ const springShadowX = useSpring(shadowX, { stiffness: 200, damping: 25 })
+ const springShadowY = useSpring(shadowY, { stiffness: 200, damping: 25 })
+
+ const boxShadow = useMotionTemplate`${springShadowX}px ${springShadowY}px 0 #000000`
+
+ const advance = useCallback(() => {
+ setActiveIndex((prev) => (prev + 1) % CARD_BACKGROUNDS.length)
+ }, [])
+
+ const resetTimer = useCallback(() => {
+ if (timerRef.current) clearInterval(timerRef.current)
+ timerRef.current = setInterval(advance, CYCLE_INTERVAL)
+ }, [advance])
+
+ useEffect(() => {
+ resetTimer()
+ return () => {
+ if (timerRef.current) clearInterval(timerRef.current)
+ }
+ }, [resetTimer])
+
+ // Mobile auto-oscillation: slow sine wave to show off parallax without interaction
+ // Pauses when user touches/clicks the card and resumes 2s after release
+ useEffect(() => {
+ const isMobile = window.matchMedia('(max-width: 768px)').matches
+ if (!isMobile) return
+
+ let frame: number
+ const startTime = Date.now()
+
+ const animate = () => {
+ if (!isInteractingRef.current) {
+ const elapsed = (Date.now() - startTime) / 1000
+ // gentle figure-8 pattern using offset sine waves
+ x.set(Math.sin(elapsed * 0.4) * 60)
+ y.set(Math.sin(elapsed * 0.3 + 1) * 40)
+ }
+ frame = requestAnimationFrame(animate)
+ }
+
+ frame = requestAnimationFrame(animate)
+ return () => cancelAnimationFrame(frame)
+ }, [x, y])
+
+ const pauseOscillation = useCallback(() => {
+ isInteractingRef.current = true
+ if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
+ }, [])
+
+ const resumeOscillation = useCallback(() => {
+ if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
+ resumeTimerRef.current = setTimeout(() => {
+ isInteractingRef.current = false
+ }, 2000)
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current)
+ }
+ }, [])
+
+ const handleClick = () => {
+ advance()
+ resetTimer()
+ }
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ pauseOscillation()
+ if (!cardRef.current) return
+ const rect = cardRef.current.getBoundingClientRect()
+ x.set(e.clientX - rect.left - rect.width / 2)
+ y.set(e.clientY - rect.top - rect.height / 2)
+ }
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ if (!cardRef.current) return
+ const rect = cardRef.current.getBoundingClientRect()
+ x.set(e.clientX - rect.left - rect.width / 2)
+ y.set(e.clientY - rect.top - rect.height / 2)
+ }
+
+ const handlePointerLeave = () => {
+ x.set(0)
+ y.set(0)
+ resumeOscillation()
+ }
+
+ return (
+
+
+ {/* All card images layered, only the active one is visible */}
+ {CARD_BACKGROUNDS.map((bg, i) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+export default PioneerCard3D
diff --git a/src/components/LandingPage/RegulatedRails.tsx b/src/components/LandingPage/RegulatedRails.tsx
index 655b8415d..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,74 +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..65be1b83d
--- /dev/null
+++ b/src/components/LandingPage/SendInSecondsCTA.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { motion } from 'framer-motion'
+import { Button } from '@/components/0_Bruddle/Button'
+
+export function SendInSecondsCTA() {
+ return (
+
+ )
+}
diff --git a/src/components/LandingPage/TweetCarousel.tsx b/src/components/LandingPage/TweetCarousel.tsx
index 5db714d10..452c2455c 100644
--- a/src/components/LandingPage/TweetCarousel.tsx
+++ b/src/components/LandingPage/TweetCarousel.tsx
@@ -334,7 +334,7 @@ const TweetCarousel = () => {
if (columns.length === 0) return null
return (
-
+
WALL OF LOVE
diff --git a/src/components/LandingPage/dropLink.tsx b/src/components/LandingPage/dropLink.tsx
index 0f10006ce..a7fbea951 100644
--- a/src/components/LandingPage/dropLink.tsx
+++ b/src/components/LandingPage/dropLink.tsx
@@ -12,6 +12,7 @@ const businessBgColor = '#90A8ED'
export function DropLink() {
return (
diff --git a/src/components/LandingPage/faq.tsx b/src/components/LandingPage/faq.tsx
index 27ec5e5de..2bf3313d5 100644
--- a/src/components/LandingPage/faq.tsx
+++ b/src/components/LandingPage/faq.tsx
@@ -14,6 +14,7 @@ type LocalFAQsProps = FAQsProps & {
export function FAQs({ heading, questions, marquee = { visible: false } }: LocalFAQsProps) {
return (
({
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,75 +46,8 @@ 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' && (
-
- )
-
-const renderArrows = (variant: 'primary' | 'secondary', arrowOpacity: number, buttonVisible?: boolean) =>
- 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') => {
- const arrowOpacity = 1 // Always visible
-
return (
- {/* {renderSparkle(variant)} */}
-
{cta.subtext}
)}
-
- {renderArrows(variant, arrowOpacity, buttonVisible)}
)
}
return (
-
-
+
+
-
+
+
-
+
TAP. SEND. ANYWHERE
@@ -180,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/index.ts b/src/components/LandingPage/index.ts
index 3288a182e..417673b2a 100644
--- a/src/components/LandingPage/index.ts
+++ b/src/components/LandingPage/index.ts
@@ -7,3 +7,4 @@ export * from './securityBuiltIn'
export * from './sendInSeconds'
export * from './yourMoney'
export * from './RegulatedRails'
+export * from './CardPioneers'
diff --git a/src/components/LandingPage/landingPageData.ts b/src/components/LandingPage/landingPageData.ts
new file mode 100644
index 000000000..e3750c386
--- /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', 'USDT/USDC', '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/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/LandingPage/noFees.tsx b/src/components/LandingPage/noFees.tsx
index 4b830ac6c..11ed75983 100644
--- a/src/components/LandingPage/noFees.tsx
+++ b/src/components/LandingPage/noFees.tsx
@@ -63,7 +63,10 @@ export function NoFees({ className }: { className?: string }) {
}
return (
-
+
{/* Animated clouds */}
+
diff --git a/src/components/LandingPage/sendInSeconds.tsx b/src/components/LandingPage/sendInSeconds.tsx
index a83c36602..15d58efab 100644
--- a/src/components/LandingPage/sendInSeconds.tsx
+++ b/src/components/LandingPage/sendInSeconds.tsx
@@ -1,186 +1,56 @@
-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 = () => (
-
- )
-
- const renderArrows = () => (
- <>
-
-
-
-
- >
- )
-
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/LandingPage/yourMoney.tsx b/src/components/LandingPage/yourMoney.tsx
index a5875c6c5..8d26169da 100644
--- a/src/components/LandingPage/yourMoney.tsx
+++ b/src/components/LandingPage/yourMoney.tsx
@@ -4,7 +4,7 @@ import { Button } from '@/components/0_Bruddle/Button'
export function YourMoney() {
return (
-
+
diff --git a/src/components/Marketing/BlogCard.tsx b/src/components/Marketing/BlogCard.tsx
new file mode 100644
index 000000000..87e3573c2
--- /dev/null
+++ b/src/components/Marketing/BlogCard.tsx
@@ -0,0 +1,32 @@
+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) {
+ const safeSlug = encodeURIComponent(slug)
+ 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/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx
new file mode 100644
index 000000000..61981a1e8
--- /dev/null
+++ b/src/components/Marketing/ContentPage.tsx
@@ -0,0 +1,60 @@
+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 */
+ children: ReactNode
+ /** Breadcrumb items: [{name, href}] */
+ breadcrumbs: Array<{ name: string; href: string }>
+}
+
+/**
+ * Universal wrapper for MDX-rendered marketing pages.
+ * 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 = {
+ '@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}
+
+
+ {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
new file mode 100644
index 000000000..3f84b6e16
--- /dev/null
+++ b/src/components/Marketing/DestinationGrid.tsx
@@ -0,0 +1,86 @@
+import Link from 'next/link'
+import { Card } from '@/components/0_Bruddle/Card'
+import { COUNTRIES_SEO, getCountryName } from '@/data/seo'
+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[]
+ /** Country slug to exclude from the grid */
+ exclude?: string
+ title?: string
+ locale?: Locale
+}
+
+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 (
+
+ {title && {title} }
+
+ {slugs.map((slug) => {
+ const seo = COUNTRIES_SEO[slug]
+ if (!seo) return null
+
+ const countryName = getCountryName(slug, locale)
+ const flagCode = SLUG_TO_ISO2[slug]
+
+ 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/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/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
new file mode 100644
index 000000000..9c8d5d480
--- /dev/null
+++ b/src/components/Marketing/MarketingHero.tsx
@@ -0,0 +1,66 @@
+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
+ image?: string
+}
+
+export function MarketingHero({
+ title,
+ subtitle,
+ ctaText = 'Get Started',
+ ctaHref = '/home',
+ image,
+}: MarketingHeroProps) {
+ return (
+ <>
+
+
+
+ {image && (
+
{
+ e.currentTarget.style.display = 'none'
+ }}
+ />
+ )}
+
+
+
+
{subtitle}
+ {ctaText && (
+
+
+ {ctaText}
+
+
+ )}
+
+
+
+ >
+ )
+}
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/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}
+
+
+
+ ))}
+
+ )
+}
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/mdx/CTA.tsx b/src/components/Marketing/mdx/CTA.tsx
new file mode 100644
index 000000000..b26228864
--- /dev/null
+++ b/src/components/Marketing/mdx/CTA.tsx
@@ -0,0 +1,79 @@
+import Link from 'next/link'
+import Image from 'next/image'
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
+import { PeanutGuyGIF } from '@/assets'
+import { PROSE_WIDTH } from './constants'
+
+interface CTAProps {
+ text: string
+ href: string
+ /** subtitle shown below the button in 'card' variant */
+ subtitle?: string
+ variant?: 'primary' | 'secondary' | 'card'
+}
+
+/**
+ * MDX call-to-action.
+ *
+ * - primary: standalone purple button within prose column (default)
+ * - secondary: subtle inline text link with arrow — for mid-content CTAs
+ * - card: bordered card with button + subtitle — for final/end-of-page CTAs
+ */
+export function CTA({ text, href, subtitle, variant = 'primary' }: CTAProps) {
+ if (variant === 'secondary') {
+ return (
+
+
+ {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..79b9c80db
--- /dev/null
+++ b/src/components/Marketing/mdx/Callout.tsx
@@ -0,0 +1,28 @@
+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..5f71dd49a
--- /dev/null
+++ b/src/components/Marketing/mdx/ExchangeWidget.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+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, sourceCurrency = 'USD' }: ExchangeWidgetProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ // 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', sourceCurrency)
+ router.replace(`?${params.toString()}`, { scroll: false })
+ }
+ }, [destinationCurrency, sourceCurrency, searchParams, router])
+
+ return (
+
+
+
+
+
+
+
+ {
+ router.push(`/send?from=${from}&to=${to}`)
+ }}
+ />
+
+
+ )
+}
+
+/**
+ * Embeddable exchange rate calculator for MDX content pages.
+ *
+ * Usage in MDX:
+ *
+ *
+ */
+export function ExchangeWidget({ destinationCurrency, sourceCurrency }: ExchangeWidgetProps) {
+ return (
+
+
+
+ }
+ >
+
+
+ )
+}
diff --git a/src/components/Marketing/mdx/FAQ.tsx b/src/components/Marketing/mdx/FAQ.tsx
new file mode 100644
index 000000000..24cf62454
--- /dev/null
+++ b/src/components/Marketing/mdx/FAQ.tsx
@@ -0,0 +1,79 @@
+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..c27ad052a
--- /dev/null
+++ b/src/components/Marketing/mdx/Hero.tsx
@@ -0,0 +1,58 @@
+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 HeroProps {
+ title: string
+ subtitle: string
+ cta?: string
+ ctaHref?: string
+ /** @deprecated — ignored. Use standalone in MDX body instead. */
+ currency?: string
+}
+
+/**
+ * MDX Hero — large bold title (Roboto Flex), subtitle, white CTA button
+ * on pink background.
+ */
+export function Hero({ title, subtitle, cta, ctaHref }: HeroProps) {
+ return (
+ <>
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+ {cta && ctaHref && (
+
+
+ {cta}
+
+
+ )}
+
+
+
+ {/* Spacer ensures consistent gap between Hero block and prose content */}
+
+ >
+ )
+}
diff --git a/src/components/Marketing/mdx/ProseStars.tsx b/src/components/Marketing/mdx/ProseStars.tsx
new file mode 100644
index 000000000..4c50edf43
--- /dev/null
+++ b/src/components/Marketing/mdx/ProseStars.tsx
@@ -0,0 +1,84 @@
+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..7a92e1a91
--- /dev/null
+++ b/src/components/Marketing/mdx/RelatedPages.tsx
@@ -0,0 +1,64 @@
+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..e031486a6
--- /dev/null
+++ b/src/components/Marketing/mdx/Stars.tsx
@@ -0,0 +1,57 @@
+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..ec2797897
--- /dev/null
+++ b/src/components/Marketing/mdx/Steps.tsx
@@ -0,0 +1,86 @@
+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/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
new file mode 100644
index 000000000..6c39904dd
--- /dev/null
+++ b/src/components/Marketing/mdx/components.tsx
@@ -0,0 +1,107 @@
+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 { Tabs, TabPanel } from './Tabs'
+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,
+ Tabs,
+ TabPanel,
+
+ // Element overrides — prose styling
+ h1: (props: React.HTMLAttributes) => (
+
+ ),
+ 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..da9a9173b
--- /dev/null
+++ b/src/components/Marketing/mdx/constants.ts
@@ -0,0 +1,8 @@
+/** 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/ReceiveMoneyContent.tsx b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
new file mode 100644
index 000000000..ee37237f0
--- /dev/null
+++ b/src/components/Marketing/pages/ReceiveMoneyContent.tsx
@@ -0,0 +1,143 @@
+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/components/Points/CashCard.tsx b/src/components/Points/CashCard.tsx
new file mode 100644
index 000000000..c5d825611
--- /dev/null
+++ b/src/components/Points/CashCard.tsx
@@ -0,0 +1,33 @@
+'use client'
+
+import { Icon } from '@/components/Global/Icons/Icon'
+import { Tooltip } from '@/components/Tooltip'
+
+interface CashCardProps {
+ hasCashbackLeft: boolean
+ lifetimeEarned: number
+}
+
+export const CashCard = ({ hasCashbackLeft, lifetimeEarned }: CashCardProps) => {
+ return (
+
+
+
+ Lifetime cashback claimed: ${lifetimeEarned.toFixed(2)}
+
+
+
+
+
+
+ {hasCashbackLeft ? (
+
You have unclaimed cashback left! Make a payment to claim 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/Points/PerkClaimCard.tsx b/src/components/Points/PerkClaimCard.tsx
new file mode 100644
index 000000000..c81b801ae
--- /dev/null
+++ b/src/components/Points/PerkClaimCard.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import { useHoldToClaim } from '@/hooks/useHoldToClaim'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { getShakeClass } from '@/utils/perk.utils'
+import { extractInviteeName } from '@/utils/general.utils'
+import type { PendingPerk } from '@/services/perks'
+
+interface PerkClaimCardProps {
+ perk: PendingPerk
+ onClaim: () => void
+ isClaiming: boolean
+}
+
+export function PerkClaimCard({ perk, onClaim, isClaiming }: PerkClaimCardProps) {
+ const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({
+ onComplete: onClaim,
+ disabled: isClaiming,
+ })
+
+ const inviteeName = extractInviteeName(perk.reason)
+
+ return (
+
+
+
+
+
+
+
+
You earned ${perk.amountUsd}!
+
{inviteeName} became a Card Pioneer
+
+
+
+
+ {/* Progress fill from left to right */}
+
+
+ {isClaiming ? 'Claiming...' : `Hold to claim $${perk.amountUsd}`}
+
+
+
+
+ )
+}
diff --git a/src/components/Profile/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx
index 3294cf965..8cc30b28a 100644
--- a/src/components/Profile/components/ProfileMenuItem.tsx
+++ b/src/components/Profile/components/ProfileMenuItem.tsx
@@ -20,6 +20,7 @@ interface ProfileMenuItemProps {
endIconClassName?: string
showTooltip?: boolean
toolTipText?: string
+ badge?: string
}
const ProfileMenuItem: React.FC = ({
@@ -34,6 +35,7 @@ const ProfileMenuItem: React.FC = ({
endIconClassName,
showTooltip = false,
toolTipText,
+ badge,
}) => {
const content = (
@@ -46,6 +48,7 @@ const ProfileMenuItem: React.FC
= ({
)}
{label}
+ {badge && }
{showTooltip && (
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 7f1722aaf..09086deed 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -7,18 +7,16 @@ import NavHeader from '../Global/NavHeader'
import ProfileHeader from './components/ProfileHeader'
import ProfileMenuItem from './components/ProfileMenuItem'
import { useRouter } from 'next/navigation'
-import { checkIfInternalNavigation, generateInviteCodeLink, generateInvitesShareText } from '@/utils/general.utils'
-import ActionModal from '../Global/ActionModal'
+import { checkIfInternalNavigation } from '@/utils/general.utils'
import { useState } from 'react'
import useKycStatus from '@/hooks/useKycStatus'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
import Card from '../Global/Card'
import ShowNameToggle from './components/ShowNameToggle'
-import ShareButton from '../Global/ShareButton'
-import CopyToClipboard from '../Global/CopyToClipboard'
import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal'
+import InviteFriendsModal from '../Global/InviteFriendsModal'
import { STAR_STRAIGHT_ICON } from '@/assets'
import Image from 'next/image'
-import QRCode from 'react-qr-code'
export const Profile = () => {
const { logoutUser, isLoggingOut, user } = useAuth()
@@ -35,8 +33,6 @@ export const Profile = () => {
// respect user's showFullName preference: use fullName only if showFullName is true, otherwise use username
const displayName = user?.user.showFullName && user?.user.fullName ? user.user.fullName : username
- const inviteData = generateInviteCodeLink(user?.user.username ?? '')
-
return (
{
href="/dummy" // Dummy link, wont be called
position="single"
/>
+ {/* Card Pioneer Entry */}
+ {!underMaintenanceConfig.disableCardPioneers && (
+
+ )}
{/* Menu Items - First Group */}
@@ -141,40 +141,10 @@ export const Profile = () => {
onClose={() => setIsKycApprovedModalOpen(false)}
/>
-
setIsInviteFriendsModalOpen(false)}
- title="Invite friends!"
- description="Invite friends to Peanut and help them skip ahead on the waitlist. Once they're onboarded and start using the app, you'll earn rewards from their activity too."
- icon="user-plus"
- content={
- <>
- {inviteData.inviteLink && (
-
-
-
- )}
-
-
- {`${inviteData.inviteCode}`}
-
-
-
-
- Promise.resolve(generateInvitesShareText(inviteData.inviteLink))}
- title="Share your invite link"
- >
- Share Invite link
-
- >
- }
+ username={user?.user.username ?? ''}
/>
)
diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx
index f6994f14e..3f1ea1c8f 100644
--- a/src/components/Setup/Views/SetupPasskey.tsx
+++ b/src/components/Setup/Views/SetupPasskey.tsx
@@ -39,7 +39,7 @@ const SetupPasskey = () => {
// clear any previous inline errors
setInlineError(null)
setErrorName(null)
- capturePasskeyDebugInfo('passkey-registration-started')
+ //capturePasskeyDebugInfo('passkey-registration-started')
try {
await withWebAuthnRetry(() => handleRegister(username), 'passkey-registration')
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'
}
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
index 9151ea3dd..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,
@@ -332,12 +333,12 @@ export const TransactionDetailsReceipt = ({
useEffect(() => {
const getTokenDetails = async () => {
- if (!transaction) {
+ if (!transaction?.tokenDisplayDetails) {
setIsTokenDataLoading(false)
return
}
- if (transaction.tokenDisplayDetails?.tokenIconUrl && transaction.tokenDisplayDetails.tokenSymbol) {
+ if (transaction.tokenDisplayDetails.tokenIconUrl && transaction.tokenDisplayDetails.tokenSymbol) {
setTokenData({
symbol: transaction.tokenDisplayDetails.tokenSymbol,
icon: transaction.tokenDisplayDetails.tokenIconUrl,
@@ -346,8 +347,13 @@ export const TransactionDetailsReceipt = ({
return
}
+ if (!transaction.tokenDisplayDetails.chainName) {
+ setIsTokenDataLoading(false)
+ return
+ }
+
try {
- const chainName = slugify(transaction.tokenDisplayDetails?.chainName ?? '')
+ const chainName = slugify(transaction.tokenDisplayDetails.chainName)
const res = await fetch(
`https://api.coingecko.com/api/v3/coins/${chainName}/contract/${transaction.tokenAddress}`
)
@@ -370,7 +376,7 @@ export const TransactionDetailsReceipt = ({
}
getTokenDetails()
- }, [])
+ }, [transaction?.tokenDisplayDetails])
const convertedAmount = useMemo(() => {
if (!transaction) return null
@@ -506,9 +512,22 @@ export const TransactionDetailsReceipt = ({
value={formatDate(new Date(transaction.date))}
hideBottomBorder={false}
/>
+ {/*
+ * HACK: Strip payment UUID from reason field.
+ *
+ * The backend stores the payment UUID in the reason field for idempotency
+ * (e.g., "Alice became a Card Pioneer! (payment: uuid)") because PerkUsage
+ * lacks a dedicated requestPaymentUuid field. The code in purchase-listener.ts
+ * uses `reason: { contains: paymentUuid }` to prevent duplicate perk issuance.
+ *
+ * Proper fix (backend): Add requestPaymentUuid field to PerkUsage model with
+ * a unique constraint @@unique([userId, perkId, requestPaymentUuid]), similar
+ * to how mantecaTransferId/bridgeTransferId/simplefiTransferId are handled.
+ * Then store clean reason text without the UUID suffix.
+ */}
@@ -1113,7 +1132,7 @@ export const TransactionDetailsReceipt = ({
value={
- {transaction.points}
+ {formatPoints(transaction.points)}
}
hideBottomBorder={shouldHideBorder('points')}
diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts
index a482aa4d5..70e56cb11 100644
--- a/src/config/underMaintenance.config.ts
+++ b/src/config/underMaintenance.config.ts
@@ -27,6 +27,13 @@
* - shows info message explaining cross-chain is temporarily unavailable
* - same-chain operations continue to work
*
+ * 6. disableCardPioneers: hides the card pioneers waitlist feature entirely
+ * - /card page redirects to /home
+ * - /lp/card marketing page redirects to /
+ * - card pioneers section hidden from landing page
+ * - card pioneer modal, carousel cta, and perk rewards hidden from home
+ * - set to false to enable the feature
+ *
* note: if either mode is enabled, the maintenance banner will show everywhere
*
* I HOPE WE NEVER NEED TO USE THIS...
@@ -41,14 +48,16 @@ interface MaintenanceConfig {
disabledPaymentProviders: PaymentProvider[]
disableSquidWithdraw: boolean
disableSquidSend: boolean
+ disableCardPioneers: boolean
}
const underMaintenanceConfig: MaintenanceConfig = {
enableFullMaintenance: false, // set to true to redirect all pages to /maintenance
enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages
disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments
- disableSquidWithdraw: true, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum)
- disableSquidSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum)
+ disableSquidWithdraw: false, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum)
+ disableSquidSend: false, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum)
+ disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature
}
export default underMaintenanceConfig
diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts
index 0d07002d4..b82de71d9 100644
--- a/src/constants/countryCurrencyMapping.ts
+++ b/src/constants/countryCurrencyMapping.ts
@@ -109,3 +109,8 @@ 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/points.consts.ts b/src/constants/points.consts.ts
new file mode 100644
index 000000000..6a787ea54
--- /dev/null
+++ b/src/constants/points.consts.ts
@@ -0,0 +1,15 @@
+/**
+ * Points System Constants
+ *
+ * Shared constants for points display.
+ * Transitivity multiplier is no longer hardcoded — use `contributedPoints` from API.
+ */
+
+/**
+ * Tier thresholds for display purposes
+ * Note: Actual tier calculation happens on backend
+ */
+export const TIER_THRESHOLDS = {
+ TIER_1: 1000,
+ TIER_2: 10000,
+} as const
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 59411a9df..64bff38aa 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,42 @@ 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',
+
+ // Locale prefixes
+ 'en',
+ 'es',
+ 'pt',
] as const
/**
@@ -56,6 +93,11 @@ export const RESERVED_ROUTES: readonly string[] = [...DEDICATED_ROUTES, ...STATI
*/
export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite', 'qr', 'dev/payment-graph'] as const
+/**
+ * Dev test routes that are public only in dev mode
+ */
+export const DEV_ONLY_PUBLIC_ROUTES = ['dev', 'dev/gift-test', 'dev/shake-test'] as const
+
/**
* Regex pattern for public routes (used in layout.tsx)
* Matches paths that don't require authentication
@@ -65,6 +107,12 @@ export const PUBLIC_ROUTES = ['request/pay', 'claim', 'pay', 'support', 'invite'
*/
export const PUBLIC_ROUTES_REGEX = /^\/(request\/pay|claim|pay\/.+|support|invite|qr|dev\/payment-graph)/
+/**
+ * Regex for dev-only public routes (dev index, gift-test, shake-test)
+ * Only matched when IS_DEV is true
+ */
+export const DEV_ONLY_PUBLIC_ROUTES_REGEX = /^\/(dev$|dev\/gift-test|dev\/shake-test)/
+
/**
* Routes where middleware should run
*
@@ -104,7 +152,15 @@ export function isReservedRoute(path: string): boolean {
/**
* Helper to check if a path is public (no auth required)
+ * Dev test pages (gift-test, shake-test) are only public in dev mode
*/
-export function isPublicRoute(path: string): boolean {
- return PUBLIC_ROUTES_REGEX.test(path)
+export function isPublicRoute(path: string, isDev = false): boolean {
+ if (PUBLIC_ROUTES_REGEX.test(path)) {
+ return true
+ }
+ // Dev test pages are only public in dev mode
+ if (isDev && DEV_ONLY_PUBLIC_ROUTES_REGEX.test(path)) {
+ return true
+ }
+ return false
}
diff --git a/src/content b/src/content
new file mode 160000
index 000000000..bbc9e2533
--- /dev/null
+++ b/src/content
@@ -0,0 +1 @@
+Subproject commit bbc9e25335d3754fed11eaae058fa53a9d77a128
diff --git a/src/context/OnrampFlowContext.tsx b/src/context/OnrampFlowContext.tsx
index 26b51f737..14878e58e 100644
--- a/src/context/OnrampFlowContext.tsx
+++ b/src/context/OnrampFlowContext.tsx
@@ -23,6 +23,10 @@ export interface IOnrampData {
bic?: string
accountHolderName?: string
clabe?: string
+ // uk faster payments fields
+ sortCode?: string
+ accountNumber?: string
+ reference?: string
}
}
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index 02012fa2b..69d7ad7c6 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -42,7 +42,8 @@ interface AuthContextType {
}
}) => Promise
isFetchingUser: boolean
- logoutUser: () => Promise
+ userFetchError: Error | null
+ logoutUser: (options?: { skipBackendCall?: boolean }) => Promise
isLoggingOut: boolean
invitedUsernamesSet: Set
}
@@ -60,7 +61,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const queryClient = useQueryClient()
const WEB_AUTHN_COOKIE_KEY = 'web-authn-key'
- const { data: user, isLoading: isFetchingUser, refetch: fetchUser } = useUserQuery()
+ const { data: user, isLoading: isFetchingUser, refetch: fetchUser, error: userFetchError } = useUserQuery()
// Pre-compute a Set of invited usernames for O(1) lookups
const invitedUsernamesSet = useMemo(() => {
@@ -149,84 +150,107 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
})
}
- const logoutUser = useCallback(async () => {
- if (isLoggingOut) return
+ /**
+ * Clears all client-side auth state (cookies, localStorage, redux, caches)
+ * Used by both normal logout and force logout (when backend is down)
+ */
+ const clearLocalAuthState = useCallback(async () => {
+ // clear user preferences (webauthn key in localStorage)
+ updateUserPreferences(user?.user.userId, { webAuthnKey: undefined })
- setIsLoggingOut(true)
- try {
- const response = await fetchWithSentry('/api/peanut/user/logout-user', {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- })
+ // clear cookies
+ removeFromCookie(WEB_AUTHN_COOKIE_KEY)
+ document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
+
+ // clear redirect url
+ clearRedirectUrl()
+
+ // invalidate all queries
+ queryClient.invalidateQueries()
- if (response.ok) {
- // clear user preferences (webauthn key in localStorage)
- updateUserPreferences(user?.user.userId, { webAuthnKey: undefined })
+ // reset redux state (setup and zerodev)
+ dispatch(setupActions.resetSetup())
+ dispatch(zerodevActions.resetZeroDevState())
+ console.log('[Logout] Cleared redux state (setup and zerodev)')
+
+ // Clear service worker caches to prevent user data leakage
+ // When User A logs out and User B logs in on the same device, cached API responses
+ // could expose User A's data (profile, transactions, KYC) to User B
+ // Only clears user-specific caches; preserves prices and external resources
+ if ('caches' in window) {
+ try {
+ const cacheNames = await caches.keys()
+ await Promise.all(
+ cacheNames
+ .filter((name) => USER_DATA_CACHE_PATTERNS.some((pattern) => name.includes(pattern)))
+ .map((name) => {
+ console.log('Logout: Clearing cache:', name)
+ return caches.delete(name)
+ })
+ )
+ } catch (error) {
+ console.error('Failed to clear caches on logout:', error)
+ // Non-fatal: logout continues even if cache clearing fails
+ }
+ }
- // clear cookies
- removeFromCookie(WEB_AUTHN_COOKIE_KEY)
- document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
+ // clear the iOS PWA prompt session flag
+ if (typeof window !== 'undefined') {
+ sessionStorage.removeItem('hasSeenIOSPWAPromptThisSession')
+ }
- // clear redirect url
- clearRedirectUrl()
+ // Reset Crisp session to prevent session merging with next user
+ // This resets both main window Crisp instance and any proxy page instances
+ if (typeof window !== 'undefined') {
+ resetCrispProxySessions()
+ }
+ }, [dispatch, queryClient, user?.user.userId])
- // invalidate all queries
- queryClient.invalidateQueries()
+ /**
+ * Logs out the user
+ * @param options.skipBackendCall - If true, skips the backend logout call (useful when backend is down)
+ */
+ const logoutUser = useCallback(
+ async (options?: { skipBackendCall?: boolean }) => {
+ if (isLoggingOut) return
- // reset redux state (setup and zerodev)
- dispatch(setupActions.resetSetup())
- dispatch(zerodevActions.resetZeroDevState())
- console.log('[Logout] Cleared redux state (setup and zerodev)')
+ setIsLoggingOut(true)
+ try {
+ // Call backend logout unless skipped (e.g., when backend is down)
+ if (!options?.skipBackendCall) {
+ const response = await fetchWithSentry('/api/peanut/user/logout-user', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
- // Clear service worker caches to prevent user data leakage
- // When User A logs out and User B logs in on the same device, cached API responses
- // could expose User A's data (profile, transactions, KYC) to User B
- // Only clears user-specific caches; preserves prices and external resources
- if ('caches' in window) {
- try {
- const cacheNames = await caches.keys()
- await Promise.all(
- cacheNames
- .filter((name) => USER_DATA_CACHE_PATTERNS.some((pattern) => name.includes(pattern)))
- .map((name) => {
- console.log('Logout: Clearing cache:', name)
- return caches.delete(name)
- })
- )
- } catch (error) {
- console.error('Failed to clear caches on logout:', error)
- // Non-fatal: logout continues even if cache clearing fails
+ if (!response.ok) {
+ throw new Error('Backend logout failed')
}
}
- // clear the iOS PWA prompt session flag
- if (typeof window !== 'undefined') {
- sessionStorage.removeItem('hasSeenIOSPWAPromptThisSession')
- }
+ // Clear all client-side auth state
+ await clearLocalAuthState()
- // Reset Crisp session to prevent session merging with next user
- // This resets both main window Crisp instance and any proxy page instances
- if (typeof window !== 'undefined') {
- resetCrispProxySessions()
+ // fetch user (should return null after logout) - skip if backend call was skipped
+ if (!options?.skipBackendCall) {
+ await fetchUser()
}
- // fetch user (should return null after logout)
- await fetchUser()
-
// force full page refresh to /setup to clear all state
// this ensures no stale redux/react state persists after logout
window.location.href = '/setup'
+ } catch (error) {
+ captureException(error)
+ console.error('Error logging out user', error)
+ toast.error('Error logging out')
+ } finally {
+ setIsLoggingOut(false)
}
- } catch (error) {
- captureException(error)
- console.error('Error logging out user', error)
- toast.error('Error logging out')
- } finally {
- setIsLoggingOut(false)
- }
- }, [fetchUser, isLoggingOut, user])
+ },
+ [clearLocalAuthState, fetchUser, isLoggingOut, toast]
+ )
return (
{
fetchUser: legacy_fetchUser,
addAccount,
isFetchingUser,
+ userFetchError: userFetchError ?? null,
logoutUser,
isLoggingOut,
invitedUsernamesSet,
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 27a243771..1710de8d6 100644
--- a/src/context/kernelClient.context.tsx
+++ b/src/context/kernelClient.context.tsx
@@ -172,7 +172,14 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => {
const userPreferences = getUserPreferences(user.user.userId)
const storedWebAuthnKey = userPreferences?.webAuthnKey ?? getFromCookie(WEB_AUTHN_COOKIE_KEY)
if (storedWebAuthnKey) {
- setWebAuthnKey(storedWebAuthnKey)
+ // Only update if the key actually changed to avoid re-triggering kernel client init
+ // Note: WebAuthnKey contains BigInt fields (pubX, pubY) which JSON.stringify cannot handle,
+ // so we use a custom replacer that converts BigInts to strings for comparison purposes.
+ const bigIntSafeStringify = (obj: unknown) =>
+ JSON.stringify(obj, (_, v) => (typeof v === 'bigint' ? v.toString() : v))
+ setWebAuthnKey((prev) =>
+ prev && bigIntSafeStringify(prev) === bigIntSafeStringify(storedWebAuthnKey) ? prev : storedWebAuthnKey
+ )
} else {
// avoid mixed state
logoutUser()
diff --git a/src/data/seo/comparisons.ts b/src/data/seo/comparisons.ts
new file mode 100644
index 000000000..ee17d2a46
--- /dev/null
+++ b/src/data/seo/comparisons.ts
@@ -0,0 +1,142 @@
+// Typed wrappers for competitor comparison data.
+// Reads from peanut-content: input/data/competitors/ + content/compare/
+// 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) ---
+
+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
+ tagline: string
+ rows: Array<{ feature: string; peanut: string; competitor: string }>
+ prosCompetitor: string[]
+ consCompetitor: string[]
+ verdict: string
+ faqs: Array<{ q: string; a: string }>
+ image?: 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')
+
+ if (!content || !isPublished(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
+}
+
+function buildTagline(fm: CompetitorEntityFrontmatter): string {
+ return `Compare Peanut with ${fm.name} for sending money to Latin America.`
+}
+
+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 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
+}
+
+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.`
+}
+
+export const COMPETITORS: Record = loadCompetitors()
diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts
new file mode 100644
index 000000000..d3403fdf8
--- /dev/null
+++ b/src/data/seo/corridors.ts
@@ -0,0 +1,236 @@
+// Typed wrappers for corridor/country SEO data.
+// Reads from peanut-content: input/data/countries/ + content/countries/ + content/send-to/
+// Public API unchanged from previous version.
+
+import {
+ readEntityData,
+ readPageContent,
+ readPageContentLocalized,
+ listEntitySlugs,
+ listContentSlugs,
+ listCorridorOrigins,
+ isPublished,
+} from '@/lib/content'
+import type { Locale } from '@/i18n/types'
+import { extractFaqs } from './utils'
+
+// --- 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 {
+ 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 {
+ from: string
+ to: string
+}
+
+// --- 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
+
+ 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'
+ }
+
+ // Extract FAQs from the content body
+ const faqs = content ? extractFaqs(content.body) : []
+
+ countries[slug] = {
+ 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 (some entities use destination: instead of origin:, skip those)
+ if (fm.corridors) {
+ for (const corridor of fm.corridors) {
+ if (corridor.origin) {
+ 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 })
+ }
+ }
+ }
+
+ // 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: 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()
+
+export const COUNTRIES_SEO: Record = _loaded.countries
+export const CORRIDORS: Corridor[] = _loaded.corridors
+
+/** 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 = readPageContentLocalized('countries', country, locale)
+ if (!localized) return base
+
+ const localizedFaqs = extractFaqs(localized.body)
+
+ return {
+ ...base,
+ context: localized.body,
+ faqs: localizedFaqs.length > 0 ? localizedFaqs : base.faqs,
+ }
+}
+
+/** Get localized country display name */
+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
new file mode 100644
index 000000000..9486629d6
--- /dev/null
+++ b/src/data/seo/exchanges.ts
@@ -0,0 +1,108 @@
+// Typed wrappers for exchange deposit data.
+// Reads from peanut-content: input/data/exchanges/ + content/deposit/
+// 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) ---
+
+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
+ recommendedNetwork: string
+ alternativeNetworks: string[]
+ withdrawalFee: string
+ processingTime: string
+ networkFee: string
+ steps: string[]
+ troubleshooting: Array<{ issue: string; fix: string }>
+ faqs: Array<{ q: string; a: string }>
+ image?: 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, /Deposit to Peanut Flow|Step-by-Step|How to Deposit/)
+ 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
+}
+
+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'
+}
+
+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'
+}
+
+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..e25b4fbe9
--- /dev/null
+++ b/src/data/seo/index.ts
@@ -0,0 +1,11 @@
+export { COUNTRIES_SEO, CORRIDORS, getLocalizedSEO, getCountryName } from './corridors'
+export type { CountrySEO, Corridor } from './corridors'
+
+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..13c2dad2c
--- /dev/null
+++ b/src/data/seo/payment-methods.ts
@@ -0,0 +1,83 @@
+// Typed wrapper for payment method data.
+// 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 { readEntityData, readPageContent, listEntitySlugs, listContentSlugs } from '@/lib/content'
+import { extractFaqs, extractSteps } from './utils'
+
+// --- 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
+ slug: string
+ lang: string
+ published: boolean
+ schema_types: string[]
+ alternates?: Record
+}
+
+// --- Public types (unchanged) ---
+
+export interface PaymentMethod {
+ slug: string
+ name: string
+ countries: string[]
+ description: string
+ steps: string[]
+ faqs: Array<{ q: string; a: string }>
+}
+
+// --- Loader ---
+
+function loadPaymentMethods(): Record {
+ const result: Record = {}
+
+ // 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
+
+ const fm = entity.frontmatter
+
+ result[slug] = {
+ slug,
+ 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
+ }),
+ faqs: extractFaqs(content.body),
+ }
+ }
+
+ return result
+}
+
+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..1a8989464
--- /dev/null
+++ b/src/data/seo/utils.ts
@@ -0,0 +1,97 @@
+// 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
+}
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/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx b/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx
index 401b3f927..c44c7b9e7 100644
--- a/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx
+++ b/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx
@@ -166,15 +166,24 @@ export function SemanticRequestFlowProvider({
const [isSuccess, setIsSuccess] = useState(false)
const [isExternalWalletPayment, setIsExternalWalletPayment] = useState(false)
- // derive recipient from parsed url
+ // derive recipient from parsed url OR charge
const recipient = useMemo(() => {
+ // If we have a charge, use its recipient address
+ if (charge?.requestLink?.recipientAddress) {
+ return {
+ identifier: charge.requestLink.recipientAddress,
+ recipientType: 'ADDRESS' as RecipientType,
+ resolvedAddress: charge.requestLink.recipientAddress as Address,
+ }
+ }
+ // Otherwise use parsed URL recipient
if (!parsedUrl?.recipient) return null
return {
identifier: parsedUrl.recipient.identifier,
recipientType: parsedUrl.recipient.recipientType,
resolvedAddress: parsedUrl.recipient.resolvedAddress as Address,
}
- }, [parsedUrl])
+ }, [parsedUrl, charge])
// reset flow
const resetSemanticRequestFlow = useCallback(() => {
diff --git a/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx b/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx
index 09ac39904..ac6a382a8 100644
--- a/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx
+++ b/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx
@@ -43,6 +43,19 @@ export function SemanticRequestPageWrapper({ recipient }: SemanticRequestPageWra
return
}
+ // If we have a chargeId, skip URL parsing - charge will provide all needed data
+ // Use a dummy parsedUrl to satisfy the component contract
+ if (chargeIdFromUrl) {
+ setParsedUrl({
+ recipient: null, // Will be populated from charge
+ amount: undefined,
+ token: undefined,
+ chain: undefined,
+ })
+ setIsLoading(false)
+ return
+ }
+
setIsLoading(true)
setError(null)
@@ -66,7 +79,7 @@ export function SemanticRequestPageWrapper({ recipient }: SemanticRequestPageWra
.finally(() => {
setIsLoading(false)
})
- }, [recipient])
+ }, [recipient, chargeIdFromUrl])
// loading state
if (isLoading) {
diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
index 5e8ceb2d2..dc43176f8 100644
--- a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
+++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
@@ -515,6 +515,7 @@ export function useSemanticRequestFlow() {
sourceChainId: selectedChainID || undefined,
sourceTokenAddress: selectedTokenAddress || undefined,
sourceTokenSymbol: selectedTokenData?.symbol,
+ squidQuoteId: calculatedRoute?.rawResponse?.route?.quoteId,
})
setPayment(paymentResult)
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx
index 4768ef23a..e0d30bcfb 100644
--- a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx
@@ -29,8 +29,14 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_SYMBOL }
import PeanutActionDetailsCard, {
type PeanutActionDetailsCardRecipientType,
} from '@/components/Global/PeanutActionDetailsCard'
+import { useSearchParams, useRouter } from 'next/navigation'
+import SendWithPeanutCta from '@/features/payments/shared/components/SendWithPeanutCta'
export function SemanticRequestConfirmView() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const context = searchParams.get('context')
+ const isCardPioneer = context === 'card-pioneer'
const {
amount,
usdAmount,
@@ -156,6 +162,18 @@ export function SemanticRequestConfirmView() {
}
}
+ // handle back navigation - for card pioneer, go back to card flow instead of INITIAL view
+ // TODO: consider using router.back() for normal request flow too instead of goBackToInitial()
+ // which manipulates internal state. router.back() would be more consistent and handle
+ // browser history properly (e.g., if user came from a shared link vs navigating in-app)
+ const handleBack = () => {
+ if (isCardPioneer) {
+ router.push('/card?step=geo')
+ } else {
+ goBackToInitial()
+ }
+ }
+
// show loading if we don't have charge details yet or fetching
if (!charge || isFetchingCharge) {
return (
@@ -167,7 +185,7 @@ export function SemanticRequestConfirmView() {
return (
-
+
{recipient && recipient.recipientType && (
@@ -191,14 +209,16 @@ export function SemanticRequestConfirmView() {
)}
{/* payment details card */}
-
+ {!isCardPioneer && (
+
+ )}
- {isCrossChainPayment && (
+ {!isCardPioneer && isCrossChainPayment && (
)}
+ {!isCardPioneer && (
+
+ }
+ />
+ )}
+
- }
+ loading={isCalculatingRoute}
+ label="Network fee"
+ value={networkFee}
+ hideBottomBorder={isCardPioneer}
/>
-
-
-
+ {!isCardPioneer && }
{/* buttons and error */}
@@ -247,6 +274,12 @@ export function SemanticRequestConfirmView() {
>
Retry
+ ) : isCardPioneer ? (
+
) : (
{
+ if (context === 'card-pioneer') {
+ router.push('/card?step=success')
+ }
+ }, [context, router])
+
// determine recipient type from parsed url
const recipientType = recipient?.recipientType || 'ADDRESS'
@@ -37,6 +51,12 @@ export function SemanticRequestSuccessView() {
payment?.uuid
)
+ // Don't render the generic success view for Card Pioneer payments
+ // (will redirect immediately via useEffect)
+ if (context === 'card-pioneer') {
+ return null
+ }
+
return (
{
sourceChainId: params.sourceChainId,
sourceTokenAddress: params.sourceTokenAddress,
sourceTokenSymbol: params.sourceTokenSymbol,
+ squidQuoteId: params.squidQuoteId,
})
setPayment(paymentResponse)
diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts
index d9db7651b..cd13d862a 100644
--- a/src/hooks/query/user.ts
+++ b/src/hooks/query/user.ts
@@ -8,6 +8,16 @@ import { usePWAStatus } from '../usePWAStatus'
import { useDeviceType } from '../useGetDeviceType'
import { USER } from '@/constants/query.consts'
+// custom error class for backend errors (5xx) that should trigger retry
+export class BackendError extends Error {
+ status: number
+ constructor(message: string, status: number) {
+ super(message)
+ this.name = 'BackendError'
+ this.status = status
+ }
+}
+
export const useUserQuery = (dependsOn: boolean = true) => {
const isPwa = usePWAStatus()
const { deviceType } = useDeviceType()
@@ -23,30 +33,36 @@ export const useUserQuery = (dependsOn: boolean = true) => {
isPwa: isPwa,
deviceType: deviceType,
})
-
dispatch(userActions.setUser(userData))
}
-
return userData
- } else {
- console.warn('Failed to fetch user, status:', userResponse.status)
- // clear stale redux data so the app doesn't keep serving cached user
- dispatch(userActions.setUser(null))
- return null
}
+
+ // 5xx = backend error, throw so tanstack retries
+ if (userResponse.status >= 500) {
+ console.error('Backend error fetching user:', userResponse.status)
+ throw new BackendError('Backend error fetching user', userResponse.status)
+ }
+
+ // 4xx = auth failure, clear stale redux so layout redirects to /setup
+ console.warn('Failed to fetch user, status:', userResponse.status)
+ dispatch(userActions.setUser(null))
+ return null
}
return useQuery({
queryKey: [USER],
queryFn: fetchUser,
- retry: 0,
+ retry: (failureCount, error) => {
+ if (error instanceof BackendError && failureCount < 2) return true
+ return false
+ },
+ retryDelay: 1000,
enabled: dependsOn,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: true,
- // use redux data as placeholder while fetching (no flicker)
- // but always validate against the backend
placeholderData: authUser || undefined,
})
}
diff --git a/src/hooks/useCardPioneerInfo.ts b/src/hooks/useCardPioneerInfo.ts
new file mode 100644
index 000000000..cea555a28
--- /dev/null
+++ b/src/hooks/useCardPioneerInfo.ts
@@ -0,0 +1,33 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import { cardApi, type CardInfoResponse } from '@/services/card'
+import { useAuth } from '@/context/authContext'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
+
+/**
+ * Hook to fetch Card Pioneer info for the authenticated user.
+ * Returns eligibility status, purchase status, and pricing.
+ */
+export const useCardPioneerInfo = () => {
+ const { user } = useAuth()
+
+ const query = useQuery({
+ queryKey: ['card-info', user?.user?.userId],
+ queryFn: () => cardApi.getInfo(),
+ enabled: !!user?.user?.userId && !underMaintenanceConfig.disableCardPioneers,
+ staleTime: 60_000, // 1 minute
+ retry: 1,
+ })
+
+ return {
+ cardInfo: query.data,
+ isLoading: query.isLoading,
+ error: query.error,
+ refetch: query.refetch,
+ // Convenience booleans - return undefined while loading to prevent flash
+ isEligible: query.isLoading ? undefined : (query.data?.isEligible ?? false),
+ hasPurchased: query.isLoading ? undefined : (query.data?.hasPurchased ?? false),
+ price: query.data?.price ?? 10,
+ }
+}
diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts
new file mode 100644
index 000000000..3498be3c9
--- /dev/null
+++ b/src/hooks/useCountUp.ts
@@ -0,0 +1,92 @@
+'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 isAnimating = useRef(false)
+ const controlsRef = useRef | null>(null)
+ const prevTargetRef = useRef(target)
+
+ 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
+ isAnimating.current = true
+
+ controlsRef.current = animate(from, to, {
+ duration,
+ 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))
+ }
+ },
+ })
+
+ 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), snap to new value
+ useEffect(() => {
+ if (prevTargetRef.current !== target && hasAnimated.current && !isAnimating.current) {
+ setDisplay(target)
+ if (storageKey) {
+ localStorage.setItem(STORAGE_PREFIX + storageKey, String(target))
+ }
+ }
+ prevTargetRef.current = target
+ }, [target, storageKey])
+
+ return display
+}
diff --git a/src/hooks/useHoldToClaim.ts b/src/hooks/useHoldToClaim.ts
index 27acdb961..cc73ab021 100644
--- a/src/hooks/useHoldToClaim.ts
+++ b/src/hooks/useHoldToClaim.ts
@@ -7,18 +7,29 @@ interface UseHoldToClaimOptions {
onComplete: () => void
holdDuration?: number
disabled?: boolean
+ /** Enable tap-to-progress mode (tap + hold both add progress, with decay) */
+ enableTapMode?: boolean
+ /** Progress added per tap (0-100), default 15 */
+ tapProgress?: number
+ /** Progress added per second while holding (0-100), default 80 */
+ holdProgressPerSec?: number
+ /** Progress decay per second when not interacting (0-100), default 8 */
+ decayRate?: number
}
interface UseHoldToClaimReturn {
holdProgress: number
isShaking: boolean
shakeIntensity: ShakeIntensity
+ isHolding: boolean
startHold: () => void
cancelHold: () => void
+ handleTap: () => void
buttonProps: {
onPointerDown: () => void
onPointerUp: () => void
onPointerLeave: () => void
+ onPointerCancel: () => void
onKeyDown: (e: React.KeyboardEvent) => void
onKeyUp: (e: React.KeyboardEvent) => void
onContextMenu: (e: React.MouseEvent) => void
@@ -29,75 +40,201 @@ interface UseHoldToClaimReturn {
/**
* Custom hook for hold-to-claim button interactions
- * Provides progress tracking, shake animation, haptic feedback, and accessibility support
+ * Supports two modes:
+ * 1. Hold-only mode (default): Progress only while holding, resets on release
+ * 2. Tap mode (enableTapMode=true): Tap + hold both add progress, with slow decay
*/
export function useHoldToClaim({
onComplete,
holdDuration = PERK_HOLD_DURATION_MS,
disabled = false,
+ enableTapMode = false,
+ tapProgress = 15,
+ holdProgressPerSec = 80,
+ decayRate = 8,
}: UseHoldToClaimOptions): UseHoldToClaimReturn {
const [holdProgress, setHoldProgress] = useState(0)
const [isShaking, setIsShaking] = useState(false)
const [shakeIntensity, setShakeIntensity] = useState('none')
+ const [isHolding, setIsHolding] = useState(false)
+
const holdTimerRef = useRef(null)
const progressIntervalRef = useRef(null)
const holdStartTimeRef = useRef(null)
+ const animationFrameRef = useRef(null)
+ const lastUpdateTimeRef = useRef(Date.now())
+ const progressRef = useRef(0)
+ const lastHapticIntensityRef = useRef('none')
+ const isCompleteRef = useRef(false)
+ const lastTapTimeRef = useRef(0)
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (holdTimerRef.current) clearTimeout(holdTimerRef.current)
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
+ if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current)
holdStartTimeRef.current = null
}
}, [])
- const cancelHold = useCallback(() => {
- const PREVIEW_DURATION_MS = 500
+ // Tap mode: Main update loop for progress, decay, and haptics
+ useEffect(() => {
+ if (!enableTapMode || disabled || isCompleteRef.current) return
+
+ const update = () => {
+ const now = Date.now()
+ const deltaTime = (now - lastUpdateTimeRef.current) / 1000
+ lastUpdateTimeRef.current = now
+
+ let newProgress = progressRef.current
+
+ // Add progress if holding
+ if (isHolding) {
+ newProgress += holdProgressPerSec * deltaTime
+ }
+ // Decay if not holding
+ else if (progressRef.current > 0) {
+ newProgress -= decayRate * deltaTime
+ }
+
+ newProgress = Math.max(0, Math.min(100, newProgress))
+ progressRef.current = newProgress
+ setHoldProgress(newProgress)
+
+ // Only shake/vibrate when progress is INCREASING (holding or just tapped)
+ // Not during decay - the gift should stabilize when you let go
+ const recentlyTapped = Date.now() - lastTapTimeRef.current < 150
+ const isProgressIncreasing = (isHolding || recentlyTapped) && newProgress > 0
+ setIsShaking(isProgressIncreasing)
+
+ // Progressive shake intensity - only when actively interacting
+ let newIntensity: ShakeIntensity = 'none'
+ if (!isProgressIncreasing || newProgress <= 0) {
+ newIntensity = 'none'
+ } else if (newProgress < 25) {
+ newIntensity = 'weak'
+ } else if (newProgress < 50) {
+ newIntensity = 'medium'
+ } else if (newProgress < 75) {
+ newIntensity = 'strong'
+ } else {
+ newIntensity = 'intense'
+ }
+
+ // Trigger haptic feedback when intensity changes (only while holding)
+ if (
+ isHolding &&
+ newIntensity !== lastHapticIntensityRef.current &&
+ newIntensity !== 'none' &&
+ 'vibrate' in navigator
+ ) {
+ switch (newIntensity) {
+ case 'weak':
+ navigator.vibrate(50)
+ break
+ case 'medium':
+ navigator.vibrate([100, 40, 100])
+ break
+ case 'strong':
+ navigator.vibrate([150, 40, 150, 40, 150])
+ break
+ case 'intense':
+ navigator.vibrate([200, 40, 200, 40, 200, 40, 200])
+ break
+ }
+ lastHapticIntensityRef.current = newIntensity
+ }
+
+ // Reset haptic tracking when not holding so next hold starts fresh
+ if (!isHolding) {
+ lastHapticIntensityRef.current = 'none'
+ }
+
+ setShakeIntensity(newIntensity)
+
+ // Check for completion
+ if (newProgress >= 100 && !isCompleteRef.current) {
+ isCompleteRef.current = true
+ onComplete()
+ return
+ }
+
+ animationFrameRef.current = requestAnimationFrame(update)
+ }
- // Calculate how long the user held
+ lastUpdateTimeRef.current = Date.now()
+ animationFrameRef.current = requestAnimationFrame(update)
+
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current)
+ }
+ }
+ }, [enableTapMode, disabled, isHolding, holdProgressPerSec, decayRate, onComplete])
+
+ // Handle tap (tap mode only)
+ const handleTap = useCallback(() => {
+ if (disabled || !enableTapMode || isCompleteRef.current) return
+
+ progressRef.current = Math.min(progressRef.current + tapProgress, 100)
+ setHoldProgress(progressRef.current)
+ lastTapTimeRef.current = Date.now()
+
+ // Haptic feedback for tap
+ if ('vibrate' in navigator) {
+ navigator.vibrate(20)
+ }
+
+ // Check for completion
+ if (progressRef.current >= 100 && !isCompleteRef.current) {
+ isCompleteRef.current = true
+ onComplete()
+ }
+ }, [disabled, enableTapMode, tapProgress, onComplete])
+
+ // Legacy hold-only mode cancel
+ const cancelHoldLegacy = useCallback(() => {
+ const PREVIEW_DURATION_MS = 500
const elapsed = holdStartTimeRef.current ? Date.now() - holdStartTimeRef.current : 0
- // Clear the completion timer
if (holdTimerRef.current) clearTimeout(holdTimerRef.current)
holdTimerRef.current = null
- // If it was a quick tap, let the preview animation continue for 500ms before resetting
if (elapsed > 0 && elapsed < PREVIEW_DURATION_MS) {
const remainingPreviewTime = PREVIEW_DURATION_MS - elapsed
-
- // Let animations continue for the preview duration
const resetTimer = setTimeout(() => {
- // Clean up after preview
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
progressIntervalRef.current = null
setHoldProgress(0)
setIsShaking(false)
setShakeIntensity('none')
holdStartTimeRef.current = null
-
- if ('vibrate' in navigator) {
- navigator.vibrate(0)
- }
+ if ('vibrate' in navigator) navigator.vibrate(0)
}, remainingPreviewTime)
-
holdTimerRef.current = resetTimer
} else {
- // Released after preview duration - reset immediately
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current)
progressIntervalRef.current = null
setHoldProgress(0)
setIsShaking(false)
setShakeIntensity('none')
holdStartTimeRef.current = null
-
- if ('vibrate' in navigator) {
- navigator.vibrate(0)
- }
+ if ('vibrate' in navigator) navigator.vibrate(0)
}
}, [])
- const startHold = useCallback(() => {
+ const cancelHold = useCallback(() => {
+ if (enableTapMode) {
+ // Tap mode: just stop holding, decay will handle the rest
+ setIsHolding(false)
+ } else {
+ cancelHoldLegacy()
+ }
+ }, [enableTapMode, cancelHoldLegacy])
+
+ // Legacy hold-only mode start
+ const startHoldLegacy = useCallback(() => {
if (disabled) return
setHoldProgress(0)
@@ -107,54 +244,41 @@ export function useHoldToClaim({
holdStartTimeRef.current = startTime
let lastIntensity: ShakeIntensity = 'weak'
- // Update progress and shake intensity
const interval = setInterval(() => {
const elapsed = Date.now() - startTime
const progress = Math.min((elapsed / holdDuration) * 100, 100)
setHoldProgress(progress)
- // Progressive shake intensity with haptic feedback
let newIntensity: ShakeIntensity = 'weak'
- if (progress < 25) {
- newIntensity = 'weak'
- } else if (progress < 50) {
- newIntensity = 'medium'
- } else if (progress < 75) {
- newIntensity = 'strong'
- } else {
- newIntensity = 'intense'
- }
+ if (progress < 25) newIntensity = 'weak'
+ else if (progress < 50) newIntensity = 'medium'
+ else if (progress < 75) newIntensity = 'strong'
+ else newIntensity = 'intense'
- // Trigger haptic feedback when intensity changes
if (newIntensity !== lastIntensity && 'vibrate' in navigator) {
- // Progressive vibration patterns that match shake intensity
switch (newIntensity) {
case 'weak':
- navigator.vibrate(50) // Short but noticeable pulse
+ navigator.vibrate(50)
break
case 'medium':
- navigator.vibrate([100, 40, 100]) // Medium pulse pattern
+ navigator.vibrate([100, 40, 100])
break
case 'strong':
- navigator.vibrate([150, 40, 150, 40, 150]) // Strong pulse pattern
+ navigator.vibrate([150, 40, 150, 40, 150])
break
case 'intense':
- navigator.vibrate([200, 40, 200, 40, 200, 40, 200]) // INTENSE pulse pattern
+ navigator.vibrate([200, 40, 200, 40, 200, 40, 200])
break
}
lastIntensity = newIntensity
}
setShakeIntensity(newIntensity)
-
- if (progress >= 100) {
- clearInterval(interval)
- }
+ if (progress >= 100) clearInterval(interval)
}, 50)
progressIntervalRef.current = interval
- // Complete after hold duration
const timer = setTimeout(() => {
onComplete()
}, holdDuration)
@@ -162,10 +286,23 @@ export function useHoldToClaim({
holdTimerRef.current = timer
}, [onComplete, holdDuration, disabled])
+ const startHold = useCallback(() => {
+ if (disabled || isCompleteRef.current) return
+
+ if (enableTapMode) {
+ // Tap mode: count as tap + start holding
+ handleTap()
+ setIsHolding(true)
+ } else {
+ startHoldLegacy()
+ }
+ }, [disabled, enableTapMode, handleTap, startHoldLegacy])
+
const buttonProps = {
onPointerDown: startHold,
onPointerUp: cancelHold,
onPointerLeave: cancelHold,
+ onPointerCancel: cancelHold,
onKeyDown: (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
e.preventDefault()
@@ -179,7 +316,6 @@ export function useHoldToClaim({
}
},
onContextMenu: (e: React.MouseEvent) => {
- // Prevent context menu from appearing
e.preventDefault()
},
className: 'relative touch-manipulation select-none overflow-hidden',
@@ -193,8 +329,10 @@ export function useHoldToClaim({
holdProgress,
isShaking,
shakeIntensity,
+ isHolding,
startHold,
cancelHold,
+ handleTap,
buttonProps,
}
}
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index 70a43822c..8613eccf9 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -11,7 +11,9 @@ import { useModalsContext } from '@/context/ModalsContext'
import { DeviceType, useDeviceType } from './useGetDeviceType'
import { usePWAStatus } from './usePWAStatus'
import { useGeoLocation } from './useGeoLocation'
+import { useCardPioneerInfo } from './useCardPioneerInfo'
import { STAR_STRAIGHT_ICON } from '@/assets'
+import underMaintenanceConfig from '@/config/underMaintenance.config'
export type CarouselCTA = {
id: string
@@ -27,6 +29,8 @@ export type CarouselCTA = {
iconContainerClassName?: string
secondaryIcon?: StaticImageData | string
iconSize?: number
+ // perk claim indicator - shows pink dot instead of X close button
+ isPerkClaim?: boolean
}
export const useHomeCarouselCTAs = () => {
@@ -41,6 +45,11 @@ export const useHomeCarouselCTAs = () => {
const { setIsQRScannerOpen } = useModalsContext()
const { countryCode: userCountryCode } = useGeoLocation()
+ const {
+ isEligible: isCardPioneerEligible,
+ hasPurchased: hasCardPioneerPurchased,
+ isLoading: isCardPioneerLoading,
+ } = useCardPioneerInfo()
const generateCarouselCTAs = useCallback(() => {
const _carouselCTAs: CarouselCTA[] = []
@@ -49,6 +58,31 @@ export const useHomeCarouselCTAs = () => {
const hasKycApproval = isUserKycApproved || isUserMantecaKycApproved
const isLatamUser = userCountryCode === 'AR' || userCountryCode === 'BR'
+ // Card Pioneer CTA - show to all users who haven't purchased yet
+ // Eligibility check happens during the flow (geo screen)
+ // Only show when we know for sure they haven't purchased (not while loading)
+ if (!underMaintenanceConfig.disableCardPioneers && hasCardPioneerPurchased === false) {
+ _carouselCTAs.push({
+ id: 'card-pioneer',
+ title: (
+
+ Get your Peanut Card
+
+ ),
+ description: (
+
+ Join Card Pioneers for early access and earn $5 per referral.
+
+ ),
+ iconContainerClassName: 'bg-purple-1',
+ icon: 'credit-card',
+ onClick: () => {
+ router.push('/card')
+ },
+ iconSize: 16,
+ })
+ }
+
// Generic invite CTA for non-LATAM users
if (!isLatamUser) {
_carouselCTAs.push({
@@ -99,14 +133,14 @@ export const useHomeCarouselCTAs = () => {
_carouselCTAs.push({
id: 'qr-payment',
title: (
-
+
Pay with QR code payments
-
+
),
description: (
-
+
Get the best exchange rate, pay like a local and earn points .
-
+
),
iconContainerClassName: 'bg-secondary-1',
icon: 'qr-code',
@@ -124,14 +158,14 @@ export const useHomeCarouselCTAs = () => {
_carouselCTAs.push({
id: 'latam-cashback-invite',
title: (
-
+
Earn 20% cashback on QR payments
-
+
),
description: (
-
+
Invite friends to unlock more rewards . The more they use, the more you earn!
-
+
),
iconContainerClassName: 'bg-secondary-1',
icon: 'gift',
@@ -178,6 +212,9 @@ export const useHomeCarouselCTAs = () => {
deviceType,
isPwa,
userCountryCode,
+ isCardPioneerEligible,
+ hasCardPioneerPurchased,
+ isCardPioneerLoading,
])
useEffect(() => {
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/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx
index 8f3bba74c..f34fb4a2b 100644
--- a/src/hooks/useSavedAccounts.tsx
+++ b/src/hooks/useSavedAccounts.tsx
@@ -16,7 +16,11 @@ export default function useSavedAccounts() {
const savedAccounts = useMemo(() => {
return (
user?.accounts.filter(
- (acc) => acc.type === AccountType.IBAN || acc.type === AccountType.US || acc.type === AccountType.CLABE
+ (acc) =>
+ acc.type === AccountType.IBAN ||
+ acc.type === AccountType.US ||
+ acc.type === AccountType.CLABE ||
+ acc.type === AccountType.GB
) ?? []
)
}, [user])
diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts
index 68f006e7f..685adee39 100644
--- a/src/hooks/useWebSocket.ts
+++ b/src/hooks/useWebSocket.ts
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react'
-import { PeanutWebSocket, getWebSocketInstance } from '@/services/websocket'
+import { PeanutWebSocket, getWebSocketInstance, type PendingPerk } from '@/services/websocket'
import { type HistoryEntry } from './useTransactionHistory'
type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -11,6 +11,7 @@ interface UseWebSocketOptions {
onKycStatusUpdate?: (status: string) => void
onMantecaKycStatusUpdate?: (status: string) => void
onTosUpdate?: (data: { accepted: boolean }) => void
+ onPendingPerk?: (perk: PendingPerk) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: Event) => void
@@ -24,6 +25,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onKycStatusUpdate,
onMantecaKycStatusUpdate,
onTosUpdate,
+ onPendingPerk,
onConnect,
onDisconnect,
onError,
@@ -38,6 +40,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onKycStatusUpdate,
onMantecaKycStatusUpdate,
onTosUpdate,
+ onPendingPerk,
onConnect,
onDisconnect,
onError,
@@ -50,11 +53,21 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onKycStatusUpdate,
onMantecaKycStatusUpdate,
onTosUpdate,
+ onPendingPerk,
onConnect,
onDisconnect,
onError,
}
- }, [onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError])
+ }, [
+ onHistoryEntry,
+ onKycStatusUpdate,
+ onMantecaKycStatusUpdate,
+ onTosUpdate,
+ onPendingPerk,
+ onConnect,
+ onDisconnect,
+ onError,
+ ])
// Connect to WebSocket
const connect = useCallback(() => {
@@ -149,6 +162,12 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
}
}
+ const handlePendingPerk = (perk: PendingPerk) => {
+ if (callbacksRef.current.onPendingPerk) {
+ callbacksRef.current.onPendingPerk(perk)
+ }
+ }
+
// Register event handlers
ws.on('connect', handleConnect)
ws.on('disconnect', handleDisconnect)
@@ -157,6 +176,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
ws.on('kyc_status_update', handleKycStatusUpdate)
ws.on('manteca_kyc_status_update', handleMantecaKycStatusUpdate)
ws.on('persona_tos_status_update', handleTosUpdate)
+ ws.on('pending_perk', handlePendingPerk)
// Auto-connect if enabled
if (autoConnect) {
@@ -172,6 +192,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
ws.off('kyc_status_update', handleKycStatusUpdate)
ws.off('manteca_kyc_status_update', handleMantecaKycStatusUpdate)
ws.off('persona_tos_status_update', handleTosUpdate)
+ ws.off('pending_perk', handlePendingPerk)
}
}, [autoConnect, connect, username])
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
new file mode 100644
index 000000000..0a580ec1e
--- /dev/null
+++ b/src/i18n/config.ts
@@ -0,0 +1,69 @@
+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]
+
+/** 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('/')}` : ''
+ 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' : HREFLANG_MAP[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' : HREFLANG_MAP[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..3a9a5e225
--- /dev/null
+++ b/src/i18n/en.json
@@ -0,0 +1,37 @@
+{
+ "sendMoneyTo": "Send Money to {country}",
+ "sendMoneyToSubtitle": "Fast, affordable transfers to {country} in {currency}. Better rates than banks.",
+ "getStarted": "Get Started",
+ "howItWorks": "How It Works",
+ "frequentlyAskedQuestions": "Frequently Asked Questions",
+ "sendMoneyToOtherCountries": "Send money to other countries",
+ "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).",
+ "stepSendToDesc": "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}.",
+ "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}",
+ "sendMoneyFromTo": "Send Money from {from} to {to}",
+ "receiveMoneyFrom": "Receive Money from {country}",
+ "receiveMoneyFromDesc": "Get money sent to you from {country}. Fast and secure.",
+ "teamTitle": "Our Team",
+ "teamSubtitle": "The people behind Peanut.",
+ "lastUpdated": "Last updated: {date}",
+ "relatedPages": "Related Pages"
+}
diff --git a/src/i18n/es-419.json b/src/i18n/es-419.json
new file mode 100644
index 000000000..fdac76bdc
--- /dev/null
+++ b/src/i18n/es-419.json
@@ -0,0 +1,37 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Comenzar",
+ "howItWorks": "Cómo Funciona",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar dinero a otros países",
+ "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).",
+ "stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
+ "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}",
+ "sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
+ "receiveMoneyFrom": "Recibir Dinero de {country}",
+ "receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
+ "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-ar.json b/src/i18n/es-ar.json
new file mode 100644
index 000000000..06b1370ea
--- /dev/null
+++ b/src/i18n/es-ar.json
@@ -0,0 +1,37 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Empezar",
+ "howItWorks": "Cómo Funciona",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar plata a otros países",
+ "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).",
+ "stepSendToDesc": "Ingresá los datos del destinatario y confirmá. Reciben {currency} en minutos vía {method}.",
+ "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}",
+ "sendMoneyFromTo": "Enviar Plata de {from} a {to}",
+ "receiveMoneyFrom": "Recibir Plata de {country}",
+ "receiveMoneyFromDesc": "Recibí plata enviada desde {country}. Rápido y seguro.",
+ "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..fdac76bdc
--- /dev/null
+++ b/src/i18n/es-es.json
@@ -0,0 +1,37 @@
+{
+ "sendMoneyTo": "Enviar Dinero a {country}",
+ "sendMoneyToSubtitle": "Transferencias rápidas y económicas a {country} en {currency}. Mejores tasas que los bancos.",
+ "getStarted": "Comenzar",
+ "howItWorks": "Cómo Funciona",
+ "frequentlyAskedQuestions": "Preguntas Frecuentes",
+ "sendMoneyToOtherCountries": "Enviar dinero a otros países",
+ "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).",
+ "stepSendToDesc": "Ingresa los datos del destinatario y confirma. Reciben {currency} en minutos vía {method}.",
+ "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}",
+ "sendMoneyFromTo": "Enviar Dinero de {from} a {to}",
+ "receiveMoneyFrom": "Recibir Dinero de {country}",
+ "receiveMoneyFromDesc": "Recibe dinero enviado desde {country}. Rápido y seguro.",
+ "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..86f9005a3
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,38 @@
+import type { Locale, Translations } from './types'
+import en from './en.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-419': es419 as Translations,
+ 'es-ar': esAr as Translations,
+ 'es-es': esEs as Translations,
+ 'pt-br': ptBr as Translations,
+}
+
+/** Get translations for a locale (falls back to English) */
+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-br.json b/src/i18n/pt-br.json
new file mode 100644
index 000000000..860c0970a
--- /dev/null
+++ b/src/i18n/pt-br.json
@@ -0,0 +1,37 @@
+{
+ "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",
+ "howItWorks": "Como Funciona",
+ "frequentlyAskedQuestions": "Perguntas Frequentes",
+ "sendMoneyToOtherCountries": "Enviar dinheiro para outros países",
+ "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).",
+ "stepSendToDesc": "Insira os dados do destinatário e confirme. Eles recebem {currency} em minutos via {method}.",
+ "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}",
+ "sendMoneyFromTo": "Enviar Dinheiro de {from} para {to}",
+ "receiveMoneyFrom": "Receber Dinheiro de {country}",
+ "receiveMoneyFromDesc": "Receba dinheiro enviado de {country}. Rápido e seguro.",
+ "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..f8acfc407
--- /dev/null
+++ b/src/i18n/types.ts
@@ -0,0 +1,67 @@
+export type Locale = 'en' | 'es-419' | 'es-ar' | 'es-es' | 'pt-br'
+
+export const SUPPORTED_LOCALES: Locale[] = ['en', 'es-419', 'es-ar', 'es-es', 'pt-br']
+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
+
+ // Section titles
+ howItWorks: string
+ frequentlyAskedQuestions: string
+ sendMoneyToOtherCountries: string
+
+ // Steps
+ stepCreateAccount: string
+ stepCreateAccountDesc: string
+ stepDepositFunds: string
+ stepDepositFundsDesc: string // "Add money via bank transfer, {method}, or stablecoins (USDC/USDT)."
+ stepSendToDesc: string // "Enter the recipient's details and confirm. They receive {currency} in minutes via {method}."
+
+ // 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}"
+
+ // From-to corridors
+ sendMoneyFromTo: string // "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."
+
+ // 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/interfaces/interfaces.ts b/src/interfaces/interfaces.ts
index f121fc926..79144b4cc 100644
--- a/src/interfaces/interfaces.ts
+++ b/src/interfaces/interfaces.ts
@@ -165,12 +165,12 @@ export interface IBridgeAccount {
id: string
customer_id: string
last_4: string
- currency?: 'usd' | 'eur' | 'mxn'
+ currency?: 'usd' | 'eur' | 'mxn' | 'gbp'
bank_name?: string
account_owner_name: string
account_number?: string
routing_number?: string
- account_type: 'iban' | 'us' | 'clabe'
+ account_type: 'iban' | 'us' | 'clabe' | 'gb'
iban?: {
account_number: string
bic?: string
@@ -181,7 +181,8 @@ export interface IBridgeAccount {
}
account?: {
account_number: string
- routing_number: string
+ routing_number?: string
+ sort_code?: string // uk bank accounts
checking_or_savings?: string
}
account_owner_type: 'individual' | 'business'
@@ -280,6 +281,7 @@ export enum AccountType {
IBAN = 'iban',
US = 'us',
CLABE = 'clabe',
+ GB = 'gb', // uk bank accounts (sort code + account number)
EVM_ADDRESS = 'evm-address',
PEANUT_WALLET = 'peanut-wallet',
BRIDGE = 'bridgeBankAccount',
@@ -305,6 +307,7 @@ export interface Account {
connectorUuid: string | null
bic?: string
routingNumber?: string
+ sortCode?: string // uk bank accounts
connector?: {
iconUrl: string
name: string
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
new file mode 100644
index 000000000..df189b600
--- /dev/null
+++ b/src/lib/blog.ts
@@ -0,0 +1,96 @@
+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
+ faqs?: Array<{ question: string; answer: 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..c82de6dd1
--- /dev/null
+++ b/src/lib/content.ts
@@ -0,0 +1,210 @@
+// 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'
+import matter from 'gray-matter'
+
+const CONTENT_ROOT = path.join(process.cwd(), 'src/content')
+
+// --- Locale fallback chains ---
+// es-ar → es-419 → en
+// es-es → en
+// pt-br → en
+// es-419 → en
+
+const FALLBACK_CHAINS: Record = {
+ en: [],
+ 'es-419': ['en'],
+ 'es-ar': ['es-419', 'en'],
+ 'es-es': ['en'],
+ 'pt-br': ['en'],
+}
+
+/** Get ordered list of locales to try (requested locale first, then fallbacks) */
+export function getLocaleFallbacks(locale: string): string[] {
+ return [locale, ...(FALLBACK_CHAINS[locale] ?? ['en'])]
+}
+
+// --- 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
+}
+
+// --- Low-level readers ---
+
+function parseMarkdownFile>(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 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
+
+ const filePath = path.join(CONTENT_ROOT, 'input/data', category, `${slug}.md`)
+ const result = parseMarkdownFile(filePath)
+ entityCache.set(key, result)
+ return result
+}
+
+// --- 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,
+ lang: string
+): MarkdownContent | null {
+ 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 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
+}
+
+// --- 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 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
+}
+
+/** 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/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/lib/url-parser/types/payment.ts b/src/lib/url-parser/types/payment.ts
index 1c7a9b31e..5953b6dec 100644
--- a/src/lib/url-parser/types/payment.ts
+++ b/src/lib/url-parser/types/payment.ts
@@ -9,7 +9,7 @@ export interface ParsedURL {
identifier: string
recipientType: RecipientType
resolvedAddress: string
- }
+ } | null
amount?: string
token?: interfaces.ISquidToken
chain?: interfaces.ISquidChain & { tokens: interfaces.ISquidToken[] }
diff --git a/src/services/card.ts b/src/services/card.ts
new file mode 100644
index 000000000..64bd890d3
--- /dev/null
+++ b/src/services/card.ts
@@ -0,0 +1,68 @@
+/**
+ * Card Pioneers API Service
+ *
+ * Handles API calls for the Card Pioneers early-access program.
+ * Uses server actions to securely include API keys.
+ */
+
+import { getCardInfo, purchaseCard } from '@/app/actions/card'
+import type { CardInfoResponse, CardPurchaseResponse } from '@/app/actions/card'
+
+export type { CardInfoResponse, CardPurchaseResponse }
+
+/**
+ * Custom error class for card purchase failures
+ */
+export class CardPurchaseError extends Error {
+ code: string
+ chargeUuid?: string
+
+ constructor(code: string, message: string, chargeUuid?: string) {
+ super(message)
+ this.name = 'CardPurchaseError'
+ this.code = code
+ this.chargeUuid = chargeUuid
+ }
+}
+
+export const cardApi = {
+ /**
+ * Get card pioneer info for the authenticated user
+ * Returns eligibility, purchase status, and pricing
+ */
+ getInfo: async (): Promise => {
+ const result = await getCardInfo()
+
+ if (result.error || !result.data) {
+ throw new Error(result.error || 'Failed to get card info')
+ }
+
+ return result.data
+ },
+
+ /**
+ * Initiate card pioneer purchase
+ * Creates a charge that the user must pay
+ */
+ purchase: async (): Promise => {
+ const result = await purchaseCard()
+
+ if (result.error || !result.data) {
+ // Handle specific error cases
+ if (result.errorCode === 'ALREADY_PURCHASED') {
+ throw new CardPurchaseError(result.errorCode, result.error || 'Already purchased')
+ }
+
+ if (result.errorCode === 'NOT_ELIGIBLE') {
+ throw new CardPurchaseError(result.errorCode, result.error || 'Not eligible')
+ }
+
+ throw new CardPurchaseError(
+ result.errorCode || 'UNKNOWN_ERROR',
+ result.error || 'Failed to initiate purchase'
+ )
+ }
+
+ return result.data
+ },
+}
diff --git a/src/services/charges.ts b/src/services/charges.ts
index 409a6f332..a24255254 100644
--- a/src/services/charges.ts
+++ b/src/services/charges.ts
@@ -74,6 +74,7 @@ export const chargesApi = {
sourceChainId,
sourceTokenAddress,
sourceTokenSymbol,
+ squidQuoteId,
}: {
chargeId: string
chainId: string
@@ -83,6 +84,7 @@ export const chargesApi = {
sourceChainId?: string
sourceTokenAddress?: string
sourceTokenSymbol?: string
+ squidQuoteId?: string
}): Promise => {
const response = await fetchWithSentry(`/api/proxy/charges/${chargeId}/payments`, {
method: 'POST',
@@ -97,6 +99,7 @@ export const chargesApi = {
sourceChainId,
sourceTokenAddress,
sourceTokenSymbol,
+ squidQuoteId,
}),
})
diff --git a/src/services/perks.ts b/src/services/perks.ts
new file mode 100644
index 000000000..67fd37dbd
--- /dev/null
+++ b/src/services/perks.ts
@@ -0,0 +1,105 @@
+import Cookies from 'js-cookie'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { PEANUT_API_URL } from '@/constants/general.consts'
+
+export type PendingPerk = {
+ id: string
+ name?: string
+ description?: string
+ reason?: string
+ amountUsd: number
+ createdAt: string
+}
+
+export type PendingPerksResponse = {
+ success: boolean
+ perks: PendingPerk[]
+ error?: string
+}
+
+export type ClaimPerkResponse = {
+ success: boolean
+ perk?: {
+ sponsored: boolean
+ amountSponsored: number
+ discountPercentage: number
+ txHash?: string
+ }
+ error?: string
+ message?: string
+}
+
+export const perksApi = {
+ /**
+ * Get pending (claimable) perks for the current user
+ */
+ getPendingPerks: async (): Promise => {
+ try {
+ const jwtToken = Cookies.get('jwt-token')
+ if (!jwtToken) {
+ console.error('getPendingPerks: No JWT token found')
+ return { success: false, perks: [], error: 'Not authenticated' }
+ }
+
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/perks/pending`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ console.error('getPendingPerks: API request failed', response.status, response.statusText)
+ return { success: false, perks: [], error: 'Failed to fetch pending perks' }
+ }
+
+ const data = await response.json()
+ return { success: true, perks: data.perks || [] }
+ } catch (error) {
+ console.error('getPendingPerks: Unexpected error', error)
+ return { success: false, perks: [], error: 'Unexpected error' }
+ }
+ },
+
+ /**
+ * Claim a perk by usage ID (V2)
+ */
+ claimPerk: async (usageId: string): Promise => {
+ try {
+ const jwtToken = Cookies.get('jwt-token')
+ if (!jwtToken) {
+ console.error('claimPerk: No JWT token found')
+ return { success: false, error: 'NOT_AUTHENTICATED', message: 'Not authenticated' }
+ }
+
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/perks/claim`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ usageId }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ console.error('claimPerk: API request failed', response.status, data)
+ return {
+ success: false,
+ error: data.error || 'CLAIM_FAILED',
+ message: data.message || 'Failed to claim perk',
+ }
+ }
+
+ return {
+ success: true,
+ perk: data.perk,
+ }
+ } catch (error) {
+ console.error('claimPerk: Unexpected error', error)
+ return { success: false, error: 'UNEXPECTED_ERROR', message: 'Unexpected error' }
+ }
+ },
+}
diff --git a/src/services/points.ts b/src/services/points.ts
index ad2dc3748..c0f1a99eb 100644
--- a/src/services/points.ts
+++ b/src/services/points.ts
@@ -338,6 +338,47 @@ export const pointsApi = {
return fetchInvitesGraph('/invites/user-graph')
},
+ getCashStatus: async (): Promise<{
+ success: boolean
+ data: {
+ hasCashbackLeft: boolean
+ lifetimeEarned: number
+ lifetimeBreakdown: {
+ cashback: number
+ inviterRewards: number
+ withdrawPerks: number
+ depositPerks: number
+ other: number
+ }
+ } | null
+ }> => {
+ try {
+ const jwtToken = Cookies.get('jwt-token')
+ if (!jwtToken) {
+ console.error('getCashStatus: No JWT token found')
+ return { success: false, data: null }
+ }
+
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/points/cash-status`, {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ 'Content-Type': 'application/json',
+ },
+ })
+ if (!response.ok) {
+ console.error('getCashStatus: API request failed', response.status, response.statusText)
+ return { success: false, data: null }
+ }
+
+ const data = await response.json()
+ return { success: true, data }
+ } catch (error) {
+ console.error('getCashStatus: Unexpected error', error)
+ return { success: false, data: null }
+ }
+ },
+
getExternalNodes: async (
apiKey: string,
options?: {
diff --git a/src/services/websocket.ts b/src/services/websocket.ts
index 20933b7d7..b49418771 100644
--- a/src/services/websocket.ts
+++ b/src/services/websocket.ts
@@ -1,4 +1,6 @@
import { type HistoryEntry } from '@/hooks/useTransactionHistory'
+import { type PendingPerk } from '@/services/perks'
+export type { PendingPerk }
import { jsonStringify } from '@/utils/general.utils'
export type WebSocketMessage = {
@@ -9,7 +11,8 @@ export type WebSocketMessage = {
| 'kyc_status_update'
| 'manteca_kyc_status_update'
| 'persona_tos_status_update'
- data?: HistoryEntry
+ | 'pending_perk'
+ data?: HistoryEntry | PendingPerk
}
export class PeanutWebSocket {
@@ -129,6 +132,12 @@ export class PeanutWebSocket {
}
break
+ case 'pending_perk':
+ if (message.data && 'id' in (message.data as object)) {
+ this.emit('pending_perk', message.data)
+ }
+ break
+
default:
// Handle other message types if needed
this.emit(message.type, message.data)
@@ -150,7 +159,6 @@ export class PeanutWebSocket {
}
private handleError(event: Event): void {
- console.error('WebSocket error:', jsonStringify(event))
this.emit('error', event)
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index a431af22e..c851dcfb7 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -486,3 +486,291 @@ input::placeholder {
flex: 0 0 100%;
min-width: 0;
}
+
+/* Slide animations for perk claim transitions */
+@keyframes slideOutLeft {
+ from {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateX(-100%);
+ opacity: 0;
+ }
+}
+
+@keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.animate-slide-out-left {
+ animation: slideOutLeft 0.3s ease-out forwards;
+}
+
+.animate-slide-in-right {
+ animation: slideInRight 0.3s ease-out forwards;
+}
+
+/* Gift box opening animations */
+@keyframes giftOpening {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(0deg) scale(1);
+ }
+ 10%,
+ 30%,
+ 50%,
+ 70%,
+ 90% {
+ transform: translateY(-4px) rotate(-2deg) scale(1.02);
+ }
+ 20%,
+ 40%,
+ 60%,
+ 80% {
+ transform: translateY(-4px) rotate(2deg) scale(1.02);
+ }
+}
+
+@keyframes giftRevealed {
+ 0% {
+ transform: scale(1.05);
+ opacity: 0.8;
+ }
+ 50% {
+ transform: scale(1.02);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes giftExit {
+ 0% {
+ transform: scale(1) translateY(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(0.95) translateY(-10px);
+ opacity: 0;
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-gift-opening {
+ animation: giftOpening 0.12s ease-in-out infinite;
+ transform-origin: center center;
+}
+
+.animate-gift-revealed {
+ animation: giftRevealed 0.4s ease-out forwards;
+}
+
+.animate-gift-exit {
+ animation: giftExit 0.4s ease-out forwards;
+}
+
+.animate-fade-in {
+ animation: fadeIn 0.5s ease-out forwards;
+}
+
+/* Gift arrival animation - gentle fade in with subtle scale (for initial page load) */
+@keyframes giftArriveGentle {
+ 0% {
+ transform: scale(0.95);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.animate-gift-arrive-gentle {
+ animation: giftArriveGentle 0.4s ease-out forwards;
+}
+
+/* Gift arrival animation - drops in from above with a bounce (for WebSocket real-time) */
+@keyframes giftArrive {
+ 0% {
+ transform: translateY(-50px) scale(0.9);
+ opacity: 0;
+ }
+ 60% {
+ transform: translateY(5px) scale(1.02);
+ opacity: 1;
+ }
+ 80% {
+ transform: translateY(-3px) scale(0.99);
+ }
+ 100% {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+ }
+}
+
+.animate-gift-arrive {
+ animation: giftArrive 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
+}
+
+/* Gift attention animation - very subtle pulse to draw attention after idle */
+@keyframes giftAttention {
+ /* Gentle pulsate - very subtle */
+ 0% {
+ transform: scale(1);
+ }
+ 15% {
+ transform: scale(1.02);
+ }
+ 30% {
+ transform: scale(1);
+ }
+ 45% {
+ transform: scale(1.02);
+ }
+ 60% {
+ transform: scale(1);
+ }
+ /* Rest before loop */
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.animate-gift-attention {
+ animation: giftAttention 4s ease-in-out infinite;
+ transform-origin: center center;
+}
+
+/* Gift box holographic shine effect */
+@keyframes giftShine {
+ 0% {
+ left: -100%;
+ }
+ 50%,
+ 100% {
+ left: 150%;
+ }
+}
+
+.gift-box-shine::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ animation: giftShine 3s ease-in-out infinite;
+ pointer-events: none;
+ 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 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));
+ }
+ 12% {
+ opacity: 1;
+ transform: translateY(calc(var(--aov-y, 20px) * -1.2)) translateX(calc(var(--aov-x, 0px) * -1.2))
+ rotate(var(--aov-rotate, 0deg));
+ }
+ 24% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.6)) translateX(calc(var(--aov-x, 0px) * 0.6))
+ rotate(var(--aov-rotate, 0deg));
+ }
+ 38% {
+ transform: translateY(calc(var(--aov-y, 20px) * -0.35)) translateX(calc(var(--aov-x, 0px) * -0.35))
+ rotate(var(--aov-rotate, 0deg));
+ }
+ 52% {
+ transform: translateY(calc(var(--aov-y, 20px) * 0.18)) translateX(calc(var(--aov-x, 0px) * 0.18))
+ rotate(var(--aov-rotate, 0deg));
+ }
+ 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% {
+ 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.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;
+}
diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts
index b18efe4e3..6ca21339e 100644
--- a/src/utils/__tests__/bridge.utils.test.ts
+++ b/src/utils/__tests__/bridge.utils.test.ts
@@ -35,8 +35,30 @@ describe('bridge.utils', () => {
})
})
+ it('should return GBP with Faster Payments for UK', () => {
+ const onrampConfig = getCurrencyConfig('GB', 'onramp')
+ expect(onrampConfig).toEqual({
+ currency: 'gbp',
+ paymentRail: 'faster_payments',
+ })
+
+ const offrampConfig = getCurrencyConfig('GB', 'offramp')
+ expect(offrampConfig).toEqual({
+ currency: 'gbp',
+ paymentRail: 'faster_payments',
+ })
+ })
+
+ it('should handle GBR country code for UK', () => {
+ const config = getCurrencyConfig('GBR', 'onramp')
+ expect(config).toEqual({
+ currency: 'gbp',
+ paymentRail: 'faster_payments',
+ })
+ })
+
it('should return EUR with SEPA for other countries', () => {
- const countries = ['DE', 'FR', 'IT', 'ES', 'NL', 'CA', 'GB', 'AU', 'JP']
+ const countries = ['DE', 'FR', 'IT', 'ES', 'NL', 'CA', 'AU', 'JP']
countries.forEach((country) => {
const onrampConfig = getCurrencyConfig(country, 'onramp')
@@ -124,6 +146,16 @@ describe('bridge.utils', () => {
expect(minimum).toBe(1)
})
+ it('should return 3 for UK', () => {
+ const minimum = getMinimumAmount('GB')
+ expect(minimum).toBe(3)
+ })
+
+ it('should return 3 for GBR country code', () => {
+ const minimum = getMinimumAmount('GBR')
+ expect(minimum).toBe(3)
+ })
+
it('should return 1 for other countries', () => {
const minimum = getMinimumAmount('DE')
expect(minimum).toBe(1)
@@ -156,6 +188,7 @@ describe('bridge.utils', () => {
expect(getPaymentRailDisplayName('sepa')).toBe('SEPA Transfer')
expect(getPaymentRailDisplayName('spei')).toBe('SPEI Transfer')
expect(getPaymentRailDisplayName('wire')).toBe('Wire Transfer')
+ expect(getPaymentRailDisplayName('faster_payments')).toBe('Faster Payments')
})
it('should return uppercase payment rail for unsupported rails', () => {
@@ -187,6 +220,15 @@ describe('bridge.utils', () => {
expect(onrampConfig.currency).toBe(offrampConfig.currency)
})
+ it('should use same payment rails for UK onramp vs offramp', () => {
+ const onrampConfig = getCurrencyConfig('GB', 'onramp')
+ const offrampConfig = getCurrencyConfig('GB', 'offramp')
+
+ expect(onrampConfig.paymentRail).toBe('faster_payments')
+ expect(offrampConfig.paymentRail).toBe('faster_payments')
+ expect(onrampConfig.currency).toBe(offrampConfig.currency)
+ })
+
it('should use same payment rails for EU countries onramp vs offramp', () => {
const onrampConfig = getCurrencyConfig('DE', 'onramp')
const offrampConfig = getCurrencyConfig('DE', 'offramp')
diff --git a/src/utils/bridge-accounts.utils.ts b/src/utils/bridge-accounts.utils.ts
index 61bd013c6..1fffc7be3 100644
--- a/src/utils/bridge-accounts.utils.ts
+++ b/src/utils/bridge-accounts.utils.ts
@@ -118,3 +118,23 @@ export function isValidRoutingNumber(routingNumber: string): boolean {
return sum % 10 === 0
}
+
+/**
+ * Validates a UK sort code (6 digits, optionally formatted as XX-XX-XX)
+ */
+export function isValidSortCode(sortCode: string): boolean {
+ // remove dashes and spaces
+ const cleaned = sortCode.replace(/[-\s]/g, '')
+ // must be exactly 6 digits
+ return /^\d{6}$/.test(cleaned)
+}
+
+/**
+ * Validates a UK bank account number (6-8 digits, zero-padded to 8 for processing)
+ */
+export function isValidUKAccountNumber(accountNumber: string): boolean {
+ // remove spaces
+ const cleaned = accountNumber.replace(/\s/g, '')
+ // uk account numbers are 6-8 digits (legacy accounts may have 6-7, padded to 8 downstream)
+ return /^\d{6,8}$/.test(cleaned)
+}
diff --git a/src/utils/bridge.utils.ts b/src/utils/bridge.utils.ts
index d022b6e41..ba9cb7eba 100644
--- a/src/utils/bridge.utils.ts
+++ b/src/utils/bridge.utils.ts
@@ -28,6 +28,13 @@ export const getCurrencyConfig = (countryId: string, operationType: BridgeOperat
}
}
+ if (countryId === 'GB' || countryId === 'GBR') {
+ return {
+ currency: 'gbp',
+ paymentRail: 'faster_payments', // UK Faster Payments
+ }
+ }
+
// All other countries use EUR/SEPA
return {
currency: 'eur',
@@ -50,6 +57,7 @@ export const getCurrencySymbol = (currency: string): string => {
usd: '$',
eur: '€',
mxn: 'MX$',
+ gbp: '£',
}
return symbols[currency.toLowerCase()] || currency.toUpperCase()
}
@@ -62,6 +70,11 @@ export const getMinimumAmount = (countryId: string): number => {
return 50
}
+ // UK has a minimum of 3 GBP
+ if (countryId === 'GB' || countryId === 'GBR') {
+ return 3
+ }
+
// Default minimum for all other countries (including US and EU)
return 1
}
@@ -76,10 +89,15 @@ export const getPaymentRailDisplayName = (paymentRail: string): string => {
sepa: 'SEPA Transfer',
spei: 'SPEI Transfer',
wire: 'Wire Transfer',
+ faster_payments: 'Faster Payments',
}
return displayNames[paymentRail] || paymentRail.toUpperCase()
}
+export function getCountryFromPath(countryPath: string): CountryData | undefined {
+ return ALL_METHODS_DATA.find((c) => c.path.toLowerCase() === countryPath.toLowerCase())
+}
+
export function getCountryFromAccount(account: Account): CountryData | undefined {
const threeLetterCountryCode = (account.details.countryCode ?? '').toUpperCase()
@@ -156,6 +174,11 @@ export const inferBankAccountType = (accountId: string): string => {
return 'ACH'
}
+ // UK Faster Payments: 8-digit account number (sort code stored separately)
+ if (/^\d{8}$/.test(accountId)) {
+ return 'Faster Payments (UK)'
+ }
+
// Fallback for other numeric formats
if (/^\d+$/.test(accountId)) {
return 'Bank Account'
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')
}
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()
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index afb85130a..87ea6ce20 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -936,6 +936,20 @@ export const generateInviteCodeLink = (username: string) => {
return { inviteLink, inviteCode }
}
+/**
+ * Extract invitee name from perk reason string.
+ * Perk reasons follow the format: "Username became a Card Pioneer! (payment: uuid)"
+ *
+ * @param reason - The perk reason string
+ * @param fallback - Fallback name if extraction fails (default: 'Your friend')
+ * @returns The extracted invitee name or fallback
+ */
+export const extractInviteeName = (reason: string | undefined | null, fallback = 'Your friend'): string => {
+ if (!reason) return fallback
+ const name = reason.split(' became')[0]
+ return name || fallback
+}
+
export const getValidRedirectUrl = (redirectUrl: string, fallbackRoute: string) => {
let decodedRedirect = redirectUrl
try {
diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts
index 3eb59e6b9..2e100e95f 100644
--- a/src/utils/sentry.utils.ts
+++ b/src/utils/sentry.utils.ts
@@ -8,9 +8,10 @@ import { type JSONValue } from '../interfaces/interfaces'
* Pattern can be a string (exact match) or regex.
*/
const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = [
- // 400 on get-user-from-cookie is expected when no valid session
{ pattern: /get-user-from-cookie/, statuses: [400, 401, 403, 404] },
{ pattern: /users/, statuses: [400, 401, 403, 404] },
+ { pattern: /perks/, statuses: [400, 401, 403, 404] },
+ { pattern: /qr-payment\/init/, statuses: [400] },
]
/**