diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index ee1132ce3..528da2f03 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -13,7 +13,14 @@ jobs: update: runs-on: ubuntu-latest steps: + # Base the bump on `dev` (the PR target), NOT the repo default branch. + # Branching off a different branch than the base is what let unrelated + # code drift leak into the auto-PR (#2226) and made it conflict with dev + # once dev's content pointer moved (#2247). Off `dev`, the PR is + # content-only by construction and always cleanly mergeable. - uses: actions/checkout@v4 + with: + ref: dev - name: Init and update submodule env: @@ -34,14 +41,19 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create PR + - name: Create PR + auto-merge (content-only) if: steps.check.outputs.changed == 'true' + # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI and auto-merge + # can fire on green checks. Falls back to GITHUB_TOKEN — the PR still + # opens, but GITHUB_TOKEN-created PRs don't trigger CI, so auto-merge + # won't fire until the PAT is configured (no regression vs today). env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail BRANCH="auto/update-content-$(date -u +%Y%m%d-%H%M%S)" SUBMODULE_SHA=$(git -C src/content rev-parse HEAD) - PARENT=$(git rev-parse HEAD) + PARENT=$(git rev-parse HEAD) # dev tip — bump is content-only vs dev BASE_TREE=$(gh api repos/${{ github.repository }}/git/commits/$PARENT --jq '.tree.sha') # Create tree via API with updated submodule pointer TREE=$(gh api repos/${{ github.repository }}/git/trees \ @@ -64,8 +76,18 @@ jobs: --method POST \ -f "ref=refs/heads/$BRANCH" \ -f "sha=$COMMIT_SHA" - gh pr create \ + PR_URL=$(gh pr create \ --head "$BRANCH" \ --base dev \ --title "Update content submodule" \ - --body "Auto-generated: updates content submodule to latest peanut-content main." + --body "Auto-generated: bumps the content submodule to latest peanut-content main. Based on dev, so it's content-only and auto-merges once checks pass.") + # Guard: only auto-merge when the PR touches NOTHING but the submodule + # pointer. If anything else slipped in, leave it for a human. + FILES=$(gh pr diff "$PR_URL" --name-only) + if [ "$FILES" = "src/content" ]; then + gh pr merge "$PR_URL" --auto --squash \ + || echo "::warning::auto-merge not enabled/permitted — left for manual merge ($PR_URL)" + else + echo "::warning::PR touches more than src/content — left for manual review:" + echo "$FILES" + fi diff --git a/.verify-content-baseline b/.verify-content-baseline index 5f6e60798..278c95299 100644 --- a/.verify-content-baseline +++ b/.verify-content-baseline @@ -1 +1 @@ -745 +740 diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 2e7ebdef5..16c490f3d 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -81,4 +81,69 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-us-bank') await context.close() }) + + // Regression: the NavHeader back button must LEAVE the amount screen on the first tap. + // Before fix/addmoney-back-nuqs-replace the flow used nuqs { history: 'push' }, so every + // amount keystroke stacked a same-screen history entry and useSafeBack's router.back() + // only stepped through stale amounts — the back button looked dead (MP/bank reports). + test('add-money/AR/bank — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/AR/bank') + + // amount step renders (verified persona skips the "country not found" gate) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + // typing writes ?amount= — with the old { history: 'push' } this stacked back-stack + // entries; with the default 'replace' it does not. + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-ar-bank-amount-typed' }) + + // one tap must exit to the country page, not linger on /bank with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/AR(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-ar-bank-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-ar-bank-back') + await context.close() + }) + + // Same regression, the originally-reported flow: Manteca (MP) AR deposit at + // /add-money/argentina/manteca. Both add-money amount screens shared the + // { history: 'push' } bug; this covers the reported variant directly. + test('add-money/argentina/manteca — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/argentina/manteca') + + // amount step renders (verified persona; currency rate is mocked) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-manteca-amount-typed' }) + + // one tap must exit to the country page, not linger on /manteca with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/argentina(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-manteca-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-manteca-back') + await context.close() + }) }) diff --git a/eslint.config.js b/eslint.config.js index 44dbeecb0..051b8f20b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,16 @@ module.exports = [ message: "window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.", }, + { + // nuqs `history: 'push'` stacks a browser-history entry on every URL write. + // For per-keystroke params (e.g. `amount`) that poisons the back stack: + // useSafeBack → router.back() then steps through stale same-screen states + // and the back button looks dead (add-money MP/bank reports, June 2026). + selector: + "CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']", + message: + "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).", + }, ], }, }, diff --git a/public/llms-full.txt b/public/llms-full.txt index 906b896de..14b336285 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1,77 +1,78 @@ # Peanut — Full Product Description -> Instant global peer-to-peer payments in digital dollars. +> Spend, send, and cash out digital dollars anywhere — pay local QR codes in Argentina and Brazil with just your passport, spend on a card, and move money across borders without a local bank account or local tax ID. ## 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. +Peanut is a self-custody money app built on digital dollars (USDC). Users hold dollars in an account that only they control and spend them like a local — scanning the same QR codes locals use, paying with a card, sending a link, or cashing out to a bank. The experience is consumer-grade: users never manage wallets, seed phrases, or gas fees. Where identity verification is needed it takes a passport and under two minutes — no local ID, bank account, or residency required. Core crypto features require no verification at all. -## Key Features +## Headline differentiators -### Instant P2P Transfers -Send digital dollars to any Peanut user instantly. No waiting for bank processing, no wire fees. +### Pay PIX in Brazil without a CPF +PIX is Brazil's instant payment network — ~150 million people use it daily. Normally it requires a CPF (Brazilian tax ID) and a Brazilian bank account, which locks out tourists, nomads, and expats. Peanut lets anyone scan a merchant PIX QR and pay in BRL using a passport-verified account funded with digital dollars. The merchant receives BRL in seconds and sees an ordinary PIX payment. The conversion isn't subject to IOF (Brazil's financial-operations tax), saving up to ~3.5% versus cards and bank transfers. Free, 24/7/365, QR scan only. -### 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. +### Pay MercadoPago in Argentina without a DNI +MercadoPago QR is accepted at 1,000,000+ merchants in Argentina. Locals need a DNI to open an account; Peanut gives access to the payment network without one — pay in pesos with a passport-verified account. Conversion uses the cripto dólar rate (a direct dollar-to-peso market rate that beats the regulated MEP rate cards use), typically several percent to ~11% more pesos per dollar. Free, instant, rate locked at payment. -### 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) +### The Peanut Card +A virtual Visa card that spends the user's digital-dollar balance anywhere Visa is accepted. Free to issue and top up; the only cost is the exchange-rate spread on currency conversion. Available in most countries (the US, Europe, the UK, Latin America, Africa, and more) and requires identity verification. A short list of regions is excluded — the card issuer's restricted-issuance list (China, India, Russia, Turkey, Vietnam, Iran, Israel, and a handful of others). -### Crypto Deposit -Fund your account by depositing crypto from any exchange (Coinbase, Binance, Kraken, Bybit, OKX, etc.) or external wallet. +## Core features -### Card Payments -Physical and virtual debit cards for spending digital dollars at any merchant that accepts card payments. +### Peanut Links +Put money in a shareable link or QR; the recipient claims it with no account needed. Works across messengers, email, and social. Unclaimed links are reclaimable; claimed is final. -### QR Payments -Generate and scan QR codes for in-person payments. +### Peanut Requests +A collection link for splitting bills, tips, or donations. Multiple contributors, funds arrive instantly to the balance. -## Security Model +### Add money (deposit) +- Crypto: USDC or USDT (and ETH on major EVM networks, auto-converted) from any exchange or wallet on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum. No verification. Gas covered. +- Bank: SEPA (EUR), ACH (USD), SPEI (MXN), Faster Payments (GBP), and incoming wires (USD/EUR). +- All deposits free. -- **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 +### Cash out (withdraw) +- Bank: SEPA, ACH, SPEI, Faster Payments — to your own or any third party. +- Crypto: to any wallet on any supported network; gas covered. +- Argentina: MercadoPago or a local account. +- All withdrawals free from Peanut. -## KYC / Compliance +### Direct bank transfers +Send from balance straight to any third-party bank account, and receive into the balance, via SEPA/ACH/SPEI/Faster Payments — no link required. -- 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 +## Security & custody -## Fee Structure +- Non-custodial smart accounts (ERC-4337) on Arbitrum. User funds never sit on Peanut servers. +- Biometric passkey auth: the private key is generated from device biometrics and sealed in the device Secure Enclave — never exported, never sent to Peanut. +- Peanut cannot freeze, seize, or move user funds. If Peanut went offline, users keep full control via any ERC-4337-compatible wallet. +- Every transaction requires an on-device biometric signature. -- 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 +## Verification & limits -## Target Markets +- Required only for bank deposits/withdrawals and local QR payments. Passport or national ID from any country (US driver's license accepted for US users). No local ID anywhere. +- Crypto deposits, crypto withdrawals, and browsing require no verification. +- Limits: Latin America ~$2,000/month combined (raisable with documents); US/Europe/Mexico/UK no hard limit (very large transactions reviewed); crypto unlimited. -Primary focus on Latin America: -- Argentina, Brazil, Mexico (largest markets) -- Colombia, Peru, Bolivia, Chile, Ecuador +## Fees -Use cases: remittances, freelancer payments, cross-border transfers, savings in stable currency, merchant payments. +- No per-transaction fee from Peanut. Same-currency moves (USD↔USDC) are free; cross-currency conversions carry a small spread embedded in the displayed, locked rate. Gas is always covered. No monthly or account fees. -## Technical Stack +## Where Peanut works -- 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 +- Local QR spending live: Argentina (MercadoPago), Brazil (PIX). +- Bank rails live: 36 SEPA-zone countries (EUR), US (ACH/wire), Mexico (SPEI), UK (Faster Payments). +- Crypto deposit/withdraw: global, all non-restricted countries. +- Card: available in most countries; excludes a short restricted-issuance list (China, India, Russia, Turkey, Vietnam, and others). +- Roadmap (not live): local payment methods in many more countries (CoDi, Transfiya, Yape, Bizum, MB WAY, UPI, and others). Only Argentina and Brazil have live local spending today. + +## Who it's for + +Tourists, digital nomads, expats, remote workers, freelancers, and families moving money across borders — especially anyone wanting to spend or save in dollars in Latin America without a local ID or bank account. ## Company -- Founded by Konrad Kononenko and Hugo Montenegro -- Based in Europe, serving Latin America - Website: https://peanut.me -- Twitter: https://twitter.com/PeanutProtocol +- Twitter/X: https://twitter.com/PeanutProtocol - GitHub: https://github.com/peanutprotocol - LinkedIn: https://www.linkedin.com/company/peanut-trade/ +- Careers: https://peanut.me/careers +- Support: https://peanut.me/support diff --git a/public/llms.txt b/public/llms.txt index eb344e2cc..bac7c3b4d 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,27 +1,81 @@ # Peanut -> Instant global peer-to-peer payments in digital dollars. +> Spend, send, and cash out digital dollars anywhere — pay local QR codes in Argentina and Brazil with just your passport, spend on a card, and move money across borders without a local bank account or local tax ID. -Peanut is the easiest way to send digital dollars to anyone, anywhere. No banks, no borders — just fast, cheap money transfers. +Peanut is a self-custody money app built on digital dollars (USDC). You hold dollars in an account only you control, then spend them like a local — scanning the same QR codes locals use, paying with a card, or sending a link to anyone. Verification (when needed) takes a passport and under two minutes: no DNI, no CPF, no local bank account, no residency. Core crypto features need no verification at all. -## What Peanut Does +## Pay PIX in Brazil — no CPF needed -- **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 +- Scan any merchant **PIX QR code** and pay in seconds — the same instant payment network ~150 million Brazilians use every day. +- **No CPF (Brazilian tax ID) and no Brazilian bank account required** — normally both are mandatory to use PIX. Verify with a passport from any country instead. +- The merchant receives BRL instantly and sees a normal PIX payment — Peanut is invisible on their end. +- Your dollars convert to BRL at the market ("cripto dólar") rate, locked the moment you confirm. IOF (Brazil's financial-operations tax) does not apply to the conversion — saving up to ~3.5% versus cards and bank transfers on cross-border spending. +- No Peanut fee on PIX payments. Available 24/7/365. QR scan only (the "copia e cola" paste-code flow is not supported). +- To pay a person rather than a merchant, share a Peanut Link — they claim it and can withdraw to their own PIX instantly, no Peanut account needed. -## Supported Corridors +## Pay MercadoPago in Argentina — no DNI needed -- Argentina (bank transfer, MercadoPago) -- Brazil (PIX, bank transfer) -- Mexico (SPEI, bank transfer) -- Colombia, Peru, Bolivia, and more +- Scan **MercadoPago QR codes** at 1,000,000+ merchants in Argentina and pay in pesos. +- **No Argentine DNI, no local bank account, no CBU or CVU required** — locals need a DNI to open MercadoPago; Peanut gives you the payment network without one. Verify with a passport. +- You get the **cripto dólar** rate — typically a few percent to ~11% more pesos per dollar than a credit card (which uses the regulated MEP rate), depending on the day and your card's fees. +- No Peanut fee. Rate locks at payment. Merchant sees a normal MercadoPago payment. +- Only MercadoPago QR is supported (MODO and Cuenta DNI use incompatible formats). + +## The Peanut Card + +- A **virtual Visa card** that spends your digital-dollar balance anywhere Visa is accepted. +- Free to issue, free to top up. You pay only the exchange-rate spread when spending in another currency — no separate card fee. +- **Available in most countries** — the US, Europe, the UK, Latin America, Africa, and more. Requires identity verification. +- A short list of countries can't be issued the card (the issuer's restricted-region list — including China, India, Russia, Turkey, Vietnam, Iran, and Israel, among a handful of others). + +## Send & receive money + +- **Peanut Links** — put money in a shareable link or QR. The recipient claims it with no Peanut account needed; works over WhatsApp, email, or any messenger. Unclaimed links are reclaimable; claimed is final. +- **Peanut Requests** — a collection link for splitting bills, tips, or donations; multiple people can contribute, instantly to your balance. +- **Direct transfers** between Peanut accounts are instant and free. +- **Direct bank transfers** — send from your balance straight to any third-party bank account (and receive into your balance) via SEPA, ACH, SPEI, or Faster Payments. No link required. + +## Add money (deposit) + +- **From crypto** — deposit USDC or USDT from any exchange (Coinbase, Binance, Kraken, Bybit, OKX, etc.) or wallet, on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum. ETH is also accepted on major EVM networks and auto-converts. No verification needed. Peanut covers gas. +- **From a bank** — SEPA (EUR, 36 European countries, ~90% under 20 min), ACH (USD, US, 1–3 business days), SPEI (MXN, Mexico, seconds, 24/7), Faster Payments (GBP, UK, seconds, 24/7), and incoming wires (USD/EUR, global). +- All deposits are free from Peanut. + +## Cash out (withdraw) + +- **To a local bank** — SEPA, ACH, SPEI, and Faster Payments, to your own or anyone's account. +- **To crypto** — withdraw to any wallet on any supported network; Peanut covers gas. +- **In Argentina** — cash out to MercadoPago or a local account. +- All withdrawals are free from Peanut (your bank may charge). + +## How it works (the basics) + +- Your balance is held in **digital dollars (USDC)** and shown in USD. It doesn't move with local exchange rates until you spend. +- **Self-custody:** funds sit in your own smart account secured by a biometric passkey (face/fingerprint). Peanut never holds your keys and cannot freeze or move your money. If Peanut went offline, you'd still control your account. +- **Verification:** required only for bank deposits/withdrawals and local QR payments. A passport or national ID (US driver's license also accepted for US users) — no local ID anywhere. Crypto deposits, crypto withdrawals, and browsing need no verification. +- **Fees:** Peanut adds no per-transaction fee. Same-currency moves (USD↔USDC) are free; cross-currency conversions carry a small spread already embedded in the rate you see and confirm. Gas is always covered. +- **Limits:** Latin America has a ~$2,000/month combined limit (raisable with more documents); the US, Europe, Mexico, and UK have no hard limit (very large transactions may be reviewed); crypto has no limits. + +## Where Peanut works + +- **Local QR spending live now:** Argentina (MercadoPago) and Brazil (PIX). +- **Bank deposit/withdraw live:** 36 SEPA-zone European countries (EUR), United States (ACH/wire), Mexico (SPEI), United Kingdom (Faster Payments). +- **Crypto deposit/withdraw:** available globally in all non-restricted countries. +- **Peanut Card:** available in most countries (a short restricted list is excluded — e.g. China, India, Russia, Turkey, Vietnam). +- **Coming soon (not live):** local QR/payment methods in many more countries — Mexico (CoDi), Colombia (Transfiya), Peru (Yape), Spain (Bizum), Portugal (MB WAY), India (UPI), and others. These are roadmap; only Argentina and Brazil have live local spending today. + +## Who it's for + +Tourists, digital nomads, expats, remote workers, freelancers, and families moving money across borders — especially anyone who wants to spend or save in dollars in Latin America without a local ID or bank account. ## Links - Website: https://peanut.me +- Pay with PIX (Brazil): https://peanut.me/en/pay-with/pix +- Pay with MercadoPago (Argentina): https://peanut.me/en/pay-with/mercadopago +- Brazil guide: https://peanut.me/en/brazil +- Argentina guide: https://peanut.me/en/argentina +- Help center: https://peanut.me/en/help - Careers: https://peanut.me/careers - Support: https://peanut.me/support - Full description: https://peanut.me/llms-full.txt diff --git a/redirects.json b/redirects.json index c8db74a25..e66e25ab5 100644 --- a/redirects.json +++ b/redirects.json @@ -154,5 +154,25 @@ "source": "/press-kit", "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d", "permanent": true + }, + { + "source": "/:locale/deposit/via-avalanche", + "destination": "/:locale/supported-networks", + "permanent": true + }, + { + "source": "/:locale/withdraw/avalanche", + "destination": "/:locale/supported-networks", + "permanent": true + }, + { + "source": "/:locale/deposit/from-faster-payments", + "destination": "/:locale/deposit/via-faster-payments", + "permanent": true + }, + { + "source": "/:locale/deposit/from-spei", + "destination": "/:locale/deposit/via-spei", + "permanent": true } ] diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 7d9ed1e85..9f7374ce7 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -26,6 +26,7 @@ import fs from 'fs' import path from 'path' +import { RAIL_SLUGS } from '../src/data/seo/deposit-rails' const ROOT = path.join(process.cwd(), 'src/content') const CONTENT_DIR = path.join(ROOT, 'content') @@ -37,20 +38,9 @@ const PRIMARY_LOCALES = ['en', 'es-419', 'pt-br'] // `content/deposit/` mixes two URL families on the same dynamic route: // exchanges → /{locale}/deposit/from-{slug} // rails → /{locale}/deposit/via-{slug} -// The rail list lives in src/data/seo/exchanges.ts (DEPOSIT_RAILS). Keep in sync. -// Anything in content/deposit/ that isn't a rail is an exchange. -const RAIL_SLUGS = new Set([ - 'ach', - 'arbitrum', - 'avalanche', - 'base', - 'ethereum', - 'polygon', - 'sepa', - 'solana', - 'tron', - 'wire', -]) +// RAIL_SLUGS is the single source of truth (src/data/seo/deposit-rails), shared +// with the app's route + sitemap generation. Anything in content/deposit/ that +// isn't a rail is an exchange. const exchangeSlugs = listDirs(path.join(ROOT, 'content/deposit')).filter((s) => !RAIL_SLUGS.has(s)) // --- Diagnostics --- diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index a704a2db1..e94ae231a 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -34,6 +34,8 @@ import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' +import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -49,13 +51,15 @@ export default function OnrampBankPage() { // URL state - persisted in query params // Example: /add-money/mexico/bank?step=inputAmount&amount=500 - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'showDetails']), - amount: parseAsString, - }, - { history: 'push' } - ) + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'showDetails']), + amount: parseAsString, + }) // Amount from URL const rawTokenAmount = urlState.amount ?? '' @@ -111,6 +115,18 @@ export default function OnrampBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) + // A ready bank rail can still carry a future-dated requirement (the gate's + // `advisory`). Offer it as a skippable pre-empt at the proceed step. + const advisory = gate.kind === 'ready' ? gate.advisory : undefined + const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ + advisory, + isLoading: sumsubFlow.isLoading, + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() @@ -231,12 +247,18 @@ export default function OnrampBankPage() { return } - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { - amount_usd: usdEquivalent, - method_type: 'bank', - country: selectedCountryPath, + // ready — offer the skippable advisory pre-empt once; on proceed (now, or + // after "Not now") record the amount-entered event and open the + // confirmation modal. Firing inside the proceed avoids double-counting if + // the user dismisses the advisory and re-clicks. + advisoryIntercept(() => { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { + amount_usd: usdEquivalent, + method_type: 'bank', + country: selectedCountryPath, + }) + setShowWarningModal(true) }) - setShowWarningModal(true) } const handleWarningConfirm = async () => { @@ -438,6 +460,8 @@ export default function OnrampBankPage() { regionName={selectedCountry?.title} /> + + +}): Promise { + const resolvedSearchParams = await searchParams + + let siteUrl = BASE_URL + try { + siteUrl = (await getOrigin()) || BASE_URL + } catch { + // getOrigin throws on a missing/invalid host header — fall back to BASE_URL + } + + const claimData = await getClaimLinkData(resolvedSearchParams, siteUrl) + return buildClaimMetadata({ claimData, siteUrl }) +} export default function ClaimPage() { return diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 41a33bd68..b25cadca6 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -75,7 +75,7 @@ export default function Home() { const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) - const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false) + const [showKycModal, setShowKycModal] = useState(false) // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key @@ -89,12 +89,16 @@ export default function Home() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // sync modal state with user data when it changes + // Show the "You're unlocked" celebration exactly once: the user has a usable + // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is + // null, stamped server-side on dismiss). A KYC re-approval can't resurface it + // — unlike the old showKycCompletedModal flag, which re-fired on every + // `→ approved` transition (e.g. the faster_payments endorsement backfill). useEffect(() => { - if (user?.user.showKycCompletedModal !== undefined) { - setShowKycModal(user.user.showKycCompletedModal) + if (isKycApproved && !user?.user.activationCelebratedAt) { + setShowKycModal(true) } - }, [user?.user.showKycCompletedModal]) + }, [isKycApproved, user?.user.activationCelebratedAt]) const userFullName = useMemo(() => { if (!user) return @@ -256,7 +260,7 @@ export default function Home() { if (user?.user.userId) { await updateUserById({ userId: user.user.userId, - showKycCompletedModal: false, + dismissActivationCelebration: true, }) // refetch user to ensure the modal doesn't reappear await fetchUser() diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 4f91076d9..30b14df4d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -25,7 +25,7 @@ import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' -import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import { useTosGuard } from '@/hooks/useTosGuard' @@ -33,6 +33,8 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' +import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -92,6 +94,18 @@ export default function WithdrawBankPage() { const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) const sumsubFlow = useMultiPhaseKycFlow({}) + // A ready bank rail can still carry a future-dated requirement (the gate's + // `advisory`). Offer it as a skippable pre-empt before the withdrawal. + const advisory = gate.kind === 'ready' ? gate.advisory : undefined + const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ + advisory, + isLoading: sumsubFlow.isLoading, + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() @@ -146,32 +160,17 @@ export default function WithdrawBankPage() { }, [bankAccount, router, amountToWithdraw, country, view]) const destinationDetails = (account: Account) => { - let countryId: string - - switch (account.type) { - case AccountType.US: - countryId = 'US' - break - case AccountType.IBAN: - // Default to a European country that uses EUR/SEPA - countryId = 'DE' // Germany as default EU country - break - case AccountType.CLABE: - countryId = 'MX' - break - case AccountType.GB: - countryId = 'GB' - break - default: - return { - currency: '', - paymentRail: '', - externalAccountId: null, - } - } - - const { currency, paymentRail } = getOfframpCurrencyConfig(countryId) - + // Derive currency + rail from the account's actual type (GB→GBP, IBAN→EUR, + // US→USD, CLABE→MXN) rather than re-deriving from a country switch whose + // `default` returned an empty currency/rail. A UK account that arrived typed + // anything but GB (the pre-BANK_GB BE mistype, or a Prisma-shaped 'BANK_GB' + // string) fell through that default → empty payload → "External account ID + // is missing.". getOfframpConfigFromAccount tolerates both the projected + // ('gb') and Prisma-shaped ('BANK_GB') strings and keeps this flow + // consistent with the Claim flow (BankFlowManager). Manteca accounts never + // reach this Bridge page (separate /withdraw/manteca route), so its throw + // cannot fire here. + const { currency, paymentRail } = getOfframpConfigFromAccount(account) return { currency, paymentRail, @@ -193,7 +192,7 @@ export default function WithdrawBankPage() { return 'N/A' } - const handleCreateAndInitiateOfframp = async () => { + const proceedWithOfframp = async () => { if (gate.kind !== 'ready') { // Loading and waiting-on-provider both mean "user has no action to // take" — silently no-op instead of bouncing them through Sumsub. @@ -327,6 +326,11 @@ export default function WithdrawBankPage() { } } + // Offer the skippable advisory pre-empt once, then run the offramp. When the + // gate isn't `ready` (or nothing is future-dated) this is a no-op and + // proceedWithOfframp runs straight away (it handles the not-ready cases). + const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp()) + const countryCodeForFlag = () => { if (!bankAccount?.details?.countryCode) return '' const code = @@ -550,6 +554,7 @@ export default function WithdrawBankPage() { providerMessage={getGateUserMessage(gate)} regionName={getCountryFromPath(country)?.title} /> + ) diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 70b4beb0e..af5c7de1a 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,6 +7,7 @@ import { isAddress } from 'viem' import { printableAddress, isStableCoin } from '@/utils/general.utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' +import { buildOgImageUrl } from '@/utils/og.utils' import { notFound } from 'next/navigation' import { couldBeRecipient, isReservedRoute } from '@/constants/routes' @@ -99,30 +100,19 @@ export async function generateMetadata({ params, searchParams }: any) { if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('type', 'request') - ogUrl.searchParams.set('username', recipient) - - if (amount) { - ogUrl.searchParams.set('amount', String(amount)) - if (token) { - ogUrl.searchParams.set('token', token.toUpperCase()) - } - } else { - // For ETH addresses/ENS without amount, set to 0 to show "is requesting funds" - ogUrl.searchParams.set('amount', '0') - } - - // Only show as receipt if there's both a chargeId AND it's paid - if (chargeId && isPaid) { - ogUrl.searchParams.set('isReceipt', 'true') - } - - if (isPeanutUsername) { - ogUrl.searchParams.set('isPeanutUsername', 'true') - } - - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl( + { + type: 'request', + username: recipient, + // ETH addresses/ENS without an amount use 0 to show "is requesting funds" + amount: amount ? String(amount) : '0', + token: amount && token ? token.toUpperCase() : undefined, + // only a receipt when there's a chargeId AND it's paid + isReceipt: Boolean(chargeId && isPaid), + isPeanutUsername, + }, + siteUrl + ) } } diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a3f72895b..519e8f43c 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -86,12 +86,15 @@ export const restartIdentityVerification = async (): Promise<{ // initiate self-heal document resubmission for a provider-rejected user export const initiateSelfHealResubmission = async ( - provider: 'BRIDGE' | 'MANTECA' + provider: 'BRIDGE' | 'MANTECA', + // Optional — target a specific (e.g. future-dated advisory) Bridge requirement + // by key. Omitted for the legacy blocking flow (current nextAction). + requirementKey?: string ): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => { try { const response = await serverFetch('/users/identity/resubmit', { method: 'POST', - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(requirementKey ? { requirementKey } : {}) }), }) const responseJson = await response.json() @@ -112,3 +115,44 @@ export const initiateSelfHealResubmission = async ( return { error: message } } } + +export interface StartKycActionResponse { + token: string + levelName: string + externalActionId?: string +} + +/** + * Mint a Sumsub WebSDK token for a capability nextAction by its `key` + * (POST /users/kyc/start-action). The capability model returns action + * descriptors (a stable key + a registry levelKey) and never carries a token; + * the FE posts the key here to get an unexpired token bound to the right RFI + * level. Used by the advisory pre-empt — an already-approved user starting a + * future-dated RFI early, where /users/identity would short-circuit on + * "already approved" and never mint a token. + */ +export const startKycAction = async (key: string): Promise<{ data?: StartKycActionResponse; error?: string }> => { + try { + const response = await serverFetch('/users/kyc/start-action', { + method: 'POST', + body: JSON.stringify({ key }), + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' } + } + if (!responseJson.sumsubAccessToken) { + return { error: 'Invalid response from server' } + } + return { + data: { + token: responseJson.sumsubAccessToken, + levelName: responseJson.levelName, + externalActionId: responseJson.externalActionId, + }, + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index ad6f00809..dbe935fb8 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -3,6 +3,7 @@ import getOrigin from '@/lib/hosting/get-origin' import { type Metadata } from 'next' import { validateInviteCode } from '../actions/invites' import { BASE_URL } from '@/constants/general.consts' +import { buildOgImageUrl } from '@/utils/og.utils' export const dynamic = 'force-dynamic' @@ -46,14 +47,10 @@ export async function generateMetadata({ description = 'Join Peanut to send and receive money instantly, shop with merchants, and move funds across borders.' - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('isInvite', 'true') - ogUrl.searchParams.set('username', inviteCodeData.username) - if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl({ username: inviteCodeData.username, isInvite: true }, siteUrl) } } diff --git a/src/app/m/[slug]/merchants.ts b/src/app/m/[slug]/merchants.ts index e7812a7a0..314d03039 100644 --- a/src/app/m/[slug]/merchants.ts +++ b/src/app/m/[slug]/merchants.ts @@ -187,7 +187,7 @@ export const MERCHANTS: Record = { { id: 'funding', question: 'How do I fund it?', - answer: 'Two ways. Crypto: USDC or USDT on Solana, Arbitrum, Base, Tron, Avalanche, Polygon, or Ethereum, and we cover the network fees. Bank: SEPA, ACH, or wire from any country. No Argentine bank account needed.', + answer: 'Two ways. Crypto: USDC or USDT on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum, and we cover the network fees. Bank: SEPA, ACH, or wire from any country. No Argentine bank account needed.', }, { id: 'receiving', diff --git a/src/app/receipt/[entryId]/page.tsx b/src/app/receipt/[entryId]/page.tsx index f55158c76..68a4131f3 100644 --- a/src/app/receipt/[entryId]/page.tsx +++ b/src/app/receipt/[entryId]/page.tsx @@ -13,6 +13,7 @@ import { generateMetadata as generateBaseMetadata } from '@/app/metadata' import { type Metadata } from 'next' import { BASE_URL } from '@/constants/general.consts' import { formatAmount, formatCurrency, isStableCoin } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import getOrigin from '@/lib/hosting/get-origin' import PageContainer from '@/components/0_Bruddle/PageContainer' @@ -138,32 +139,31 @@ export async function generateMetadata({ // Generate dynamic OG image URL const origin = (await getOrigin()) || BASE_URL - const ogUrl = new URL(`${origin}/api/og`) - - // Map transaction type for OG image const ogType = mapTransactionTypeToOGType(transactionDetails.extraDataForDrawer?.transactionCardType || 'send') - ogUrl.searchParams.set('type', ogType) - ogUrl.searchParams.set('isReceipt', 'true') - - // Add amount if available (always use USD amount) - if (transactionDetails.amount > 0) { - ogUrl.searchParams.set('amount', formatCurrency(Number(transactionDetails.amount).toString())) - ogUrl.searchParams.set('token', 'USDC') - } - - // Add username if available and not an address-like string - if ( + const hasAmount = transactionDetails.amount > 0 + // include username only when present and not an address-like string + const hasUsername = Boolean( transactionDetails.userName && transactionDetails.userName.length < 20 && !transactionDetails.userName.startsWith('0x') - ) { - ogUrl.searchParams.set('username', transactionDetails.userName) - } + ) + + const ogImageUrl = buildOgImageUrl( + { + type: ogType, + isReceipt: true, + username: hasUsername ? transactionDetails.userName : undefined, + // always denominate the receipt amount in USD + amount: hasAmount ? formatCurrency(Number(transactionDetails.amount).toString()) : undefined, + token: hasAmount ? 'USDC' : undefined, + }, + origin + ) return generateBaseMetadata({ title, description, - image: ogUrl.toString(), + image: ogImageUrl, keywords: 'crypto receipt, transaction receipt, payment receipt, Peanut Protocol', }) } diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a43a54724..a6435d34e 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -41,15 +41,17 @@ const MantecaAddMoney: FC = () => { // URL state - persisted in query params // Example: /add-money/argentina/manteca?step=inputAmount&amount=100¤cy=ARS - // The `amount` is stored in whatever denomination `currency` specifies - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'depositDetails']), - amount: parseAsString, - currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), - }, - { history: 'push' } - ) + // The `amount` is stored in whatever denomination `currency` specifies. + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'depositDetails']), + amount: parseAsString, + currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), + }) // Derive state from URL (with defaults) const step: MantecaStep = urlState.step ?? 'inputAmount' diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 6698a3044..c00fc73de 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -209,6 +209,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // fallback to the previous method if we can't find the new account // this can happen if the user object is not updated immediately const newAccountFromResponse = result.data as Account + // The freshly-added account hasn't surfaced in the user refetch yet. + // The add-bank-account response is the projected wire shape, so it + // already carries bridgeAccountId + the legacy `type`. Guard: without + // a bridgeAccountId the confirm step dead-ends on "Bank account is + // missing", so surface a retryable error rather than navigating. + if (!newAccountFromResponse?.bridgeAccountId) { + return { error: 'Your bank account is still being set up. Please try again in a moment.' } + } // ensure details has accountOwnerName for confirmation page display newAccountFromResponse.details = { ...(newAccountFromResponse.details || {}), diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 0b27f89ff..3b65d8488 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -10,6 +10,7 @@ import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/compo import { useParams, useRouter } from 'next/navigation' import { validateIban, + validateBankAccount, validateBic, isValidRoutingNumber, isValidSortCode, @@ -19,7 +20,12 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { + getCountryFromIban, + getCountryCodeForWithdraw, + validateMXCLabeAccount, + validateUSBankAccount, +} from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -213,11 +219,29 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ? accountNumber.replace(/\s/g, '').padStart(8, '0') : accountNumber.replace(/\s/g, '') + // SEPA routes by IBAN, so the destination country must come from the + // IBAN itself — not the country picked on the previous screen. We + // relaxed the "IBAN must match the selected country" gate, so without + // this a German IBAN entered under "Spain" would reach Bridge with + // countryCode=ESP and 400. Derive all three country fields from the + // IBAN to keep the Bridge payload internally consistent (exactly what + // the old equality gate guaranteed, just sourced from the IBAN). + // Single normalized IBAN source: the IBAN is entered in the + // accountNumber field (→ cleanedAccountNumber), but fall back to the + // separate `iban` form value so the country never derives off an empty + // string (which would send an empty countryCode to Bridge → 400). + const normalizedIban = isIban ? cleanedAccountNumber || (iban || '').replace(/\s/g, '') : '' + const ibanCountryCode = isIban + ? getCountryCodeForWithdraw(normalizedIban.slice(0, 2).toUpperCase()) + : '' + const ibanCountryName = isIban ? (getCountryFromIban(normalizedIban) ?? selectedCountry) : '' + const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() + const payload: Partial = { accountType, accountNumber: cleanedAccountNumber, - countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: selectedCountry, + countryCode: resolvedCountryCode, + countryName: isIban ? ibanCountryName : selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -228,7 +252,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D city: data.city ?? '', state: data.state ?? '', postalCode: data.postalCode ?? '', - country: isUs ? 'USA' : country.toUpperCase(), + country: resolvedCountryCode, }, ...(bic && { bic }), } @@ -453,9 +477,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const isValidIban = await validateIban(val) if (!isValidIban) return 'Invalid IBAN' - if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { - return 'IBAN does not match the selected country' - } + // SEPA routes by IBAN — the country picked on the + // previous screen is cosmetic for a EUR payout. Don't + // force the IBAN's country to equal the dropdown: that + // false-rejected a German IBAN with Spain selected, and + // blocked UK users withdrawing EUR to a GB IBAN. Gate on + // actual support instead (BE allowedCountries: SEPA/US/CA). + const isSupported = await validateBankAccount(val) + if (!isSupported) return 'This IBAN isn’t supported for withdrawals' return true }, diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 663660de0..14bff2756 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -11,6 +11,9 @@ export interface RevealedCardDetails { cvv: string expiryMonth: number expiryYear: number + /** Registered cardholder name from Rain. Optional — the backend resolves it + * best-effort, so a Rain hiccup leaves it absent and the field is hidden. */ + cardholderName?: string } interface Props { @@ -125,10 +128,17 @@ const CardFace: FC = ({ )} + {/* Registered cardholder name — PII, kept out of session + * recordings like the other revealed fields. */} + {revealed.cardholderName && ( + + {revealed.cardholderName} + + )}
-
Expiry
+ {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} {/* ph-no-capture: expiry digits out of recordings. */}
{String(revealed.expiryMonth).padStart(2, '0')}/ @@ -137,7 +147,7 @@ const CardFace: FC = ({
-
CVV
+ {/* "CVV" label dropped — value only */} {/* ph-no-capture: CVV out of recordings. */}
{revealed.cvv}
@@ -170,11 +180,11 @@ const CardFace: FC = ({
-
Expiry
+ {/* label dropped to match the revealed layout — no height jump on reveal */}
-
CVV
+ {/* label dropped to match the revealed layout */}
diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx new file mode 100644 index 000000000..2c50a501b --- /dev/null +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -0,0 +1,43 @@ +/** + * CardFace — registered cardholder name. + * + * The name comes from Rain (best-effort) and is shown ONLY in the revealed + * state, alongside PAN/CVV/expiry. It must never appear on the masked card, and + * the card must still render when the reveal payload omits the name (backend + * degraded the Rain lookup). + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' + +const revealed: RevealedCardDetails = { + pan: '4111111111111234', + cvv: '123', + expiryMonth: 12, + expiryYear: 2030, + cardholderName: 'Jane Doe', +} + +describe('CardFace cardholder name', () => { + it('shows the registered name when the card is revealed', () => { + render() + const name = screen.getByText('Jane Doe') + expect(name).toBeInTheDocument() + // PII guard: the name must stay inside the ph-no-capture wrapper so it's + // kept out of session recordings — assert the class, not just the text. + expect(name).toHaveClass('ph-no-capture') + }) + + it('hides the name when the card is masked (not revealed)', () => { + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + }) + + it('still renders the revealed card when the name is absent', () => { + const { cardholderName: _omitted, ...withoutName } = revealed + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + // PAN still renders, proving reveal works without the name. + expect(screen.getByText('4111 1111 1111 1234')).toBeInTheDocument() + }) +}) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index f66aacdef..4fbfb4b24 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -15,7 +15,7 @@ import useClaimLink from '../../useClaimLink' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useAuth } from '@/context/authContext' import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/services/services.types' -import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import { getContractAddress } from '@/utils/peanut-claim.utils' @@ -239,7 +239,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const externalAccountId = (account.bridgeAccountId ?? account.id) as string - const destination = getOfframpCurrencyConfig(account.country ?? selectedCountry!.id) + // Derive destination currency + rail from the SELECTED ACCOUNT's + // type, not from `selectedCountry`. Pairing a GB/GBP account with + // a SEPA destination is semantically impossible — Bridge rejects + // with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N + // on 2026-06-02). The account's `type` already carries the right + // answer for every Bridge destination we support. + const destination = getOfframpConfigFromAccount(account) // handle offramp request creation const offrampRequestParams: TCreateOfframpRequest = { @@ -321,6 +327,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } if (addBankAccountResponse.data?.id) { const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() derives + // the rail from it (GB→GBP) instead of falling back to country + type: addBankAccountResponse.data.type, name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', iban: addBankAccountResponse.data.type === 'iban' @@ -410,6 +419,18 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // merge the external account details with the user's details const finalBankDetails = { + // derive the account type from the response shape so + // getOfframpConfigFromAccount() routes by rail (GB sort_code → gb) + // instead of falling back to country + type: externalAccountResponse?.iban + ? 'iban' + : externalAccountResponse?.clabe + ? 'clabe' + : externalAccountResponse?.account?.sort_code + ? 'gb' + : externalAccountResponse?.account + ? 'us' + : undefined, id: externalAccountResponse.id, bridgeAccountId: externalAccountResponse.id, name: externalAccountResponse.bank_name ?? rawData.name, @@ -459,6 +480,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const lastName = lastNameParts.join(' ') const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() + // derives the rail from it instead of falling back to country + type: account.type, name: account.details?.accountOwnerName || user?.user.fullName || '', iban: account.type === 'iban' ? account.identifier || '' : '', clabe: account.type === 'clabe' ? account.identifier || '' : '', diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index d1be84f07..9946fd6bc 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -32,7 +32,7 @@ const NavHeader = ({
{!onPrev ? ( -
diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx new file mode 100644 index 000000000..bf0a62a11 --- /dev/null +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -0,0 +1,65 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface AdvisoryPreemptModalProps { + visible: boolean + /** ISO date the requirement becomes blocking; drives the deadline copy. */ + effectiveDate?: string + isLoading?: boolean + /** Launch the verification flow early. */ + onCompleteNow: () => void + /** Dismiss and continue with what the user was doing. */ + onSkip: () => void + onClose: () => void +} + +function formatEffectiveDate(iso?: string): string | null { + if (!iso) return null + const date = new Date(iso) + // `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight. + // Format in UTC too, or Americas timezones render the day before the deadline. + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) +} + +/** + * Skippable pre-empt for a future-dated verification requirement on a rail that + * still works today (the gate's `ready` + `advisory`). "Complete now" launches + * the verification early; "Not now" lets the user carry on and resolve it later. + * Once the effective date passes the backend reclassifies the requirement to + * blocking and the non-skippable InitiateKycModal takes over — there is no FE + * cutover logic here. + */ +export default function AdvisoryPreemptModal({ + visible, + effectiveDate, + isLoading = false, + onCompleteNow, + onSkip, + onClose, +}: AdvisoryPreemptModalProps) { + const formatted = formatEffectiveDate(effectiveDate) + return ( + + ) +} diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dc82087ed..4ead262e3 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -49,9 +49,11 @@ import { isPerkReward as isPerkRewardTransaction, isRequestEntry, isSendLinkEntry, + isSplittable, usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' +import { buildSplitBillRequestUrl } from './splitBill.utils' import { CardPaymentRows } from './provider-rows/CardPaymentRows' import { LocalRailNudge } from './provider-rows/LocalRailNudge' import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' @@ -316,7 +318,10 @@ export const TransactionDetailsReceipt = ({ isAvatarClickable={isAvatarClickable} showProgessBar={transaction.isRequestPotLink} goal={Number(transaction.amount)} - progress={Number(formattedTotalAmountCollected)} + // Use the raw numeric field, NOT formattedTotalAmountCollected — the + // latter is comma-grouped ("1,234.56"), so Number() → NaN for any pot + // that has collected ≥ $1,000, blanking the progress bar. + progress={Number(transaction.totalAmountCollected)} isRequestPotTransaction={transaction.isRequestPotLink} isTransactionClosed={transaction.status === 'closed'} convertedAmount={convertedAmount ?? undefined} @@ -739,11 +744,9 @@ export const TransactionDetailsReceipt = ({
)} - {!isPublic && isQRPayment && transaction.status !== 'refunded' && ( + {!isPublic && isSplittable(transaction) && (