diff --git a/.gitmodules b/.gitmodules index 2a901695e..0a48c0bc9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "src/assets/animations"] - path = src/assets/animations - url = https://github.com/peanutprotocol/peanut-animations.git [submodule "src/content"] path = src/content url = https://github.com/peanutprotocol/peanut-content.git diff --git a/package.json b/package.json index f1bf62ad8..fe83fa310 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "node_modules/(?!(@wagmi|wagmi|viem|@viem|@walletconnect|@justaname\\.id|@zerodev|permissionless)/)" ], "moduleNameMapper": { - "\\.(svg|png|jpg|jpeg|gif)$": "jest-transform-stub", + "\\.(svg|png|jpg|jpeg|gif|webp)$": "jest-transform-stub", "^@/config/wagmi\\.config$": "/src/utils/__mocks__/wagmi-config.ts", "^wagmi/chains$": "/src/utils/__mocks__/wagmi.ts", "^@justaname\\.id/react$": "/src/utils/__mocks__/justaname.ts", diff --git a/public/badges/_archive/founder_house.png b/public/badges/_archive/founder_house.png deleted file mode 100644 index 0bf3fb33d..000000000 Binary files a/public/badges/_archive/founder_house.png and /dev/null differ diff --git a/public/badges/peanut-pioneer.png b/public/badges/peanut-pioneer.png index 2848cc77b..d355bce29 100644 Binary files a/public/badges/peanut-pioneer.png and b/public/badges/peanut-pioneer.png differ diff --git a/public/claim-metadata-img.jpg b/public/claim-metadata-img.jpg deleted file mode 100644 index b99bd85e5..000000000 Binary files a/public/claim-metadata-img.jpg and /dev/null differ diff --git a/public/easter-eggs/antarctica.png b/public/easter-eggs/antarctica.png deleted file mode 100644 index ba2d514ff..000000000 Binary files a/public/easter-eggs/antarctica.png and /dev/null differ diff --git a/public/easter-eggs/antarctica.webp b/public/easter-eggs/antarctica.webp new file mode 100644 index 000000000..507df68ed Binary files /dev/null and b/public/easter-eggs/antarctica.webp differ diff --git a/public/easter-eggs/bouvet.png b/public/easter-eggs/bouvet.png deleted file mode 100644 index 9b287751e..000000000 Binary files a/public/easter-eggs/bouvet.png and /dev/null differ diff --git a/public/easter-eggs/bouvet.webp b/public/easter-eggs/bouvet.webp new file mode 100644 index 000000000..6b159ef9e Binary files /dev/null and b/public/easter-eggs/bouvet.webp differ diff --git a/public/easter-eggs/christmas.png b/public/easter-eggs/christmas.png deleted file mode 100644 index 14b5099d9..000000000 Binary files a/public/easter-eggs/christmas.png and /dev/null differ diff --git a/public/easter-eggs/christmas.webp b/public/easter-eggs/christmas.webp new file mode 100644 index 000000000..c1ebce118 Binary files /dev/null and b/public/easter-eggs/christmas.webp differ diff --git a/public/easter-eggs/cocos.png b/public/easter-eggs/cocos.png deleted file mode 100644 index 0b8ed0a64..000000000 Binary files a/public/easter-eggs/cocos.png and /dev/null differ diff --git a/public/easter-eggs/cocos.webp b/public/easter-eggs/cocos.webp new file mode 100644 index 000000000..51cb01d38 Binary files /dev/null and b/public/easter-eggs/cocos.webp differ diff --git a/public/easter-eggs/heard.png b/public/easter-eggs/heard.png deleted file mode 100644 index 8f47c53a2..000000000 Binary files a/public/easter-eggs/heard.png and /dev/null differ diff --git a/public/easter-eggs/heard.webp b/public/easter-eggs/heard.webp new file mode 100644 index 000000000..3f5a67f0b Binary files /dev/null and b/public/easter-eggs/heard.webp differ diff --git a/public/easter-eggs/pitcairn.png b/public/easter-eggs/pitcairn.png deleted file mode 100644 index fb167f27b..000000000 Binary files a/public/easter-eggs/pitcairn.png and /dev/null differ diff --git a/public/easter-eggs/pitcairn.webp b/public/easter-eggs/pitcairn.webp new file mode 100644 index 000000000..b6818a300 Binary files /dev/null and b/public/easter-eggs/pitcairn.webp differ diff --git a/public/easter-eggs/southgeorgia.png b/public/easter-eggs/southgeorgia.png deleted file mode 100644 index 16828d0c5..000000000 Binary files a/public/easter-eggs/southgeorgia.png and /dev/null differ diff --git a/public/easter-eggs/southgeorgia.webp b/public/easter-eggs/southgeorgia.webp new file mode 100644 index 000000000..e36398d31 Binary files /dev/null and b/public/easter-eggs/southgeorgia.webp differ diff --git a/public/easter-eggs/tokelau.png b/public/easter-eggs/tokelau.png deleted file mode 100644 index c70e02405..000000000 Binary files a/public/easter-eggs/tokelau.png and /dev/null differ diff --git a/public/easter-eggs/tokelau.webp b/public/easter-eggs/tokelau.webp new file mode 100644 index 000000000..21235aeff Binary files /dev/null and b/public/easter-eggs/tokelau.webp differ diff --git a/public/email/peanut-jail.png b/public/email/peanut-jail.png index 41a0c37a7..cb3bbf746 100644 Binary files a/public/email/peanut-jail.png and b/public/email/peanut-jail.png differ diff --git a/public/email/peanut-wave.png b/public/email/peanut-wave.png index a6235cbfa..58586cdb5 100644 Binary files a/public/email/peanut-wave.png and b/public/email/peanut-wave.png differ diff --git a/public/game/1x-cloud.png b/public/game/1x-cloud.png index 9790097e7..89a9d5071 100644 Binary files a/public/game/1x-cloud.png and b/public/game/1x-cloud.png differ diff --git a/public/game/1x-horizon.png b/public/game/1x-horizon.png index cb921649e..c24e48821 100644 Binary files a/public/game/1x-horizon.png and b/public/game/1x-horizon.png differ diff --git a/public/game/1x-obstacle-large.png b/public/game/1x-obstacle-large.png index 852b3597b..e83d2feb2 100644 Binary files a/public/game/1x-obstacle-large.png and b/public/game/1x-obstacle-large.png differ diff --git a/public/game/1x-obstacle-small.png b/public/game/1x-obstacle-small.png index b264a6fca..2b2f76e80 100644 Binary files a/public/game/1x-obstacle-small.png and b/public/game/1x-obstacle-small.png differ diff --git a/public/game/1x-restart.png b/public/game/1x-restart.png index 96fe78107..ba2be42c3 100644 Binary files a/public/game/1x-restart.png and b/public/game/1x-restart.png differ diff --git a/public/game/1x-trex.png b/public/game/1x-trex.png index ff972fe31..38e5526f5 100644 Binary files a/public/game/1x-trex.png and b/public/game/1x-trex.png differ diff --git a/public/game/2x-cloud.png b/public/game/2x-cloud.png index 5760f0be2..2781e48ab 100644 Binary files a/public/game/2x-cloud.png and b/public/game/2x-cloud.png differ diff --git a/public/game/2x-horizon.png b/public/game/2x-horizon.png index a53977915..d58aa348d 100644 Binary files a/public/game/2x-horizon.png and b/public/game/2x-horizon.png differ diff --git a/public/game/2x-obstacle-large.png b/public/game/2x-obstacle-large.png index f135f5552..d43e91576 100644 Binary files a/public/game/2x-obstacle-large.png and b/public/game/2x-obstacle-large.png differ diff --git a/public/game/2x-obstacle-small.png b/public/game/2x-obstacle-small.png index 6371b249a..abababbd1 100644 Binary files a/public/game/2x-obstacle-small.png and b/public/game/2x-obstacle-small.png differ diff --git a/public/game/2x-restart.png b/public/game/2x-restart.png index 5d1d4c542..4ff2a53d5 100644 Binary files a/public/game/2x-restart.png and b/public/game/2x-restart.png differ diff --git a/public/game/2x-text.png b/public/game/2x-text.png index 3f41a68b6..f3f43a48c 100644 Binary files a/public/game/2x-text.png and b/public/game/2x-text.png differ diff --git a/public/game/2x-trex.png b/public/game/2x-trex.png index 52e83e896..900a07373 100644 Binary files a/public/game/2x-trex.png and b/public/game/2x-trex.png differ diff --git a/public/icons/apple-touch-icon-152x152-beta.png b/public/icons/apple-touch-icon-152x152-beta.png index a7e5ddd99..5d37c011a 100644 Binary files a/public/icons/apple-touch-icon-152x152-beta.png and b/public/icons/apple-touch-icon-152x152-beta.png differ diff --git a/public/icons/apple-touch-icon-beta.png b/public/icons/apple-touch-icon-beta.png index ce2262e00..d40004b3a 100644 Binary files a/public/icons/apple-touch-icon-beta.png and b/public/icons/apple-touch-icon-beta.png differ diff --git a/public/icons/icon-192x192-beta.png b/public/icons/icon-192x192-beta.png index a7e5ddd99..5d37c011a 100644 Binary files a/public/icons/icon-192x192-beta.png and b/public/icons/icon-192x192-beta.png differ diff --git a/public/icons/icon-192x192-maskable.png b/public/icons/icon-192x192-maskable.png index 3720b270a..dec989f03 100644 Binary files a/public/icons/icon-192x192-maskable.png and b/public/icons/icon-192x192-maskable.png differ diff --git a/public/icons/icon-512x512-beta.png b/public/icons/icon-512x512-beta.png index 375cdc578..84dee41b1 100644 Binary files a/public/icons/icon-512x512-beta.png and b/public/icons/icon-512x512-beta.png differ diff --git a/public/icons/icon-512x512-maskable.png b/public/icons/icon-512x512-maskable.png index 820ae691b..2f87bfcb2 100644 Binary files a/public/icons/icon-512x512-maskable.png and b/public/icons/icon-512x512-maskable.png differ diff --git a/public/logo-favicon.png b/public/logo-favicon.png index dda794488..37693677c 100644 Binary files a/public/logo-favicon.png and b/public/logo-favicon.png differ diff --git a/public/merchants/badigitalnomads/coworking.jpg b/public/merchants/badigitalnomads/coworking.jpg index 36e271d0a..336017655 100644 Binary files a/public/merchants/badigitalnomads/coworking.jpg and b/public/merchants/badigitalnomads/coworking.jpg differ diff --git a/public/merchants/stain/profile.jpg b/public/merchants/stain/profile.jpg index b3f0561f4..97a731d2d 100644 Binary files a/public/merchants/stain/profile.jpg and b/public/merchants/stain/profile.jpg differ diff --git a/public/merchants/stain/tripadvisor-2.jpg b/public/merchants/stain/tripadvisor-2.jpg index 58e3c638e..5827a44c6 100644 Binary files a/public/merchants/stain/tripadvisor-2.jpg and b/public/merchants/stain/tripadvisor-2.jpg differ diff --git a/public/metadata-img.png b/public/metadata-img.png index 664a35938..beddbcedf 100644 Binary files a/public/metadata-img.png and b/public/metadata-img.png differ diff --git a/public/preview-bg.png b/public/preview-bg.png deleted file mode 100644 index f8bfb71af..000000000 Binary files a/public/preview-bg.png and /dev/null differ diff --git a/public/raffle-metadata-img.png b/public/raffle-metadata-img.png deleted file mode 100644 index 69ea8754b..000000000 Binary files a/public/raffle-metadata-img.png and /dev/null differ diff --git a/public/redpacket-img.png b/public/redpacket-img.png deleted file mode 100644 index 91c3f5c69..000000000 Binary files a/public/redpacket-img.png and /dev/null differ diff --git a/public/social-preview-bg.png b/public/social-preview-bg.png index 2adfce109..6fdc79d41 100644 Binary files a/public/social-preview-bg.png and b/public/social-preview-bg.png differ diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 3fc6ac842..4e67893f2 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -268,8 +268,9 @@ jest.mock('@/constants/rhino.consts', () => ({ getSupportedTokens: jest.fn(() => [ { name: 'USDC', logoUrl: '/usdc.png' }, { name: 'USDT', logoUrl: '/usdt.png' }, + { name: 'ETH', logoUrl: '/eth-token.png' }, ]), - TOKEN_LOGOS: { USDC: '/usdc.png', USDT: '/usdt.png' }, + TOKEN_LOGOS: { USDC: '/usdc.png', USDT: '/usdt.png', ETH: '/eth-token.png' }, })) jest.mock('@/constants/zerodev.consts', () => ({ diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx index 8cbbcb913..a155c8fd6 100644 --- a/src/app/(mobile-ui)/card-recovery/page.tsx +++ b/src/app/(mobile-ui)/card-recovery/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' import type { Address, Hex } from 'viem' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' @@ -9,6 +8,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { useKernelClient } from '@/context/kernelClient.context' +import { useSafeBack } from '@/hooks/useSafeBack' import { RAIN_WITHDRAW_EIP712_DOMAIN_NAME, RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, @@ -40,13 +40,17 @@ type Step = 'preview' | 'confirm' | 'signing' | 'submitting' | 'done' * requires the user's passkey. */ export default function CardRecoveryPage() { - const router = useRouter() + const onBack = useSafeBack('/home') const { getClientForChain } = useKernelClient() const [step, setStep] = useState('preview') const [preview, setPreview] = useState(null) const [error, setError] = useState(null) const [txHash, setTxHash] = useState(null) + // The amount actually prepared + signed + submitted. The mount-time `preview` + // can be stale by the time the user confirms (collateral can change), so the + // completion screen must report what was really recovered, not the preview. + const [recoveredCents, setRecoveredCents] = useState(null) useEffect(() => { let cancelled = false @@ -71,6 +75,8 @@ export default function CardRecoveryPage() { // page were tampered with at runtime, the backend signs over the // values it computed itself. const prep = await rainApi.prepareRecoverFunds() + // Lock in the real prepared amount for the completion screen. + setRecoveredCents(prep.amountCents) const chainIdStr = String(PEANUT_WALLET_CHAIN.id) const chainIdNum = Number(prep.chainId) @@ -120,7 +126,7 @@ export default function CardRecoveryPage() { return (
- router.push('/home')} /> +
{error && } @@ -128,7 +134,8 @@ export default function CardRecoveryPage() {

Funds sent to your wallet.

- ${formatCents(preview!.amountCents)} USDC has been returned to your peanut wallet. + ${formatCents(recoveredCents ?? preview!.amountCents)} USDC has been returned to your peanut + wallet.

{ const { overview, isLoading: overviewLoading, error: overviewError } = useRainCardOverview() const { serializeGrant } = useGrantSessionKey() + const { railsForProvider, isLoading: capabilitiesLoading } = useCapabilities() const { setIsSupportModalOpen } = useModalsContext() const onBack = useSafeBack('/home') @@ -84,8 +86,8 @@ const CardPage: FC = () => { const [isIssuing, setIsIssuing] = useState(false) // Track whether the user has acknowledged the skip-badge celebration. - // localStorage for M2; Phase 5 will read this from BE's - // cardWaitlistSkipCelebrationSeenAt column. + // localStorage on purpose (per-device, replayable via the eligibility + // re-hold below) — the celebration is a moment, not durable state. const [skipCelebrationSeen, setSkipCelebrationSeen] = useState(() => getSkipCelebrationSeen()) // Press-and-hold "see if you qualify" gate. Resets per mount: as long @@ -96,66 +98,29 @@ const CardPage: FC = () => { // exists (see cardState.utils.ts — active-card wins first). const [eligibilityCheckDone, setEligibilityCheckDone] = useState(false) - // `?press_door=1` arrives from /shhhhh → /setup → here. It means: the - // user clicked "press the door" on /shhhhh while signed out, just - // completed signup, and now expects to enter the card flow. We - // auto-stamp `flowEarlyAccess` on their behalf so they don't have to - // re-press the door. Initial value is read synchronously to gate the - // outer-gate redirect on first paint; the param is cleared once the - // stamp lands. - const [pressDoorMode, setPressDoorMode] = useState(() => { - if (typeof window === 'undefined') return false - return new URL(window.location.href).searchParams.get('press_door') === '1' - }) - const pressDoorFiredRef = useRef(false) - useEffect(() => { - if (!pressDoorMode) return - if (pioneerLoading || !cardInfo) return - if (pressDoorFiredRef.current) return - pressDoorFiredRef.current = true - const finish = (): void => { - const url = new URL(window.location.href) - url.searchParams.delete('press_door') - window.history.replaceState(window.history.state, '', url.toString()) - setPressDoorMode(false) - } - if (cardInfo.flowEarlyAccess) { - // Already stamped (e.g. quick refresh after stamp landed) — just clean up. - finish() - return - } - void (async () => { - try { - await cardApi.grantFlowEarlyAccess() - posthog.capture(ANALYTICS_EVENTS.CARD_FLOW_EARLY_ACCESS_GRANTED) - await queryClient.invalidateQueries({ queryKey: ['card-info'] }) - } catch (err) { - console.error('[card] press_door auto-stamp failed:', err) - } finally { - finish() - } - })() - }, [pressDoorMode, pioneerLoading, cardInfo, queryClient]) + // The old `?press_door=1` auto-stamp was removed alongside the /shhhhh + // door rework: the bare door joins the waitlist and grants nothing, so a + // shareable URL that silently stamps flowEarlyAccess would have been the + // exact bypass the rework forbids. BE now also reports flowEarlyAccess + // true whenever hasCardAccess is (inner gate implies outer). // Outer gate: pre-public-launch, the card campaign isn't fully online // yet. Users without flow early access get a 404 — the page behaves as // if it doesn't exist. The only ways in are (a) already holding a card - // / being mid-application, or (b) entering through the special /shhhhh - // page, which stamps `flowEarlyAccess` via ?press_door=1 before landing - // here. BE returns `flowEarlyAccess: false` for everyone else. + // / being mid-application, or (b) holding card access (skip badge / + // admin grant — BE reports flowEarlyAccess true whenever hasCardAccess + // is). Everyone else belongs on /shhhhh, which joins the waitlist + // inline and never routes here. // // IMPORTANT: skip the 404 if the user already has a non-canceled card. // Legacy Pioneers + admin-granted users issued cards before /shhhhh // existed and have no flowEarlyAccess stamp — they must still reach // YourCardScreen. The computeCardState() precedence below mirrors this - // rule (active-card before no-flow-access). Also skip while pressDoorMode - // is in flight: the stamp is about to land any tick now, and 404-ing - // mid-stamp would wrongly bounce a legit /shhhhh entrant. + // rule (active-card before no-flow-access). // // notFound() thrown synchronously inside the effect bubbles to Next's // not-found boundary just like a render-time call. useEffect(() => { - if (pressDoorMode) return if (pioneerLoading || pioneerError) return if (!cardInfo) return if (cardInfo.flowEarlyAccess) return @@ -167,7 +132,7 @@ const CardPage: FC = () => { if (hasIssuedCard) return posthog.capture(ANALYTICS_EVENTS.CARD_FLOW_GATED) notFound() - }, [pioneerLoading, pioneerError, cardInfo, overview, overviewLoading, pressDoorMode]) + }, [pioneerLoading, pioneerError, cardInfo, overview, overviewLoading]) const state = computeCardState({ overview, @@ -546,6 +511,40 @@ const CardPage: FC = () => { return case 'manual-review': return + case 'requires-info': { + // Surface the structured remediation reason from the + // capabilities read-model — `rail.reason.userMessage` is + // display-ready and provider-neutral by contract. The card + // provider serves exactly one rail, so [0] is the card rail. + // Overview and capabilities load independently — wait for + // capabilities so the screen never flashes without its reason. + if (capabilitiesLoading) { + return ( +
+ +
+ ) + } + const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage + return ( + setIsSupportModalOpen(true)} + onPrev={onBack} + /> + ) + } + case 'requires-support': + // Pipeline-side failure — nothing the user can re-submit. + // Same support deep-link as 'rejected' below. + return ( + setIsSupportModalOpen(true)} + onPrev={onBack} + /> + ) case 'rejected': // No retry CTA: Rain denials are terminal on our side. The // only path forward is support reviewing the case manually diff --git a/src/app/(mobile-ui)/dev/components/page.tsx b/src/app/(mobile-ui)/dev/components/page.tsx index 1d74fc903..7dd7051cc 100644 --- a/src/app/(mobile-ui)/dev/components/page.tsx +++ b/src/app/(mobile-ui)/dev/components/page.tsx @@ -991,9 +991,6 @@ export default function ComponentsPage() {

GuestLoginModal — guest auth flow

-

- KycVerifiedOrReviewModal — KYC status -

BalanceWarningModal — low balance warning

diff --git a/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx index d2598a969..a099ee762 100644 --- a/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx +++ b/src/app/(mobile-ui)/dev/ds/patterns/modal/page.tsx @@ -303,7 +303,6 @@ export default function ModalPage() { ['InviteFriendsModal', 'Share referral link with copy + social buttons'], ['ConfirmInviteModal', 'Confirm invitation before sending'], ['GuestLoginModal', 'Prompt guest users to log in or register'], - ['KycVerifiedOrReviewModal', 'KYC verification status feedback'], ['BalanceWarningModal', 'Warn about insufficient balance'], ['TokenAndNetworkConfirmationModal', 'Confirm token + chain before transfer'], ['TokenSelectorModal', 'Pick token from a list'], diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 9d463407d..033e9b1e8 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -17,6 +17,10 @@ import { buildKycHistoryEntry } from '@/utils/kyc-grouping.utils' import { useAuth } from '@/context/authContext' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' +import CardUnlockHistoryItem from '@/components/Card/CardUnlockHistoryItem' +import { deriveCardUnlockEntry, isCardUnlockHistoryItem } from '@/components/Card/cardUnlock.types' +import { useCardInfo } from '@/hooks/useCardInfo' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' import React, { useMemo } from 'react' import { useQueryClient, type InfiniteData } from '@tanstack/react-query' import { useWebSocket } from '@/hooks/useWebSocket' @@ -35,6 +39,9 @@ const HistoryPage = () => { const { user } = useUserStore() const queryClient = useQueryClient() const { fetchUser } = useAuth() + // Synthetic card-unlock row inputs — same cached queries HomeHistory uses. + const { cardInfo } = useCardInfo() + const { overview: rainOverview } = useRainCardOverview() const { data: historyData, @@ -176,6 +183,19 @@ const HistoryPage = () => { if (kycEntry) entries.push(kycEntry) } + // add the card-unlock milestone row, placed chronologically. Unlike + // the home top-5 (where it ages out), the full page always carries it. + if (cardInfo) { + const unlock = deriveCardUnlockEntry({ + hasIssuedCard: (rainOverview?.cards.length ?? 0) > 0, + hasCardAccess: cardInfo.hasCardAccess, + cardAccessGrantedAt: cardInfo.waitlistReleasedAt, + skipBadges: cardInfo.skipBadges, + userBadges: user?.user?.badges, + }) + if (unlock) entries.push(unlock) + } + entries.sort((a, b) => { const dateA = new Date(a.timestamp || 0).getTime() const dateB = new Date(b.timestamp || 0).getTime() @@ -183,7 +203,7 @@ const HistoryPage = () => { }) return entries - }, [allEntries, user, isLoading]) + }, [allEntries, user, isLoading, cardInfo, rainOverview]) // Memoize per-row drawer projection so the .map() below doesn't recompute // mapTransactionDataForDrawer per row on every parent rerender (websocket @@ -191,7 +211,7 @@ const HistoryPage = () => { const drawerByUuid = useMemo(() => { const m = new Map>() for (const item of combinedAndSortedEntries) { - if (isKycStatusItem(item) || isBadgeHistoryItem(item)) continue + if (isKycStatusItem(item) || isBadgeHistoryItem(item) || isCardUnlockHistoryItem(item)) continue if (!m.has(item.uuid)) m.set(item.uuid, mapTransactionDataForDrawer(item)) } return m @@ -265,6 +285,13 @@ const HistoryPage = () => { ) : isBadgeHistoryItem(item) ? ( + ) : isCardUnlockHistoryItem(item) ? ( + ) : ( (() => { const { transactionDetails, transactionCardType } = diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 9e0de82fb..41a33bd68 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -24,7 +24,7 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useNotifications } from '@/hooks/useNotifications' import { useCapabilities } from '@/hooks/useCapabilities' -import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo' +import { useCardInfo } from '@/hooks/useCardInfo' import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA' import EnableAutoBalanceBanner from '@/components/Home/EnableAutoBalanceBanner' import InvitesIcon from '@/components/Home/InvitesIcon' @@ -70,7 +70,7 @@ export default function Home() { const { isActivated, activationStep, dismissCardStep } = useActivationStatus() // Fire-and-forget: warms the card-info cache so /card mounts fast. // Return values intentionally unused — only the fetch side effect matters. - useCardPioneerInfo() + useCardInfo() const username = user?.user.username const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 786d9f027..d61084129 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -15,7 +15,6 @@ import '../../styles/globals.css' import QRScannerOverlay from '@/components/Global/QRScannerOverlay' import SecurityVerificationOverlay from '@/components/Global/SecurityVerificationOverlay' import SupportDrawer from '@/components/Global/SupportDrawer' -import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' import JoinWaitlistPage from '@/components/Invites/JoinWaitlistPage' import { useRouter } from 'next/navigation' import { Banner } from '@/components/Global/Banner' @@ -226,8 +225,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => { - - diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index bcc351fb7..cdcfc5213 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -79,8 +79,16 @@ jest.mock('@/assets/payment-apps', () => ({ PIX: '/pix.png', })) -jest.mock('@/assets', () => ({ - PeanutGuyGIF: '/peanut-guy.gif', +// The page imports PeanutThinking from @/assets/mascot and STAR_STRAIGHT_ICON +// from @/assets/icons directly — mock those paths, not the @/assets barrel, and +// keep sibling exports (e.g. PEANUTMAN_LOGO, ETHEREUM_ICON used by QRScanner) intact. +jest.mock('@/assets/mascot', () => ({ + ...jest.requireActual('@/assets/mascot'), + PeanutThinking: '/peanut-guy.gif', +})) + +jest.mock('@/assets/icons', () => ({ + ...jest.requireActual('@/assets/icons'), STAR_STRAIGHT_ICON: '/star.png', })) @@ -1175,6 +1183,16 @@ describe('GROUP 5: Error States', () => { }) }) + test('Below-minimum Pix charge shows the Pix minimum-amount error', async () => { + mockMantecaApi.initiateQrPayment.mockRejectedValue(new Error('PIX_MIN_AMOUNT')) + + renderQrPay({ qrCode: 'pix://payment?id=123', type: 'PIX', t: '1' }) + + await waitFor(() => { + expect(screen.getByText(/BRL minimum for Pix payments/i)).toBeInTheDocument() + }) + }) + test('Manteca API error shows generic error', async () => { mockMantecaApi.initiateQrPayment.mockRejectedValue(new Error('Network timeout')) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index dd0be7549..b15f0d006 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -54,7 +54,8 @@ import ActionModal from '@/components/Global/ActionModal' import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient, useQuery } from '@tanstack/react-query' import { shootDoubleStarConfetti } from '@/utils/confetti' -import { PeanutGuyGIF, STAR_STRAIGHT_ICON } from '@/assets' +import { PeanutThinking } from '@/assets/mascot' +import { STAR_STRAIGHT_ICON } from '@/assets/icons' import { useAuth } from '@/context/authContext' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' @@ -77,6 +78,15 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' +// Deterministic provider rejections — retrying the payment-lock query can't +// change the outcome, so fail fast instead of burning the 3-attempt budget. +const NON_RETRYABLE_QR_PAY_ERRORS = ['PAYMENT_DESTINATION_DECODING_ERROR', 'PIX_MIN_AMOUNT'] + +// Shown wherever the backend rejects a Pix payment below the rail minimum +// (typed 400 PIX_MIN_AMOUNT — fires at lock-init for merchant-encoded amounts +// and at re-init for user-entered amounts on open-amount QRs). +const PIX_MIN_AMOUNT_ERROR_MESSAGE = `This Pix charge is below the ${MIN_PIX_AMOUNT_BRL} BRL minimum for Pix payments.` + type PaymentProcessor = 'MANTECA' export default function QRPayPage() { @@ -491,7 +501,7 @@ export default function QRPayPage() { !shouldBlockPay, retry: (failureCount, error: any) => { // Don't retry provider-specific errors - if (error?.message?.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { + if (NON_RETRYABLE_QR_PAY_ERRORS.some((code) => error?.message?.includes(code))) { return false } // Retry network/timeout errors up to 2 times (3 total attempts) @@ -535,6 +545,11 @@ export default function QRPayPage() { ) posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) + } else if (error.message.includes('PIX_MIN_AMOUNT')) { + // Deterministic rejection — the merchant-encoded amount is below + // the rail minimum, so there's no merchant amount to wait for. + setWaitingForMerchantAmount(false) + setErrorInitiatingPayment(PIX_MIN_AMOUNT_ERROR_MESSAGE) } else { // Network/timeout errors after all retries exhausted setErrorInitiatingPayment( @@ -573,8 +588,14 @@ export default function QRPayPage() { }) setPaymentLock(finalPaymentLock) } catch (error) { - captureException(error) - setErrorMessage('Could not initiate payment due to unexpected error. Please contact support') + if (error instanceof Error && error.message.includes('PIX_MIN_AMOUNT')) { + // Deterministic rejection (user-entered amount below the rail + // minimum) — actionable copy, not a Sentry-worthy surprise. + setErrorMessage(PIX_MIN_AMOUNT_ERROR_MESSAGE) + } else { + captureException(error) + setErrorMessage('Could not initiate payment due to unexpected error. Please contact support') + } setIsSuccess(false) setLoadingState('Idle') return @@ -1575,7 +1596,8 @@ const QrPayPageLoading = ({ message }: { message: string }) => {
Peanut Man + {/* Mounted here (not in a route-group layout) so the cooldown + explainer also covers public pay/send/request pages — + the rain:cooldown event fires on every spend path. */} + {HarnessBootstrap && ( diff --git a/src/app/[...recipient]/error.tsx b/src/app/[...recipient]/error.tsx index 3ff40afd8..b9e261f5f 100644 --- a/src/app/[...recipient]/error.tsx +++ b/src/app/[...recipient]/error.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import { Button } from '@/components/0_Bruddle/Button' import { Card } from '@/components/0_Bruddle/Card' +import { recoverFromChunkError } from '@/utils/chunk-error-recovery' export default function PaymentError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { const router = useRouter() @@ -12,6 +13,9 @@ export default function PaymentError({ error, reset }: { error: Error & { digest useEffect(() => { console.error(error) + // "Try again" re-renders against the same dead deployment under skew — + // for chunk errors only a reload (re-pin to current deployment) works. + recoverFromChunkError(error) }, [error]) return ( diff --git a/src/app/[locale]/(marketing)/error.tsx b/src/app/[locale]/(marketing)/error.tsx index 938173d6a..89c250f95 100644 --- a/src/app/[locale]/(marketing)/error.tsx +++ b/src/app/[locale]/(marketing)/error.tsx @@ -2,10 +2,14 @@ import { useEffect } from 'react' import Link from 'next/link' +import { recoverFromChunkError } from '@/utils/chunk-error-recovery' export default function MarketingError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { console.error(error) + // "Try again" re-renders against the same dead deployment under skew — + // for chunk errors only a reload (re-pin to current deployment) works. + recoverFromChunkError(error) }, [error]) return ( diff --git a/src/app/actions/history.ts b/src/app/actions/history.ts index f16570ecf..f72a00c67 100644 --- a/src/app/actions/history.ts +++ b/src/app/actions/history.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { completeHistoryEntry } from '@/utils/history.utils' import type { HistoryEntry } from '@/utils/history.utils' import { serverFetch } from '@/utils/api-fetch' @@ -8,12 +9,17 @@ import { serverFetch } from '@/utils/api-fetch' * Final-state entries are cacheable; intermediate states are fetched * fresh so the shared receipt URL always reflects the latest status. * + * Wrapped in React `cache()`: generateMetadata and the page body both fetch + * the same entry within one request — this dedupes the second BE roundtrip + * and guarantees metadata and page render the same snapshot (two live + * fetches could disagree if the entry settles between them). + * * @param entryId The intent id (or sendlink pubkey, or perk_usage id) * @param kind The canonical TransactionIntentKind (or synthetic * 'PERK_REWARD' / 'REQUEST_POT'); routes the BE single-entry * dispatcher to the right table. */ -export async function getHistoryEntry(entryId: string, kind: string): Promise { +export const getHistoryEntry = cache(async (entryId: string, kind: string): Promise => { let response: Response try { const safeEntryId = encodeURIComponent(entryId) @@ -37,4 +43,4 @@ export async function getHistoryEntry(entryId: string, kind: string): Promise { Sentry.captureException(error) + // Caught chunk errors never reach the inline window-level recovery + // script — attempt the same guarded one-time reload from here. + recoverFromChunkError(error) }, [error]) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7514dc236..2dfe74b21 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import localFont from 'next/font/local' import Script from 'next/script' import '../styles/globals.css' import { PEANUT_API_URL, BASE_URL } from '@/constants/general.consts' +import { CHUNK_ERROR_RECOVERY_SCRIPT } from '@/utils/chunk-error-recovery' import { type Metadata } from 'next' const baseUrl = BASE_URL || 'https://peanut.me' @@ -157,6 +158,17 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* Prefetch /qr-pay route - disabled in dev to avoid 9s+ compile time */} {process.env.NODE_ENV !== 'development' && } + {/* Chunk-load failure recovery: MUST be a raw inline script — error boundaries + are lazy chunks themselves and fail to load in the exact conditions that need + them, and even next/script beforeInteractive only queues into self.__next_s + for Next's bootstrap CHUNK to execute (see src/utils/chunk-error-recovery.ts) */} + {process.env.NODE_ENV !== 'development' && ( +