diff --git a/public/badges/offramp_user.png b/public/badges/offramp_user.png new file mode 100644 index 000000000..4b69a14e5 Binary files /dev/null and b/public/badges/offramp_user.png differ diff --git a/src/app/(mobile-ui)/dev/card-session-approve/page.tsx b/src/app/(mobile-ui)/dev/card-session-approve/page.tsx index 34306f2a6..922031122 100644 --- a/src/app/(mobile-ui)/dev/card-session-approve/page.tsx +++ b/src/app/(mobile-ui)/dev/card-session-approve/page.tsx @@ -9,6 +9,7 @@ */ import { useState } from 'react' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { Button } from '@/components/0_Bruddle/Button' @@ -18,7 +19,7 @@ export default function CardSessionApprovePage() { const { grant, isGranting } = useGrantSessionKey() const [status, setStatus] = useState('') - const card = overview?.cards?.[0] + const card = findActiveCard(overview) const handleClick = async () => { setStatus('Waiting for passkey tap…') diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a6435d34e..baa9fea45 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -1,6 +1,8 @@ 'use client' import { type FC, useEffect, useMemo, useState, useCallback } from 'react' import MantecaDepositShareDetails from '@/components/AddMoney/components/MantecaDepositShareDetails' +import MantecaPixQrDeposit from '@/components/AddMoney/components/MantecaPixQrDeposit' +import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading' import InputAmountStep from '@/components/AddMoney/components/InputAmountStep' import { useParams, useSearchParams } from 'next/navigation' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -25,11 +27,9 @@ import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import InfoCard from '@/components/Global/InfoCard' -import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config' // Step type for URL state -type MantecaStep = 'inputAmount' | 'depositDetails' +type MantecaStep = 'inputAmount' | 'depositDetails' | 'showQR' // Currency denomination type for URL state type CurrencyDenomination = 'USD' | 'ARS' | 'BRL' | 'MXN' | 'EUR' @@ -48,7 +48,7 @@ const MantecaAddMoney: FC = () => { // 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']), + step: parseAsStringEnum(['inputAmount', 'depositDetails', 'showQR']), amount: parseAsString, currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), }) @@ -57,7 +57,6 @@ const MantecaAddMoney: FC = () => { const step: MantecaStep = urlState.step ?? 'inputAmount' // Amount from URL - this is in the denomination specified by `currency` const displayedAmount = urlState.amount ?? '' - const currentDenomination = urlState.currency ?? 'USD' // Local UI state for tracking both amounts (needed for API call and validation) const [usdAmount, setUsdAmount] = useState('') @@ -73,10 +72,11 @@ const MantecaAddMoney: FC = () => { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) + // Default the input denomination to BRL for Brazil (PIX is in BRL); every other + // country keeps the USD default. + const currentDenomination: CurrencyDenomination = + urlState.currency ?? (selectedCountry?.currency === 'BRL' ? 'BRL' : 'USD') const onBack = useSafeBack(addMoneyCountryUrl(selectedCountryPath)) - // BRL-via-PIX onramp warn-only maintenance flag (see underMaintenance.config.ts). - // Brazil-scoped so the Argentina/ARS Manteca onramp is unaffected. - const showPixMaintenance = selectedCountry?.id === 'BR' && underMaintenanceConfig.pixBrazilOnrampMaintenance // The pool→full upgrade gate asks "did the user clear ID verification?", // not "do they have an enabled rail elsewhere?" — read the identity // signal directly (Sumsub-cleared the human) instead of the old @@ -194,14 +194,20 @@ const MantecaAddMoney: FC = () => { setError(depositData.error) return } - setDepositDetails(depositData.data) posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, { amount_usd: usdAmount, method_type: 'manteca', country: selectedCountryPath, }) - // Update URL state to show deposit details step - setUrlState({ step: 'depositDetails' }) + // BRL deposits carry the dynamic PIX QR in the ramp-on synthetic's + // details → show the QR step. ARS/others show deposit details. + const data = depositData.data + setDepositDetails(data) + if (selectedCountry?.currency === 'BRL') { + setUrlState({ step: 'showQR' }) + } else { + setUrlState({ step: 'depositDetails' }) + } } catch (error) { console.log(error) const errorMessage = error instanceof Error ? error.message : String(error) @@ -229,10 +235,23 @@ const MantecaAddMoney: FC = () => { if (step === 'depositDetails' && !depositDetails) { setUrlState({ step: 'inputAmount' }) } + if (step === 'showQR' && !depositDetails) { + setUrlState({ step: 'inputAmount' }) + } }, [step, depositDetails, setUrlState]) if (!selectedCountry) return null + // While the BRL PIX deposit request is in flight (the QR is being generated), + // show the branded processing screen — same as when a PIX payment is processing. + if (isCreatingDeposit && selectedCountry.currency === 'BRL') { + return ( +
+ +
+ ) + } + if (step === 'inputAmount') { return ( <> @@ -287,16 +306,6 @@ const MantecaAddMoney: FC = () => { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} onBack={onBack} - maintenanceBanner={ - showPixMaintenance ? ( - - ) : undefined - } /> ) @@ -316,6 +325,20 @@ const MantecaAddMoney: FC = () => { ) } + if (step === 'showQR') { + if (!depositDetails) { + return null + } + return ( + setUrlState({ step: 'inputAmount' })} + onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })} + /> + ) + } + return null } diff --git a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx index c50baefbf..2c9c94102 100644 --- a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx +++ b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx @@ -53,7 +53,9 @@ const MantecaDepositShareDetails = ({ return MANTECA_COUNTRIES_CONFIG[currentCountryDetails.id]?.depositAddressLabel ?? 'Deposit Address' }, [currentCountryDetails]) - const depositAddress = depositDetails.details.depositAddress + // BRL synthetics no longer carry these (QR-only) — but BRL routes to the QR + // screen, never here; the fallback just keeps the ARS/static path total. + const depositAddress = depositDetails.details.depositAddress ?? '' const shortenedAddress = depositAddress.length > 30 ? shortenStringLong(depositAddress, 10) : depositAddress const depositAlias = depositDetails.details.depositAlias const depositAmount = currencyAmount ?? depositDetails.stages['1'].thresholdAmount diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx new file mode 100644 index 000000000..254e2676e --- /dev/null +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -0,0 +1,119 @@ +'use client' + +import { type FC, useEffect, useMemo, useState } from 'react' +import NavHeader from '@/components/Global/NavHeader' +import QRCodeWrapper from '@/components/Global/QRCodeWrapper' +import CopyToClipboard from '@/components/Global/CopyToClipboard' +import { Icon } from '@/components/Global/Icons/Icon' +import { Button } from '@/components/0_Bruddle/Button' +import { type MantecaDepositResponseData } from '@/types/manteca.types' +import { useMantecaDepositPolling } from '@/components/AddMoney/hooks/useMantecaDepositPolling' +import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading' + +const MantecaPixQrDeposit: FC<{ + depositDetails: MantecaDepositResponseData + currencyAmount?: string + // Parent owns step navigation — usually setUrlState({ step: 'inputAmount' }). + onBack: () => void + // Fired once when the deposit settles (parent refreshes balance/history). + onComplete: () => void +}> = ({ depositDetails, currencyAmount, onBack, onComplete }) => { + // The dynamic PIX QR (EMVCo copia-e-cola) rides in the ramp-on synthetic's + // details.depositAddresses.PIX (confirmed against prod 2026-07-02). + const qr = depositDetails.details.depositAddresses?.PIX?.code + // Poll by the real synthetic id (unchanged polling contract). + const { status } = useMantecaDepositPolling(depositDetails.id, onComplete) + + // QR expiry countdown. `priceExpireAt` carries a tz offset, so Date parses it + // directly. We tick once a second and stop once the QR is paid or has lapsed + // (the effect re-runs when isExpired flips and clears the interval). + const expiresAtMs = useMemo( + () => new Date(depositDetails.details.priceExpireAt).getTime(), + [depositDetails.details.priceExpireAt] + ) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const remainingMs = expiresAtMs - nowMs + const isExpired = remainingMs <= 0 + const minutes = Math.floor(remainingMs / 60000) + const seconds = Math.floor((remainingMs % 60000) / 1000) + const countdownLabel = isExpired ? null : `${minutes}:${String(seconds).padStart(2, '0')}` + + useEffect(() => { + if (status === 'completed' || status === 'processing' || isExpired) return + const interval = setInterval(() => setNowMs(Date.now()), 1000) + return () => clearInterval(interval) + }, [status, isExpired]) + + if (status === 'completed') { + return ( +
+ +
+
+ +
+

Deposit received!

+

Your balance has been updated.

+ +
+
+ ) + } + + // Payment detected, settling — show the branded processing screen (same as PIX payments). + if (status === 'processing') { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ +
+
+

Pay with PIX

+ {currencyAmount &&

R$ {currencyAmount}

} +
+ + {!qr ? ( + + ) : ( + <> + + + {countdownLabel && ( +

Expires in {countdownLabel}

+ )} + + {isExpired ? ( +
+

This QR code has expired.

+ +
+ ) : ( +
+

+ Scan with your bank app, or copy the PIX code. +

+ +
+ )} + + )} +
+
+ ) +} + +export default MantecaPixQrDeposit diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx new file mode 100644 index 000000000..74ba1a3fb --- /dev/null +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -0,0 +1,98 @@ +/** + * MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen. + * + * One `details.depositAddresses.PIX.code` string (EMVCo copia-e-cola) drives + * both the QR and the copy button; a live countdown is derived from + * `details.priceExpireAt`; polling flips the screen to a success state. Nested + * primitives are stubbed so only this component's own logic is under test. + */ +import React from 'react' +import { render, screen } from '@testing-library/react' + +const mockUseMantecaDepositPolling = jest.fn() +jest.mock('@/components/AddMoney/hooks/useMantecaDepositPolling', () => ({ + useMantecaDepositPolling: (...args: unknown[]) => mockUseMantecaDepositPolling(...args), +})) + +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/QRCodeWrapper', () => ({ + __esModule: true, + default: ({ url, disabled }: { url: string; disabled?: boolean }) => ( +
+ ), +})) +jest.mock('@/components/Global/CopyToClipboard', () => ({ + __esModule: true, + default: ({ textToCopy }: { textToCopy: string }) =>
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () =>
})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import MantecaPixQrDeposit from '../MantecaPixQrDeposit' + +const PIX_CODE = '00020126-COPIA-E-COLA' +const baseDeposit = { + id: 'syn-1', + type: 'RAMP_OPERATION' as const, + details: { + // prod shape confirmed 2026-07-02 — the QR rides in depositAddresses.PIX + depositAddresses: { + PIX: { + type: 'QR', + code: PIX_CODE, + url: `https://widget.manteca.dev/qr?code=${PIX_CODE}`, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(), // ~3 days out + bankId: 'bank-1', + }, + }, + priceExpireAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out + }, + stages: {}, +} as unknown as import('@/types/manteca.types').MantecaDepositResponseData + +beforeEach(() => { + mockUseMantecaDepositPolling.mockReset() + mockUseMantecaDepositPolling.mockReturnValue({ status: 'pending' }) +}) + +describe('MantecaPixQrDeposit', () => { + it('renders the QR + copy from the same PIX copia-e-cola code, the amount, and a live countdown', () => { + render( + + ) + expect(screen.getByTestId('qr')).toHaveAttribute('data-url', PIX_CODE) + expect(screen.getByTestId('copy')).toHaveAttribute('data-text', PIX_CODE) + expect(screen.getByText('R$ 10')).toBeInTheDocument() + expect(screen.getByText(/Expires in/)).toBeInTheDocument() + }) + + it('shows the expired state (QR disabled, no countdown) once priceExpireAt has passed', () => { + const expired = { + ...baseDeposit, + details: { ...baseDeposit.details, priceExpireAt: new Date(Date.now() - 1000).toISOString() }, + } + render() + + expect(screen.getByText(/expired/i)).toBeInTheDocument() + expect(screen.getByTestId('qr')).toHaveAttribute('data-disabled', 'true') + expect(screen.queryByText(/Expires in/)).not.toBeInTheDocument() + }) + + it('shows the success state when the deposit completes', () => { + mockUseMantecaDepositPolling.mockReturnValue({ status: 'completed' }) + render() + + expect(screen.getByText('Deposit received!')).toBeInTheDocument() + expect(screen.queryByTestId('qr')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx new file mode 100644 index 000000000..f6ff2041d --- /dev/null +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -0,0 +1,87 @@ +/** + * useMantecaDepositPolling — maps the BE intent status to a poll status and + * fires onComplete exactly once on COMPLETED. Read-only: it never moves money, + * it mirrors GET /manteca/deposit/:id/status so the QR screen can advance. + */ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' + +const mockGetDepositStatus = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { getDepositStatus: mockGetDepositStatus }, +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import { useMantecaDepositPolling } from '../useMantecaDepositPolling' + +describe('useMantecaDepositPolling', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mockGetDepositStatus.mockReset() + }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + it('reports "pending" for a non-terminal status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-1', jest.fn()), { wrapper }) + + await waitFor(() => expect(mockGetDepositStatus).toHaveBeenCalledWith('dep-1')) + expect(result.current.status).toBe('pending') + }) + + it('reports "completed" and fires onComplete exactly once on COMPLETED', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-2', status: 'COMPLETED' } }) + const onComplete = jest.fn() + const { result, rerender } = renderHook(() => useMantecaDepositPolling('dep-2', onComplete), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('completed')) + rerender() + rerender() + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('reports "failed" for a terminal failure status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-3', status: 'CANCELLED' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-3', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('failed')) + }) + + it('keeps "pending" on PROCESSING at stage 1 — QR live, user has NOT paid (the vanished-QR bug)', async () => { + // Manteca flips the synthetic ACTIVE (→ intent PROCESSING) seconds after + // creation; treating that as "settling" hid the QR before anyone could pay. + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-4', status: 'PROCESSING', stage: 1 } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-4', jest.fn()), { wrapper }) + + await waitFor(() => expect(mockGetDepositStatus).toHaveBeenCalledWith('dep-4')) + expect(result.current.status).toBe('pending') + }) + + it('reports "processing" only once stage >= 2 (fiat received)', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-5', status: 'PROCESSING', stage: 2 } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-5', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('processing')) + }) + + it('degrades to "pending" when stage is absent (older BE / legacy row)', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-6', status: 'PROCESSING', stage: null } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-6', jest.fn()), { wrapper }) + + await waitFor(() => expect(mockGetDepositStatus).toHaveBeenCalledWith('dep-6')) + expect(result.current.status).toBe('pending') + }) + + it('does not query when depositId is undefined', () => { + const { result } = renderHook(() => useMantecaDepositPolling(undefined, jest.fn()), { wrapper }) + + expect(result.current.status).toBe('pending') + expect(mockGetDepositStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AddMoney/hooks/useMantecaDepositPolling.ts b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts new file mode 100644 index 000000000..ae9766b3d --- /dev/null +++ b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts @@ -0,0 +1,57 @@ +'use client' + +import { mantecaApi } from '@/services/manteca' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useRef } from 'react' + +const POLLING_INTERVAL = 5000 + +// Terminal TransactionIntentStatus values returned by GET /manteca/deposit/:id/status. +const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELLED', 'REFUNDED'] +// "Payment detected" is signalled by the synthetic's stage, NOT the intent status: +// Manteca flips a ramp-on to ACTIVE (→ intent PROCESSING) seconds after creation, +// while the user hasn't paid — stage 1 is DEPOSIT (QR live, awaiting fiat), and +// only stage >= 2 (ORDER/WITHDRAW) means the fiat actually arrived. +const PAID_STAGE = 2 + +type MantecaDepositPollStatus = 'pending' | 'processing' | 'completed' | 'failed' + +/** + * Poll a BRL PIX deposit intent until it settles. Read-only: the webhook/poller + * post the actual credit — this just mirrors `intent.status` so the QR screen can + * advance to a success state. Fires `onComplete` exactly once on COMPLETED. + */ +export function useMantecaDepositPolling(depositId: string | undefined, onComplete: () => void) { + const hasCompleted = useRef(false) + + const { data } = useQuery({ + queryKey: ['manteca-deposit-status', depositId], + queryFn: () => mantecaApi.getDepositStatus(depositId!), + enabled: !!depositId, + gcTime: 0, // don't carry a settled status across navigations + refetchInterval: (query) => { + const status = (query.state.data as { data?: { status?: string } } | undefined)?.data?.status + return status && TERMINAL_STATUSES.includes(status) ? false : POLLING_INTERVAL + }, + }) + + const status: MantecaDepositPollStatus = useMemo(() => { + const s = data?.data?.status + const stage = data?.data?.stage + if (s === 'COMPLETED') return 'completed' + if (s && TERMINAL_STATUSES.includes(s)) return 'failed' + // stage may be null/absent (older BE, legacy row) — then stay 'pending' + // so the QR keeps showing rather than stranding the user on a loader. + if (typeof stage === 'number' && stage >= PAID_STAGE) return 'processing' + return 'pending' + }, [data]) + + useEffect(() => { + if (status === 'completed' && !hasCompleted.current) { + hasCompleted.current = true + onComplete() + } + }, [status, onComplete]) + + return { status } +} diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index e6329cfab..0a1737561 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -238,18 +238,24 @@ describe('AddWithdrawCountriesList — bank gate', () => { }) /** - * BRL-via-PIX onramp is unstable, so the Pix option is flagged "under maintenance" - * (config: pixBrazilOnrampMaintenance) — warn-only: it stays visible and clickable. + * When the BRL-via-PIX onramp degrades, the Pix option gets flagged "under + * maintenance" (config: pixBrazilOnrampMaintenance) — warn-only: it stays + * visible and clickable. */ describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { + // snapshot/restore the shipped flag so each test can flip it without leaking + // state — and without coupling the restore to the committed default + let originalPixMaintenance: boolean + beforeEach(() => { mockPush.mockClear() // a ready gate so a click can navigate — proving the option is not blocked setCapabilities('ready', [{ status: 'enabled', channel: 'bank', country: 'US' }]) + originalPixMaintenance = underMaintenanceConfig.pixBrazilOnrampMaintenance }) afterEach(() => { - underMaintenanceConfig.pixBrazilOnrampMaintenance = true + underMaintenanceConfig.pixBrazilOnrampMaintenance = originalPixMaintenance }) it('tags the Pix option "Maintenance" but keeps it clickable (warn-only)', () => { diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 863b92917..3d5905471 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -207,6 +207,10 @@ export const BADGES: Record = { path: '/badges/touched_grass.svg', description: 'You logged off and touched real grass with Peanut.', }, + OFFRAMP_USER: { + path: '/badges/offramp_user.png', + description: 'You migrated to Peanut. We welcomed you.', + }, PSYOPS_DIVISION: { path: '/badges/psyops_division.svg', description: 'Enlisted in the Psyops Division. Welcome to the influence game.', diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx index a7af0ca91..a21732554 100644 --- a/src/components/Global/FAQs/index.tsx +++ b/src/components/Global/FAQs/index.tsx @@ -1,11 +1,15 @@ 'use client' +import type { ReactNode } from 'react' + export type FAQsProps = { heading: string questions: Array<{ id: string question: string answer: string + /** Rich JSX answer body — rendered instead of `answer`, which still feeds SEO schemas */ + answerContent?: ReactNode redirectUrl?: string redirectText?: string calModal?: boolean @@ -54,7 +58,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) {
-

{linkifyText(faq.answer)}

+ {faq.answerContent ??

{linkifyText(faq.answer)}

} {faq.calModal && ( { const [qrRendered, setQrRendered] = useState(false) @@ -39,7 +42,7 @@ const QRCodeWrapper = ({ const showLoading = isLoading || !qrRendered || !url return ( -
+
{/* Container with black border and rounded corners */}
(null) + // Card id the last SUCCESSFUL grant (with a fresh overview) was tapped + // for — keyed by identity so a later re-issued card never inherits the + // stuck signal from an old card's grant. + const [grantSucceededFor, setGrantSucceededFor] = useState(null) + // Card id of the last grant attempt that RESOLVED (ok or failed). Gates + // `lastError` below: the hook's error state isn't card-scoped, so without + // this a failure on card A would leak "Try again" copy and the escape + // hatch into a re-issued card B's first-ever prompt. + const [lastAttemptFor, setLastAttemptFor] = useState(null) - const card = overview?.cards?.[0] + const card = findActiveCard(overview) const shouldShow = card?.status === 'ACTIVE' && !card.hasWithdrawApproval && !!overview?.status?.contractAddress && !!overview?.status?.coordinatorAddress + // Only honor the hook's error if the attempt it came from was for THIS + // card. `lastAttemptFor === null` (error with no recorded attempt) can't + // occur in real flows but defaults to honoring the error — an unearned + // escape beats an unearned trap. + const errorForThisCard = !!lastError && (lastAttemptFor === null || lastAttemptFor === card?.id) + // `user-cancelled` just means the passkey sheet was dismissed — not a real // error, the user simply taps Continue again. Any other failure gets a // recoverable message. - const hardError = !!lastError && lastError.kind !== 'user-cancelled' + const hardError = errorForThisCard && lastError!.kind !== 'user-cancelled' + + // Loop signal: grant() resolved ok with a FRESH overview, yet the SAME + // card still lacks the approval. That is the dup-card lockout shape — + // warn once per card and treat it like a failure so the escape hatch + // renders with an explanation. + const stuckAfterSuccess = !!card && grantSucceededFor !== null && grantSucceededFor === card.id && shouldShow + // Set of card ids already warned for — a plain "last id" ref would + // re-page Sentry when the active card alternates (A → B → A) during + // remediation. + const warnedCardsRef = useRef>(new Set()) + useEffect(() => { + if (stuckAfterSuccess && card && !warnedCardsRef.current.has(card.id)) { + warnedCardsRef.current.add(card.id) + console.warn( + '[EnableAutoBalanceBanner] grant succeeded but the active card still lacks hasWithdrawApproval — duplicate-card lockout shape' + ) + captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { + level: 'error', + extra: { cardId: card.id }, + }) + } + }, [stuckAfterSuccess, card]) const ctas: ActionModalButtonProps[] = [ { - text: isGranting ? 'Working…' : hardError ? 'Try again' : 'Continue', + text: isGranting ? 'Working…' : hardError || stuckAfterSuccess ? 'Try again' : 'Continue', variant: 'purple', shadowSize: '4', disabled: isGranting, onClick: () => { - void grant() + const grantedCardId = card?.id ?? null + void grant().then((result) => { + setLastAttemptFor(grantedCardId) + // A failed refetch means the flag is merely STALE, not + // stuck — treating it as success would fire a false + // Sentry page on any flaky connection. + if (result.ok && result.overviewFresh) setGrantSucceededFor(grantedCardId) + }) }, }, ] - // Escape hatch, shown only once a grant has failed, so the user is never - // trapped behind this non-dismissible modal. - if (lastError) { + // Escape hatch, shown once a grant has failed — or "succeeded" without + // clearing the modal — so the user is never trapped behind this + // non-dismissible modal. + if (errorForThisCard || stuckAfterSuccess) { ctas.push({ text: 'Skip for now', variant: 'stroke', disabled: isGranting, - onClick: () => setDismissed(true), + onClick: () => setDismissedFor(card?.id ?? null), }) } + const dismissed = dismissedFor !== null && dismissedFor === (card?.id ?? null) + return ( , []>() let mockLastError: GrantSessionKeyError | null = null jest.mock('@/hooks/wallet/useGrantSessionKey', () => ({ useGrantSessionKey: () => ({ grant: mockGrant, isGranting: false, lastError: mockLastError }), })) +type MockCard = { id?: string; status: string; hasWithdrawApproval: boolean } +let mockCards: MockCard[] = [] jest.mock('@/hooks/useRainCardOverview', () => ({ useRainCardOverview: () => ({ overview: { - cards: [{ status: 'ACTIVE', hasWithdrawApproval: false }], + cards: mockCards, status: { contractAddress: '0xabc', coordinatorAddress: '0xdef' }, }, }), })) +jest.mock('@sentry/nextjs', () => ({ + captureMessage: jest.fn(), +})) +import * as Sentry from '@sentry/nextjs' + jest.mock('@/components/Global/ActionModal', () => ({ __esModule: true, default: (props: { visible: boolean; description?: string; ctas?: { text: string; onClick: () => void }[] }) => @@ -45,6 +60,8 @@ import EnableAutoBalanceBanner from '../EnableAutoBalanceBanner' beforeEach(() => { jest.clearAllMocks() mockLastError = null + mockGrant.mockResolvedValue({ ok: false }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] }) describe('EnableAutoBalanceBanner', () => { @@ -69,4 +86,142 @@ describe('EnableAutoBalanceBanner', () => { expect(screen.getByText(/couldn't finish setting up your card/i)).toBeInTheDocument() expect(screen.getByText('Try again')).toBeInTheDocument() }) + + it('keys off the ACTIVE card, not cards[0] — a CANCELED newest row with a granted older card hides the modal', () => { + // The post-remediation nicnode shape: duplicate canceled, real card granted. + mockCards = [ + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', status: 'ACTIVE', hasWithdrawApproval: true }, + ] + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('still prompts when the ACTIVE card lacks the grant even behind a CANCELED newest row', () => { + mockCards = [ + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + render() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('a grant that "succeeds" without clearing the modal reveals the escape and pages Sentry (dup-card lockout shape)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockGrant.mockResolvedValue({ ok: true, overviewFresh: true }) + // Overview never flips hasWithdrawApproval — the lockout shape. + render() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + expect(screen.getByText('Skip for now')).toBeInTheDocument() + // The stuck state must EXPLAIN itself — happy-path copy with an + // unexplained Skip button just makes users re-tap Continue forever. + expect(screen.getByText(/couldn't finish setting up your card/i)).toBeInTheDocument() + expect(screen.getByText('Try again')).toBeInTheDocument() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('duplicate-card lockout shape')) + expect(Sentry.captureMessage).toHaveBeenCalledWith( + expect.stringContaining('hasWithdrawApproval never flipped'), + expect.objectContaining({ level: 'error' }) + ) + warnSpy.mockRestore() + }) + + it('a grant whose overview refetch FAILED is stale, not stuck — no escape, no Sentry page', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + // Grant succeeded but the follow-up refetch died (flaky network): + // the cached flag is stale; treating it as the lockout would false-page. + mockGrant.mockResolvedValue({ ok: true, overviewFresh: false }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + expect(Sentry.captureMessage).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('skipping a stuck card does NOT suppress the prompt for a different card later in the session', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockGrant.mockResolvedValue({ ok: true, overviewFresh: true }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + + // Card A gets stuck; user escapes via Skip. + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + fireEvent.click(screen.getByText('Skip for now')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Support cancels A; ungranted card B becomes active — it must prompt. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + expect(screen.getByTestId('modal')).toBeInTheDocument() + warnSpy.mockRestore() + }) + + it('a re-issued card does NOT inherit the stuck signal from an old card grant (no premature escape, no false Sentry page)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + // Model the REAL happy path: grant() refetches the overview before + // resolving, so by the time it returns ok the flag is already flipped. + mockGrant.mockImplementation(async () => { + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: true }] + return { ok: true, overviewFresh: true } + }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + + // Grant succeeds for card A and the flag flips — modal hides, all good. + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + rerender() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Card A is replaced by card B, which legitimately needs its own setup: + // fresh prompt, NO escape, NO Sentry noise. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: true }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + // findActiveCard skips CANCELED → card-b drives the modal + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + expect(Sentry.captureMessage).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + it("card A's failure does not leak error copy or the escape into a re-issued card B's first prompt", async () => { + // Grant fails hard for card A → error copy + escape for A. + mockGrant.mockResolvedValue({ ok: false }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + mockLastError = { kind: 'unexpected', message: 'boom' } + rerender() + expect(screen.getByText('Try again')).toBeInTheDocument() + expect(screen.getByText('Skip for now')).toBeInTheDocument() + + // A is canceled, B issued — the hook's lastError is still set, but B + // has never been attempted: fresh Continue, no error copy, no escape. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + expect(screen.getByText('Continue')).toBeInTheDocument() + expect(screen.queryByText('Try again')).not.toBeInTheDocument() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + }) }) diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index 26ba73164..934e3cc3a 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -12,6 +12,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = { founderhaus: 'FOUNDER_HOUSE', alumni: 'EVENT_ALUMNI', touched_grass: 'TOUCHED_GRASS', + offramp: 'OFFRAMP_USER', irl_nomads: 'IRL_NOMADS', survivor: 'SUPPORT_SURVIVOR', notsoshhh: 'NOT_SO_SHHHH', @@ -31,6 +32,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', alumni: 'EVENT_ALUMNI', 'touched-grass': 'TOUCHED_GRASS', + offramp: 'OFFRAMP_USER', 'festa-junina': 'FESTA_JUNINA_2026', 'card-alpha': 'CARD_ALPHA', 'irl-nomads': 'IRL_NOMADS', @@ -53,6 +55,7 @@ export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([ 'card_alpha', 'festa_junina_2026', 'irl_nomads', + 'offramp_user', ]) export type CampaignClassification = { diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index cc3c5a9bd..bb05397b3 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -1,8 +1,10 @@ 'use client' import { useFooterVisibility } from '@/context/footerVisibility' -import { Suspense, useEffect, useState, useRef, useCallback, type ReactNode } from 'react' +import { Suspense, useEffect, useMemo, useState, useRef, useCallback, type ReactNode } from 'react' import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage' +import { SupportedRailsFaqAnswer } from '@/components/LandingPage/SupportedRailsFaqAnswer' +import { SUPPORTED_RAILS_FAQ_ID } from '@/constants/faq.consts' import TweetCarousel from '@/components/LandingPage/TweetCarousel' import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA' import underMaintenanceConfig from '@/config/underMaintenance.config' @@ -51,6 +53,17 @@ export function LandingPageClient({ footerSlot, }: LandingPageClientProps) { const { isFooterVisible } = useFooterVisibility() + + // Memoized: this component re-renders per scroll frame during the button + // animation — don't rebuild the FAQ array + rich answer element each time. + const faqQuestions = useMemo( + () => + faqData.questions.map((q) => + q.id === SUPPORTED_RAILS_FAQ_ID ? { ...q, answerContent: } : q + ), + [faqData.questions] + ) + const [buttonVisible, setButtonVisible] = useState(true) const [isScrollFrozen, setIsScrollFrozen] = useState(false) const [buttonScale, setButtonScale] = useState(1) @@ -215,7 +228,7 @@ export function LandingPageClient({ - + {footerSlot} diff --git a/src/components/LandingPage/SupportedRailsFaqAnswer.tsx b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx new file mode 100644 index 000000000..2777fbcef --- /dev/null +++ b/src/components/LandingPage/SupportedRailsFaqAnswer.tsx @@ -0,0 +1,56 @@ +'use client' + +import ChainChip from '@/components/AddMoney/components/ChainChip' +import { CHAIN_LOGOS, OTHER_SUPPORTED_CHAINS, SUPPORTED_EVM_CHAINS, getSupportedTokens } from '@/constants/rhino.consts' +import { FIAT_RAILS } from '@/constants/faq.consts' +import { chainDisplayName } from '@/utils/chain-display.utils' + +/** + * Rich answer body for the "which networks, tokens and banks?" landing FAQ item. + * Renders from the same rhino.consts constants as the add-money Choose Network + * drawer (and FIAT_RAILS shared with the plain-text SEO answer), so the FAQ + * always advertises exactly what the app supports. + */ +export function SupportedRailsFaqAnswer() { + return ( +
+
+

+ Crypto — one deposit address for all {SUPPORTED_EVM_CHAINS.length} EVM networks, plus{' '} + {OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(' and ')}: +

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

Tokens:

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

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

+
+
+

Banks & local payment apps:

+
    + {FIAT_RAILS.map((rail) => ( +
  • + {rail.flag} + {rail.name} + + {rail.currency} · {rail.region} + +
  • + ))} +
+
+

Deposits are free — Peanut covers the gas.

+
+ ) +} diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index eb0b096b5..317c28652 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -36,9 +36,8 @@ * * 7. pixBrazilOnrampMaintenance: warn-only flag for the BRL-via-PIX onramp (Manteca Brazil deposit) * - shows a "Maintenance" tag on the Pix option in /add-money/brazil - * - shows a warning banner inside the deposit flow (/add-money/brazil/manteca) * - does NOT block deposits — the option stays usable (warn-only) - * - set to false when PIX deposits are stable again + * - set to true if the PIX onramp degrades again * * 8. disableCardLaunchCTA: kill-switch for the in-app "shhh" card CTA (the home nudge) * - true hides BOTH the activation-funnel card step and the activated-base home splash @@ -84,7 +83,7 @@ const underMaintenanceConfig: MaintenanceConfig = { disableXchainSend: true, // 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 disableCardLaunchCTA: false, // kill-switch for the in-app "shhh" card CTA (funnel card step + activated home splash). Set true to mute it (dial down in-app load); /card flow + /shhhhh + waitlist stay reachable regardless. - pixBrazilOnrampMaintenance: true, // set to false when BRL-via-PIX deposits are stable again + pixBrazilOnrampMaintenance: false, // BRL deposits restored via dynamic PIX QR (2026-07-02). Set true if the onramp degrades again. disabledMantecaCurrencies: [], // Manteca restored (ARS + BRL live). Add a currency here to block it during a future outage. } @@ -92,13 +91,10 @@ const underMaintenanceConfig: MaintenanceConfig = { export const CROSS_CHAIN_DISABLED_MESSAGE = 'Cross-chain claims are temporarily unavailable. Try claiming to an external wallet on the same chain as the link, or try again later.' -// shared user-facing copy for the BRL-via-PIX onramp maintenance warning — keep the list tag and -// the in-flow banner aligned +// user-facing copy for the BRL-via-PIX onramp maintenance tag (the in-flow banner was +// retired when the dynamic-QR deposit flow shipped — the tag is the remaining surface) export const PIX_BRAZIL_ONRAMP_MAINTENANCE = { badge: 'Maintenance', - title: 'PIX deposits are under maintenance', - description: - 'PIX deposits are currently unstable and may be delayed or fail. You can still continue, but service may be unreliable until this is resolved.', } export default underMaintenanceConfig diff --git a/src/constants/faq.consts.ts b/src/constants/faq.consts.ts new file mode 100644 index 000000000..327cd1c93 --- /dev/null +++ b/src/constants/faq.consts.ts @@ -0,0 +1,32 @@ +import { OTHER_SUPPORTED_CHAINS, SUPPORTED_EVM_CHAINS } from '@/constants/rhino.consts' +import { chainDisplayName } from '@/utils/chain-display.utils' + +/** + * The "which networks, tokens and banks?" landing FAQ item. Question, fiat-rail + * facts and plain-text answer live here (server-safe, feeds the FAQPage JSON-LD + * via getLandingContent); the rich chip UI is rendered client-side by + * SupportedRailsFaqAnswer, matched by id. Chain names derive from rhino.consts + * and the visible rails render from FIAT_RAILS, so the public answer and the + * on-page UI share one source and can't drift from what the app supports. + */ +export const SUPPORTED_RAILS_FAQ_ID = 'supported-rails' + +export const FIAT_RAILS = [ + { flag: '🇺🇸', name: 'ACH & Wire', currency: 'USD', region: 'United States' }, + { flag: '🇪🇺', name: 'SEPA', currency: 'EUR', region: '36 countries' }, + { flag: '🇬🇧', name: 'Faster Payments', currency: 'GBP', region: 'United Kingdom' }, + { flag: '🇲🇽', name: 'SPEI', currency: 'MXN', region: 'Mexico' }, + { flag: '🇦🇷', name: 'Mercado Pago', currency: 'ARS', region: 'Argentina' }, + { flag: '🇧🇷', name: 'Pix', currency: 'BRL', region: 'Brazil' }, +] as const + +const EVM_CHAIN_LIST = SUPPORTED_EVM_CHAINS.map(chainDisplayName).join(', ') +const OTHER_CHAIN_LIST = OTHER_SUPPORTED_CHAINS.map(chainDisplayName).join(' and ') +const FIAT_RAIL_LIST = FIAT_RAILS.map((rail) => `${rail.name} (${rail.currency}, ${rail.region})`).join(', ') + +export const SUPPORTED_RAILS_FAQ_QUESTION = 'Which networks, tokens and banks does Peanut support?' + +export const SUPPORTED_RAILS_FAQ_ANSWER = + `Crypto: deposit and withdraw USDC and USDT on ${SUPPORTED_EVM_CHAINS.length} EVM networks with a single address (${EVM_CHAIN_LIST}), plus ${OTHER_CHAIN_LIST}. ETH is also supported on EVM networks; Tron is USDT-only. ` + + `Banks & local payment apps: ${FIAT_RAIL_LIST}. ` + + 'Deposits are free — Peanut covers the gas.' diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index a5eb330f2..987f0e5c3 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -6,6 +6,7 @@ import { pad, parseAbi, toFunctionSelector } from 'viem' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useKernelClient } from '@/context/kernelClient.context' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useQueryClient } from '@tanstack/react-query' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' @@ -66,8 +67,12 @@ export type GrantSessionKeyError = export interface GrantSessionKeyResult { /** Full grant: passkey tap + POST to `/session-approve`. Requires an - * active card; use for the lazy "first collateral spend" flow. */ - grant: () => Promise<{ ok: true } | { ok: false; error: GrantSessionKeyError }> + * active card; use for the lazy "first collateral spend" flow. + * `overviewFresh` is false when the grant itself succeeded but the + * follow-up overview refetch failed (react-query refetch resolves with + * an error state instead of throwing) — consumers must NOT read the + * still-stale `hasWithdrawApproval` as a lockout signal in that case. */ + grant: () => Promise<{ ok: true; overviewFresh: boolean } | { ok: false; error: GrantSessionKeyError }> /** Passkey tap only — returns the serialized approval string without * submitting it. Use when the card doesn't exist yet (issuance) and * another endpoint stores the string (e.g. `POST /rain/cards`). */ @@ -217,7 +222,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { const grant = useCallback(async () => { const result = await wrap(async () => { - const card = overview?.cards?.[0] + const card = findActiveCard(overview) if (!card) return { ok: false, error: { kind: 'no-card' } as const } const r = await runSerialize() @@ -230,11 +235,14 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { } // Flip the `hasWithdrawApproval` flag in UI by refetching overview. - await refetch() + // refetch() resolves (never throws) with an error state on network + // failure — surface that so the caller can tell "flag is stale" + // apart from "flag genuinely didn't flip". + const refetchResult = await refetch() queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) - return { ok: true as const } + return { ok: true as const, value: refetchResult.isSuccess } }) - if (result.ok) return { ok: true } + if (result.ok) return { ok: true, overviewFresh: result.value === true } return result }, [wrap, runSerialize, overview, refetch, queryClient]) diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index a6206642a..77cd1cf0d 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -15,6 +15,7 @@ import { rainWithdrawEip712Types, } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' @@ -162,7 +163,7 @@ export const useSignSpendBundle = () => { if (!overview) { throw new SessionKeyGrantRequiredError({ kind: 'unexpected' } as GrantSessionKeyError) } - const card = overview.cards?.[0] + const card = findActiveCard(overview) if (card && !card.hasWithdrawApproval) { onGrantRequired?.() const grantResult = await grant() diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index ef12a6d5c..998ab179c 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -18,6 +18,7 @@ import { } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' @@ -217,7 +218,7 @@ export const useSpendBundle = () => { // the one-time session-key grant. If missing, run the inline grant // flow now (one extra passkey tap the FIRST time, zero after). const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' - const card = overview?.cards?.[0] + const card = findActiveCard(overview) if (touchesCollateral && card && !card.hasWithdrawApproval) { onGrantRequired?.() const grantResult = await grant() diff --git a/src/lib/landingContent.ts b/src/lib/landingContent.ts index 58984f8cb..95c77a853 100644 --- a/src/lib/landingContent.ts +++ b/src/lib/landingContent.ts @@ -5,6 +5,11 @@ // component or route handler, never inside a 'use client' file. import { readSingletonContentLocalized } from '@/lib/content' +import { + SUPPORTED_RAILS_FAQ_ANSWER, + SUPPORTED_RAILS_FAQ_ID, + SUPPORTED_RAILS_FAQ_QUESTION, +} from '@/constants/faq.consts' import type { Locale } from '@/i18n/types' interface LandingFrontmatter { @@ -40,7 +45,30 @@ const DEFAULTS: LandingContent = { marqueeMessages: [], } +// Code-defined FAQ item advertising supported networks/tokens/bank rails. +// Lives in code (not the content MD) because its facts derive from +// rhino.consts — the same constants the add-money flow renders — so the +// public answer can't drift from what the app actually supports. +// LandingPageClient swaps in the rich chip UI by this id. +const SUPPORTED_RAILS_QUESTION = { + id: SUPPORTED_RAILS_FAQ_ID, + question: SUPPORTED_RAILS_FAQ_QUESTION, + answer: SUPPORTED_RAILS_FAQ_ANSWER, +} + +// Insert right after the "What is Peanut?" question (falls back to the end). +function withSupportedRails(questions: LandingContent['faqData']['questions']) { + const idx = questions.findIndex((q) => /what is peanut\??/i.test(q.question)) + const at = idx === -1 ? questions.length : idx + 1 + return [...questions.slice(0, at), SUPPORTED_RAILS_QUESTION, ...questions.slice(at)] +} + export function getLandingContent(locale: Locale = 'en'): LandingContent { + const base = readLandingContent(locale) + return { ...base, faqData: { ...base.faqData, questions: withSupportedRails(base.faqData.questions) } } +} + +function readLandingContent(locale: Locale): LandingContent { const content = readSingletonContentLocalized('landing', locale) if (!content) return DEFAULTS diff --git a/src/services/manteca.ts b/src/services/manteca.ts index 4e7db4fd5..d70742fb7 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -287,6 +287,22 @@ export const mantecaApi = { } }, + getDepositStatus: async ( + depositId: string + // stage: 1 = QR live awaiting fiat, >= 2 = fiat received (settling), null = no snapshot + ): Promise<{ data?: { id: string; status: string; stage?: number | null }; error?: string }> => { + try { + const response = await serverFetch(`/manteca/deposit/${depositId}/status`) + const data = await response.json() + if (!response.ok) { + return { error: data.error || 'Failed to fetch deposit status.' } + } + return { data } + } catch (error) { + return { error: error instanceof Error ? error.message : 'An unexpected error occurred.' } + } + }, + cancelDeposit: async (depositId: string): Promise<{ data?: MantecaDepositResponseData; error?: string }> => { try { const response = await serverFetch(`/manteca/deposit/${depositId}/cancel`, { diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts index a25c54834..b354039a6 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -11,10 +11,23 @@ export interface MantecaDepositResponseData { type: 'RAMP_OPERATION' details: { depositAddresses: { - BANK_TRANSFER: string + BANK_TRANSFER?: string + // dynamic BRL PIX QR — confirmed against prod 2026-07-02 + // (mono ops/scripts/manteca/probe-brl-rampon-qr.ts) + PIX?: { + type: 'QR' + // EMVCo "copia e cola" string — QR-encode it and offer copy + code: string + // Manteca-hosted QR page (unused by us, kept for parity) + url: string + // QR string validity (~3 days) — NOT the price lock expiry + expiresAt: string + bankId: string + } } - depositAddress: string - depositAlias: string + // absent for BRL — PIX deposits are QR-only since Manteca's med-2.0 switch + depositAddress?: string + depositAlias?: string withdrawCostInAgainst: string withdrawCostInAsset: string price: string diff --git a/src/utils/chain-display.utils.ts b/src/utils/chain-display.utils.ts new file mode 100644 index 000000000..fe9bce425 --- /dev/null +++ b/src/utils/chain-display.utils.ts @@ -0,0 +1,8 @@ +// Display labels where plain title-case reads wrong. +const CHAIN_DISPLAY_OVERRIDES: Record = { + BNB: 'BNB Chain', +} + +/** rhino.consts chain key (e.g. 'ARBITRUM', 'BNB') → human display name */ +export const chainDisplayName = (chain: string): string => + CHAIN_DISPLAY_OVERRIDES[chain] ?? chain.charAt(0) + chain.slice(1).toLowerCase()