From 8a1ccdbab5e5fc11ef49bf41610035a5203c8ffd Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Sat, 5 Sep 2026 12:30:22 +0100 Subject: [PATCH 1/5] fix(mobile): five polish fixes across invite, capabilities, home, FX and avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invite QR — regression. `content` used to sit directly in ActionModal's centered column, so an intrinsically sized body centered itself. The head's mb-3 rewrite (f1265c7a9) wrapped it in a plain block and the QR went hard left. The wrapper is a centered column again. The QR also gains the white p-4 quiet zone that QRCodeWrapper already carries — modules running to the edge of a code are a scan failure, not a style choice. Capabilities — the card-only footer note is gone. A card-restricted user already reads "Not available" on the Peanut card row itself (unlock-payments.utils maps restrictions.card to the notAvailable chip), so the line only said it twice. The banking note stays: it covers rails whose rows are absent entirely. Home — the avatar gains a chevron. A lone sticker in the corner reads as decoration; the chevron is what says it opens something. Exchange rate — no layout shift on first paint. The fee card and the delivery line were gated on `destinationAmount`, which needs the FX round trip, so the card rendered short and the CTA jumped down when the quote landed. Both read only the typed amount and the currency pair, and both fees are free on every pair, so they gate on `sourceAmount` — known synchronously from the URL. The two amount skeletons were h-8 against a 20px line box and the rate skeleton h-3 against a 16px one; all three now match what they replace, and the rate skeleton stops being grey-on-grey against its own pill. Avatars — a-z initials as their own group, on top. This replaces the "use my initial instead" button, which wrote `avatarKey: null`: not a pick, but a subscription to whatever the username starts with, changing on every rename. `letter.` is a real pick, so someone can wear the initial they actually go by. The grid roves by its own column count rather than the shared 5. Also fixes the picker's clipped buttons: the drawer's padding sat on the panel, outside the overflow-auto scroll box, so a w-full button's 4px offset shadow fell past the scroll edge and was cut. The horizontal padding moves onto the scroll area, which is what `scrollAreaClassName` is for. Needs peanut-api-ts#1529 deployed first — until then a `letter.*` write is a 400. --- src/components/Avatar/AvatarPicker.tsx | 47 +++++++++---- .../Avatar/__tests__/AvatarPicker.test.tsx | 23 +++++-- .../Avatar/__tests__/avatar.utils.test.ts | 6 +- src/components/Avatar/avatar.utils.ts | 21 ++++-- src/components/Avatar/avatarPicker.utils.ts | 11 ++- src/components/Global/ActionModal/index.tsx | 6 +- .../__tests__/no-quote-layout-shift.test.tsx | 68 +++++++++++++++++++ .../Global/ExchangeRateWidget/index.tsx | 30 +++++--- .../Global/InviteFriendsModal/index.tsx | 5 +- .../Profile/views/UnlockPayments.view.tsx | 8 +-- src/features/home/views/HomeTopNav.tsx | 5 +- .../home/views/__tests__/HomeTopNav.test.tsx | 10 ++- src/i18n/app/messages/en.json | 3 +- src/i18n/app/messages/es-419.json | 3 +- src/i18n/app/messages/es-AR.json | 1 - src/i18n/app/messages/pt-BR.json | 3 +- 16 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index 8a6256c08f..d1241b4694 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -8,8 +8,8 @@ import { useToast } from '@/components/0_Bruddle/Toast' import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' import { useAuth } from '@/context/authContext' import { twMerge } from '@/utils/tw' -import { badgeAvatarKeys, offerBasics } from './avatar.utils' -import { roveAvatarTiles } from './avatarPicker.utils' +import { badgeAvatarKeys, letterAvatarKeys, offerBasics } from './avatar.utils' +import { AVATAR_PICKER_COLUMNS, AVATAR_PICKER_LETTER_COLUMNS, roveAvatarTiles } from './avatarPicker.utils' import { UserAvatar } from './UserAvatar' interface AvatarPickerProps { @@ -18,11 +18,16 @@ interface AvatarPickerProps { } /** - * The profile avatar picker (TASK-22142): what the user's badges unlocked, - * then one row of the basics everyone has. A tap saves at once; the dice - * rerolls the offered row and never the pick; "use my initial" clears it. - * The API validates the pick against the same pool, so a locked key never - * lands even if the manifest and the catalog drift. + * The profile avatar picker (TASK-22142): the a-z initials everyone has, then + * what the user's badges unlocked, then one row of the basics. A tap saves at + * once; the dice rerolls the offered row and never the pick. The API validates + * the pick against the same pool, so a locked key never lands even if the + * manifest and the catalog drift. + * + * The initials grid replaced a "use my initial instead" text button. That + * button wrote `avatarKey: null`, which renders the first letter of the + * USERNAME and follows it on rename; a `letter.` pick is a real pick and + * stays put. `null` remains the day-0 state of someone who never opened this. */ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const t = useTranslations('avatar') @@ -79,6 +84,8 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { if (!draining.current) void drain() } + const letters = letterAvatarKeys() + // the offered row of five basics: dealt on open, redealt by the dice const [offer, setOffer] = useState([]) useEffect(() => { @@ -91,16 +98,21 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { // human labels: "Bug Whisperer · beetle" for a badge avatar, the slug for a basic const label = (key: string) => { const [kind, code, slug] = key.split('.') - return kind === 'badge' ? `${badgeName[code] ?? code} · ${slug}` : code + if (kind === 'badge') return `${badgeName[code] ?? code} · ${slug}` + return kind === 'letter' ? code.toUpperCase() : code } - const tiles = (keys: string[], groupLabel: string) => { + const tiles = (keys: string[], groupLabel: string, columns: number = AVATAR_PICKER_COLUMNS) => { const focusIndex = Math.max(0, keys.indexOf(pick ?? '')) return (
{keys.map((key, index) => { @@ -129,12 +141,20 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { return ( - + {/* The horizontal padding belongs to the SCROLL AREA, not to the panel + around it: the panel's padding sits outside the overflow-auto box, + so a w-full button's 4px offset shadow fell past the scroll edge + and got clipped. The matching pb-2 below covers the bottom. */} + {t('title')} {t('description')} -
+
+
+
{t('initials')}
+ {tiles(letters, t('initials'), AVATAR_PICKER_LETTER_COLUMNS)} +
{t('fromBadges')} @@ -159,9 +179,6 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { -
diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx index 992af7c337..0f96f88ab6 100644 --- a/src/components/Avatar/__tests__/AvatarPicker.test.tsx +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -82,7 +82,10 @@ describe('AvatarPicker', () => { it('lists one row of five basics and only the avatars of badges the user holds', () => { renderWithIntl() - expect(screen.getAllByRole('radio')).toHaveLength(8) + // scoped per group: the 26 initials are always on top of these + expect( + screen.getByRole('radiogroup', { name: 'From your badges' }).querySelectorAll('[role="radio"]') + ).toHaveLength(3) // human labels, not keys: badge name + slug, or the slug alone expect(radio(A)).toBeInTheDocument() expect(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).toHaveLength(5) @@ -94,7 +97,7 @@ describe('AvatarPicker', () => { mockUser.user.badges = [] renderWithIntl() - expect(screen.getAllByRole('radio')).toHaveLength(5) + expect(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).toHaveLength(5) expect(screen.getByText('Earn a badge and its avatars appear here.')).toBeInTheDocument() }) @@ -221,13 +224,23 @@ describe('AvatarPicker', () => { random.mockRestore() }) - it('clears the pick back to the initial', () => { + it('offers every letter as its own pick, ahead of the sticker groups', () => { + renderWithIntl() + const groups = screen.getAllByRole('radiogroup').map((el) => el.getAttribute('aria-label')) + + expect(groups[0]).toBe('Initials') + expect(screen.getByRole('radiogroup', { name: 'Initials' }).querySelectorAll('[role="radio"]')).toHaveLength(26) + expect(screen.getByRole('radio', { name: 'A' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Z' })).toBeInTheDocument() + }) + + it('a letter is a real pick, not a clear back to the username initial', () => { mockUser.user.avatarKey = 'basic.apple' renderWithIntl() - fireEvent.click(screen.getByRole('button', { name: 'Use my initial instead' })) + fireEvent.click(screen.getByRole('radio', { name: 'K' })) - expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: null }) + expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: 'letter.k' }) }) it('closes on done', () => { diff --git a/src/components/Avatar/__tests__/avatar.utils.test.ts b/src/components/Avatar/__tests__/avatar.utils.test.ts index e299971a25..99f97736af 100644 --- a/src/components/Avatar/__tests__/avatar.utils.test.ts +++ b/src/components/Avatar/__tests__/avatar.utils.test.ts @@ -47,7 +47,8 @@ describe('avatar catalog', () => { 'badge.BUG_WHISPERER.shell', 'badge.BUG_WHISPERER.peek', ]) - expect(avatarPool(['OFFRAMP_USER'])).toHaveLength(23) + // 26 letters + 20 basics + the 3 OFFRAMP_USER avatars + expect(avatarPool(['OFFRAMP_USER'])).toHaveLength(49) }) it('maps keys to their art and rejects anything the manifest does not know', () => { @@ -56,6 +57,9 @@ describe('avatar catalog', () => { expect(avatarSrc('basic.peanut')).toBeNull() expect(avatarSrc('badge.BUG_WHISPERER.nope')).toBeNull() expect(avatarSrc('badge.FIRST_INVITE.beetle')).toBeNull() + expect(avatarSrc('letter.k')).toBe('/avatars/letter/k.webp') + expect(avatarSrc('letter.K')).toBeNull() + expect(avatarSrc('letter.ab')).toBeNull() expect(avatarSrc('../etc/passwd')).toBeNull() // plain JSON object: prototype names must not read as badges expect(avatarSrc('badge.constructor.x')).toBeNull() diff --git a/src/components/Avatar/avatar.utils.ts b/src/components/Avatar/avatar.utils.ts index fb1fba7893..bf042e1008 100644 --- a/src/components/Avatar/avatar.utils.ts +++ b/src/components/Avatar/avatar.utils.ts @@ -4,9 +4,12 @@ * in peanut-api-ts and never hand-edited. `avatars.basics` is the set every * user gets; `avatars.badges[CODE]` lists the slugs holding that badge unlocks. * - * Keys are `basic.` and `badge..`. The API validates a - * pick against the same pool. This file only mirrors the manifest into - * paths and palettes; it never decides who may wear what. + * Keys are `basic.`, `badge..` and `letter.`. The API + * validates a pick against the same pool. This file only mirrors the manifest + * into paths and palettes; it never decides who may wear what. + * + * The letters are not in the manifest — they are art everyone has + * (`public/avatars/letter/`), so they are listed here and unlocked for all. */ import badgeAssets from '@/types/badge-assets.json' @@ -19,12 +22,18 @@ const slugsOf = (code: string): readonly string[] => (Object.hasOwn(BADGE_AVATAR export const basicAvatarKeys = (): string[] => BASICS.map((slug) => `basic.${slug}`) +/** a-z, the letter stickers every user may wear regardless of their name. */ +export const LETTERS: readonly string[] = Array.from({ length: 26 }, (_, i) => String.fromCharCode(97 + i)) + +export const letterAvatarKeys = (): string[] => LETTERS.map((letter) => `letter.${letter}`) + /** Avatar keys unlocked by holding these badge codes, in badge order. */ export const badgeAvatarKeys = (heldCodes: readonly string[]): string[] => heldCodes.flatMap((code) => slugsOf(code).map((slug) => `badge.${code}.${slug}`)) /** Everything the user may pick: the basics plus what their badges unlock. */ export const avatarPool = (heldCodes: readonly string[]): string[] => [ + ...letterAvatarKeys(), ...basicAvatarKeys(), ...badgeAvatarKeys(heldCodes), ] @@ -53,13 +62,15 @@ export function avatarSrc(key: string | null | undefined): string | null { if (kind === 'badge' && rest.length === 2 && slugsOf(rest[0]).includes(rest[1])) { return `/avatars/badge/${rest[0]}/${rest[1]}.webp` } + if (kind === 'letter' && rest.length === 1 && LETTERS.includes(rest[0])) return `/avatars/letter/${rest[0]}.webp` return null } /** * Sticker art for the first letter of a name, or null when the first character - * is not a-z. The letter set is art in `public/avatars/letter/`, not a manifest - * entry: it is never a pick, only the day-0 look of a user who has not picked. + * is not a-z. This is the day-0 look of a user who has not picked; the same art + * is also pickable outright as `letter.`, which is how someone wears an + * initial that is not the one their username starts with. */ export function letterAvatarSrc(name: string | null | undefined): string | null { const ch = name?.trim().charAt(0).toLowerCase() diff --git a/src/components/Avatar/avatarPicker.utils.ts b/src/components/Avatar/avatarPicker.utils.ts index 332c258abc..7df7a3c2ae 100644 --- a/src/components/Avatar/avatarPicker.utils.ts +++ b/src/components/Avatar/avatarPicker.utils.ts @@ -1,14 +1,19 @@ import type { KeyboardEvent } from 'react' export const AVATAR_PICKER_COLUMNS = 5 +/** The initials group is 26 tiles; 7 across keeps it four rows instead of six. */ +export const AVATAR_PICKER_LETTER_COLUMNS = 7 -/** One tab stop per radiogroup; arrows move between tiles and wrap. */ +/** One tab stop per radiogroup; arrows move between tiles and wrap. Vertical + * steps read the group's own column count off `data-columns`, so the initials + * grid roves by its 7 and not by the 5 of the sticker rows. */ export function roveAvatarTiles(event: KeyboardEvent): void { + const columns = Number(event.currentTarget.dataset.columns) || AVATAR_PICKER_COLUMNS const step = { ArrowRight: 1, ArrowLeft: -1, - ArrowDown: AVATAR_PICKER_COLUMNS, - ArrowUp: -AVATAR_PICKER_COLUMNS, + ArrowDown: columns, + ArrowUp: -columns, }[event.key] if (!step) return const radios = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')) diff --git a/src/components/Global/ActionModal/index.tsx b/src/components/Global/ActionModal/index.tsx index 49480f9a41..d0521ac027 100644 --- a/src/components/Global/ActionModal/index.tsx +++ b/src/components/Global/ActionModal/index.tsx @@ -186,7 +186,11 @@ const ActionModal: React.FC = ({
- {content &&
{content}
} + {/* items-center, not a bare block: before the head's mb-3 landed, + `content` sat directly in this centered column, and intrinsically + sized bodies (the invite QR) centered themselves. A plain + wrapper left them hanging off the left edge. */} + {content &&
{content}
} {(checkbox || (ctas && ctas.length > 0)) && (
diff --git a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx new file mode 100644 index 0000000000..cef0c66696 --- /dev/null +++ b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx @@ -0,0 +1,68 @@ +/** + * The card must not grow when the quote lands. Everything below the two amount + * fields — the fee rows, the delivery line — reads only the typed amount and + * the currency pair, both known synchronously from the URL, so gating any of it + * on `destinationAmount` meant the first paint was short and the CTA jumped down + * the moment the FX request resolved. + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import ExchangeRateWidget from '../index' + +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => {props.alt}, +})) + +const mockUseExchangeRate = jest.fn() +jest.mock('@/hooks/useExchangeRate', () => ({ + useExchangeRate: (...args: unknown[]) => mockUseExchangeRate(...args), +})) + +const quote = (over: Record = {}) => ({ + sourceAmount: 10, + destinationAmount: 8.56, + exchangeRate: 0.8563, + isLoading: false, + isError: false, + handleSourceAmountChange: jest.fn(), + handleDestinationAmountChange: jest.fn(), + getDestinationDisplayValue: () => '', + ...over, +}) + +const renderWidget = () => + render( + + + + ) + +describe('ExchangeRateWidget before the quote arrives', () => { + it('already shows the fee rows and the delivery line while the rate is loading', () => { + mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isLoading: true })) + renderWidget() + + expect(screen.getByText('Bank fee')).toBeInTheDocument() + expect(screen.getByText('Peanut fee')).toBeInTheDocument() + expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() + }) + + it('keeps them when the rate fetch fails outright — the fees are free either way', () => { + mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isError: true })) + renderWidget() + + expect(screen.getByText('Rate currently unavailable')).toBeInTheDocument() + expect(screen.getByText('Bank fee')).toBeInTheDocument() + expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() + }) + + it('drops them only when the user clears the amount', () => { + mockUseExchangeRate.mockReturnValue(quote({ sourceAmount: '', destinationAmount: '' })) + renderWidget() + + expect(screen.queryByText('Bank fee')).not.toBeInTheDocument() + expect(screen.queryByText('Should arrive in minutes.')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index fd6e08aef6..dc406d7fcb 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -238,6 +238,11 @@ const ExchangeRateWidget: FC = ({ // Determine delivery time text based on destination currency const deliveryTimeText = destinationCurrency === 'USD' ? l.arrivesHours : l.arrivesMinutes + // The source amount is known synchronously (URL, default 10); the quote is + // not. Reading it here is what lets the fee card and the delivery line hold + // their space from the first paint. + const hasAmount = typeof sourceAmount === 'number' && sourceAmount > 0 + // no exchange-rate board exists in figma (checked 2026-08-20) — container // rebuilt on the DS Card primitive (board 17802:61536) as the conservative // recipe; a dedicated board can restyle the internals later. @@ -251,7 +256,7 @@ const ExchangeRateWidget: FC = ({
{showLoading ? (
-
+
) : ( = ({ } }} type="number" - className="w-full bg-transparent text-body-m-semibold text-foreground-primary outline-none" + // h-5 pins the field to its own line box so the skeleton + // it swaps with is exactly as tall + className="h-5 w-full bg-transparent text-body-m-semibold text-foreground-primary outline-none" /> )} = ({
{showLoading ? (
-
+
) : ( = ({ } }} type="number" - className="w-full bg-transparent text-body-m-semibold text-foreground-primary outline-none" + // h-5 pins the field to its own line box so the skeleton + // it swaps with is exactly as tall + className="h-5 w-full bg-transparent text-body-m-semibold text-foreground-primary outline-none" /> )} = ({
{showLoading ? ( -
+
) : isError ? ( {l.rateUnavailable} ) : ( @@ -365,7 +374,12 @@ const ExchangeRateWidget: FC = ({ )}
- {typeof destinationAmount === 'number' && destinationAmount > 0 && ( + {/* Gated on what the user typed, not on the quote. Both fees are free + on every pair, so nothing here needs the rate — and gating them on + `destinationAmount` meant the fee card and the delivery line were + absent on first paint and pushed the CTA down the moment the quote + landed. */} + {hasAmount && (

{l.bankFee}

@@ -388,9 +402,7 @@ const ExchangeRateWidget: FC = ({ {ctaLabel} - {typeof destinationAmount === 'number' && destinationAmount > 0 && ( -

{deliveryTimeText}

- )} + {hasAmount &&

{deliveryTimeText}

} ) } diff --git a/src/components/Global/InviteFriendsModal/index.tsx b/src/components/Global/InviteFriendsModal/index.tsx index a84eee980b..f5dab01801 100644 --- a/src/components/Global/InviteFriendsModal/index.tsx +++ b/src/components/Global/InviteFriendsModal/index.tsx @@ -56,7 +56,10 @@ export default function InviteFriendsModal({ visible, onClose, username, source content={ <> {inviteLink && ( -
+ // the white p-4 is the QR quiet zone, not decoration: modules + // that run to the edge of the code are the scan failure + // QRCodeWrapper already guards against the same way +
{ const reviewEscalation = Number.isFinite(reviewSubmittedAtMs) && Date.now() - reviewSubmittedAtMs > 7 * 24 * 60 * 60 * 1000 + // No card-only note: a card-restricted user already reads "Not available" + // on the card row itself (unlock-payments.utils), so the footer line only + // repeated it. The banking note stays — it covers rails whose rows are + // absent from the list entirely. const showBankRestrictionNote = restrictions.banking - const showCardRestrictionNote = !restrictions.banking && restrictions.card const residenceTrailing = !residenceIso2 ? undefined : residence?.verified ? ( @@ -440,9 +443,6 @@ const UnlockPayments = () => { {showBankRestrictionNote && (

{t('bankNotAvailableNote')}

)} - {showCardRestrictionNote && ( -

{t('cardNotAvailableNote')}

- )} {/* Region-restricted users get the one honest region screen instead of an unlock offer that can only end in the same rejection: the diff --git a/src/features/home/views/HomeTopNav.tsx b/src/features/home/views/HomeTopNav.tsx index 193c56c869..0fc4b95456 100644 --- a/src/features/home/views/HomeTopNav.tsx +++ b/src/features/home/views/HomeTopNav.tsx @@ -28,7 +28,7 @@ export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps href="/profile" onClick={() => triggerHaptic()} // 32px visual — extend the pressable area to 44px (touch-target law) - className="relative block after:absolute after:-inset-1.5" + className="relative flex items-center gap-0.5 after:absolute after:-inset-1.5" aria-label={t('openProfile')} > {/* Own identity: the picked avatar (TASK-22142), or the first @@ -37,6 +37,9 @@ export function HomeTopNav({ username, avatarKey, showRewards }: HomeTopNavProps No username yet still gets an avatar-toned circle (yellow — the palette's no-name default). */} + {/* A lone sticker reads as decoration; the chevron is what says + it opens something. Decorative — the Link already has a label. */} + {showRewards && ( { expect(container.querySelector('a[href="/profile"] img')).toHaveAttribute('src', '/avatars/letter/t.webp') expect(screen.queryByText(/^TE$/i)).not.toBeInTheDocument() - expect(container.querySelector('a[href="/profile"] svg')).not.toBeInTheDocument() + // the avatar slot itself carries no generated face — the link's only + // svg is the affordance chevron, asserted below + expect(container.querySelector('a[href="/profile"] [role="img"] svg')).not.toBeInTheDocument() + }) + + it('marks the avatar as tappable with a chevron', () => { + const { container } = renderWithIntl() + + expect(container.querySelector('a[href="/profile"] svg')).toBeInTheDocument() }) it('wears the picked avatar inside the profile link (TASK-22142)', () => { diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 6147a21f6b..ad8e9990d2 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -398,7 +398,6 @@ "notAvailable": "Not available" }, "bankNotAvailableNote": "Bank transfers and card issuing aren't available for residents of your country.", - "cardNotAvailableNote": "The card isn't available for residents of your country.", "unlockModal": { "title": "Unlock {method}", "titleGeneric": "Unlock this payment method", @@ -3685,12 +3684,12 @@ "avatar": { "title": "Your avatar", "description": "Pick one, or roll the dice for a fresh row of basics.", + "initials": "Initials", "fromBadges": "From your badges", "basics": "Basics", "unlocked": "{count} unlocked", "noBadgeAvatars": "Earn a badge and its avatars appear here.", "rollDice": "Roll the dice", - "useInitial": "Use my initial instead", "change": "Change avatar", "saveFailed": "Could not save your avatar. Try again." } diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index 909c52c21b..84715a2792 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -398,7 +398,6 @@ "notAvailable": "No disponible" }, "bankNotAvailableNote": "Las transferencias bancarias y la emisión de tarjetas no están disponibles para residentes de tu país.", - "cardNotAvailableNote": "La tarjeta no está disponible para residentes de tu país.", "unlockModal": { "title": "Desbloquear {method}", "titleGeneric": "Desbloquea este método de pago", @@ -3685,12 +3684,12 @@ "avatar": { "title": "Tu avatar", "description": "Elige uno o tira los dados para ver otros básicos.", + "initials": "Iniciales", "fromBadges": "De tus insignias", "basics": "Básicos", "unlocked": "{count} desbloqueados", "noBadgeAvatars": "Gana una insignia y sus avatares aparecen aquí.", "rollDice": "Tirar los dados", - "useInitial": "Usar mi inicial", "change": "Cambiar avatar", "saveFailed": "No pudimos guardar tu avatar. Inténtalo de nuevo." } diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 301ada101b..e907b85d32 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -209,7 +209,6 @@ "notAvailable": "No disponible" }, "bankNotAvailableNote": "Las transferencias bancarias y la emisión de tarjetas no están disponibles para residentes de tu país.", - "cardNotAvailableNote": "La tarjeta no está disponible para residentes de tu país.", "unlockModal": { "title": "Desbloquear {method}", "titleGeneric": "Desbloqueá este método de pago", diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 32d9e9694f..9556b9b34a 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -398,7 +398,6 @@ "notAvailable": "Não disponível" }, "bankNotAvailableNote": "Transferências bancárias e emissão de cartão não estão disponíveis para residentes do seu país.", - "cardNotAvailableNote": "O cartão não está disponível para residentes do seu país.", "unlockModal": { "title": "Desbloquear {method}", "titleGeneric": "Desbloqueie este método de pagamento", @@ -3685,12 +3684,12 @@ "avatar": { "title": "Seu avatar", "description": "Escolha um ou jogue os dados para ver outros básicos.", + "initials": "Iniciais", "fromBadges": "Dos seus selos", "basics": "Básicos", "unlocked": "{count} desbloqueados", "noBadgeAvatars": "Ganhe um selo e seus avatares aparecem aqui.", "rollDice": "Jogar os dados", - "useInitial": "Usar minha inicial", "change": "Trocar avatar", "saveFailed": "Não foi possível salvar seu avatar. Tente de novo." } From d32d3af3f4b39cd3daaafca906f417950e164a91 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Sat, 5 Sep 2026 12:51:36 +0100 Subject: [PATCH 2/5] feat(avatars): a rejected letter falls back to the device, not to an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouples the Initials group from peanut-api-ts#1529. Until that ships the API rejects `letter.*` at the schema pattern, and the picker's save path turned that 400 into a red "could not save your avatar" toast plus a tile that snapped back — on the most prominent group in the drawer, replacing a button that worked. A rejected letter is now mirrored to localStorage instead, per account, and read back in preference to the server key. Any save the server DOES accept clears the mirror, so the two can never disagree about which pick came last, and the day #1529 deploys a letter write starts succeeding and promotes itself to the durable copy with no further change here. Letters only, deliberately. Basics and badge avatars are validated server-side against the user's own pool, which is what stops a badge avatar being worn without the badge; a device-local fallback there would hand that art to anyone who can edit localStorage. Letters unlock for everyone, so there is nothing to enforce and nothing to bypass — and a rejected sticker still reports as before. useAvatarKey takes the server key as an argument rather than reading it. There is no single source to read: useHomeFlow takes the user from the redux store while the profile surfaces take it from authContext. The first cut read authContext internally and returned null on the home screen, where redux holds the pick; useHomeFlow's own test caught it and now pins it. The mirror is presentation-only and degrades to nothing — safe-storage returns null wherever localStorage is blocked, and the avatar falls back to the username initial exactly as it does today. --- src/components/Avatar/AvatarPicker.tsx | 27 +++++++- .../Avatar/__tests__/AvatarPicker.test.tsx | 43 ++++++++++++ .../Avatar/__tests__/useAvatarKey.test.ts | 53 +++++++++++++++ .../Avatar/avatar-letter.storage.ts | 67 +++++++++++++++++++ src/components/Avatar/useAvatarKey.ts | 34 ++++++++++ .../Profile/components/ProfileHeader.tsx | 4 +- src/components/UserHeader/index.tsx | 9 +-- src/features/home/useHomeFlow.ts | 3 +- 8 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 src/components/Avatar/__tests__/useAvatarKey.test.ts create mode 100644 src/components/Avatar/avatar-letter.storage.ts create mode 100644 src/components/Avatar/useAvatarKey.ts diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index d1241b4694..ff308a2a56 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -9,6 +9,8 @@ import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } f import { useAuth } from '@/context/authContext' import { twMerge } from '@/utils/tw' import { badgeAvatarKeys, letterAvatarKeys, offerBasics } from './avatar.utils' +import { isLetterAvatarKey, storeLetterAvatar } from './avatar-letter.storage' +import { useAvatarKey } from './useAvatarKey' import { AVATAR_PICKER_COLUMNS, AVATAR_PICKER_LETTER_COLUMNS, roveAvatarTiles } from './avatarPicker.utils' import { UserAvatar } from './UserAvatar' @@ -28,6 +30,10 @@ interface AvatarPickerProps { * button wrote `avatarKey: null`, which renders the first letter of the * USERNAME and follows it on rename; a `letter.` pick is a real pick and * stays put. `null` remains the day-0 state of someone who never opened this. + * + * A letter the API still rejects (until peanut-api-ts#1529 ships) falls back to + * a device-local mirror rather than an error toast — see avatar-letter.storage. + * Sticker picks have no fallback by design: their unlock is enforced server-side. */ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const t = useTranslations('avatar') @@ -37,7 +43,8 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { const userId = user?.user.userId const username = user?.user.username ?? undefined - const saved = user?.user.avatarKey ?? null + // the effective pick: the server's, or the device-local letter fallback + const saved = useAvatarKey(user?.user.avatarKey, userId) const badges = user?.user.badges ?? [] const held = badges.map((badge) => badge.code) const badgeName = Object.fromEntries(badges.map((badge) => [badge.code, badge.name])) @@ -64,9 +71,13 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { wanted.current = undefined try { const { error } = await updateUserById({ userId, avatarKey: key }) - if (error) toast({ type: 'error', message: t('saveFailed') }) + if (error) rememberOrReport(key) + // the server now holds the pick, so a mirror could only + // shadow it — this is also what promotes a letter to the + // durable copy the day the API starts accepting one + else storeLetterAvatar(userId, null) } catch { - toast({ type: 'error', message: t('saveFailed') }) + rememberOrReport(key) } } await fetchUser() @@ -77,6 +88,16 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { } } + /** + * A rejected letter is not a user-facing failure: the pick is kept on this + * device and upgrades itself on the next write the server does accept. A + * rejected sticker has nowhere to go, so it still reports. + */ + const rememberOrReport = (key: string | null) => { + if (isLetterAvatarKey(key)) storeLetterAvatar(userId, key) + else toast({ type: 'error', message: t('saveFailed') }) + } + const save = (key: string | null) => { if (!userId) return setPending(key) diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx index 0f96f88ab6..ebb1d6bdfd 100644 --- a/src/components/Avatar/__tests__/AvatarPicker.test.tsx +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import type { ComponentProps, ReactNode } from 'react' import { renderWithIntl } from '@/test-utils/intl' import { AvatarPicker } from '../AvatarPicker' +import { readLetterAvatar, resetLetterAvatarCache } from '../avatar-letter.storage' jest.mock('next/image', () => ({ __esModule: true, @@ -66,6 +67,8 @@ function fakeServer() { beforeEach(() => { jest.clearAllMocks() + window.localStorage.clear() + resetLetterAvatarCache() mockUpdateUserById.mockResolvedValue({ data: {} }) mockFetchUser.mockResolvedValue(null) mockUser = { @@ -234,6 +237,46 @@ describe('AvatarPicker', () => { expect(screen.getByRole('radio', { name: 'Z' })).toBeInTheDocument() }) + it('keeps a letter this API build still rejects, on the device, without an error toast', async () => { + const server = fakeServer() + renderWithIntl() + + fireEvent.click(screen.getByRole('radio', { name: 'K' })) + await server.settle(0, { error: 'body/avatarKey must match pattern' }) + + // the pick survives the rejection and the user is not told off for it + await waitFor(() => expect(readLetterAvatar('u1')).toBe('letter.k')) + expect(mockToast).not.toHaveBeenCalled() + await waitFor(() => expect(radio('K')).toHaveAttribute('aria-checked', 'true')) + }) + + it('still reports a rejected sticker — those have no device-local fallback', async () => { + const server = fakeServer() + renderWithIntl() + + fireEvent.click(radio(A)) + await server.settle(0, { error: 'Avatar not unlocked' }) + + expect(mockToast).toHaveBeenCalledWith({ type: 'error', message: 'Could not save your avatar. Try again.' }) + expect(readLetterAvatar('u1')).toBeNull() + }) + + it('a server write that lands drops the mirror, so the durable copy wins', async () => { + const server = fakeServer() + renderWithIntl() + + fireEvent.click(screen.getByRole('radio', { name: 'K' })) + await server.settle(0, { error: 'body/avatarKey must match pattern' }) + await waitFor(() => expect(readLetterAvatar('u1')).toBe('letter.k')) + + // the API now accepts it (peanut-api-ts#1529 deployed) + fireEvent.click(screen.getByRole('radio', { name: 'M' })) + await server.settle(1) + + await waitFor(() => expect(readLetterAvatar('u1')).toBeNull()) + expect(server.committed()).toBe('letter.m') + }) + it('a letter is a real pick, not a clear back to the username initial', () => { mockUser.user.avatarKey = 'basic.apple' renderWithIntl() diff --git a/src/components/Avatar/__tests__/useAvatarKey.test.ts b/src/components/Avatar/__tests__/useAvatarKey.test.ts new file mode 100644 index 0000000000..fb5cdbe12c --- /dev/null +++ b/src/components/Avatar/__tests__/useAvatarKey.test.ts @@ -0,0 +1,53 @@ +/** + * The hook takes the server key as an ARGUMENT rather than reading it. There is + * no single source to read: useHomeFlow takes the user from the redux store and + * the profile surfaces take it from authContext. A first cut read authContext + * internally and returned null on the home screen, where redux held the pick. + */ +import { act, renderHook } from '@testing-library/react' +import { resetLetterAvatarCache, storeLetterAvatar } from '../avatar-letter.storage' +import { useAvatarKey } from '../useAvatarKey' + +beforeEach(() => { + window.localStorage.clear() + resetLetterAvatarCache() +}) + +describe('useAvatarKey', () => { + it('returns whatever server key the caller passes, from whichever store it came', () => { + expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog') + expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBeNull() + expect(renderHook(() => useAvatarKey(undefined, undefined)).result.current).toBeNull() + }) + + it('prefers a device-local letter over the server pick it has not superseded yet', () => { + storeLetterAvatar('u1', 'letter.k') + + expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBe('letter.k') + expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('letter.k') + }) + + it('scopes the mirror per account — a second login does not inherit the first initial', () => { + storeLetterAvatar('u1', 'letter.k') + + expect(renderHook(() => useAvatarKey(null, 'u2')).result.current).toBeNull() + }) + + it('re-renders live when the picker writes, so the header updates behind the drawer', () => { + const { result } = renderHook(() => useAvatarKey(null, 'u1')) + expect(result.current).toBeNull() + + act(() => storeLetterAvatar('u1', 'letter.m')) + expect(result.current).toBe('letter.m') + + act(() => storeLetterAvatar('u1', null)) + expect(result.current).toBeNull() + }) + + it('ignores a mirror value that is not a single lowercase letter key', () => { + window.localStorage.setItem('peanut:avatarLetter:u1', 'badge.FOUNDING_PIONEER.crown') + resetLetterAvatarCache() + + expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBeNull() + }) +}) diff --git a/src/components/Avatar/avatar-letter.storage.ts b/src/components/Avatar/avatar-letter.storage.ts new file mode 100644 index 0000000000..89292aba0e --- /dev/null +++ b/src/components/Avatar/avatar-letter.storage.ts @@ -0,0 +1,67 @@ +/** + * Device-local fallback for a `letter.` avatar pick. + * + * The server copy (`users.avatar_key`, written by /update-user and read back on + * /users/me) is the durable record. This mirror exists only because the API can + * reject a letter key with a 400 until peanut-api-ts#1529 ships: without it, the + * Initials group would toast "could not save your avatar" and snap back on every + * tap. Readers prefer the server value, and any successful server write clears + * the mirror — see `useAvatarKey`. + * + * LETTERS ONLY, deliberately. The basics and badge avatars are validated + * server-side against the user's own pool (`isAvatarUnlocked`), which is what + * stops a badge avatar being worn without the badge; a device-local fallback + * there would hand out that art to anyone who can edit localStorage. Letters + * are unlocked for everyone, so there is nothing to enforce and nothing to + * bypass. + * + * Keys are scoped per account, like [[declared-residence.storage]]: localStorage + * is shared across every login on the device, and an unscoped key would let a + * second account inherit the first one's initial. + */ +import { readStoredValue, removeStoredValue, writeStoredValue } from '@/utils/safe-storage' + +const keyFor = (userId: string) => `peanut:avatarLetter:${userId}` + +/** Same shape the API's AVATAR_KEY_PATTERN admits — a single lowercase letter. */ +const LETTER_KEY = /^letter\.[a-z]$/ + +export const isLetterAvatarKey = (key: string | null | undefined): boolean => !!key && LETTER_KEY.test(key) + +// getSnapshot must return a stable value for the same store state, and +// localStorage is synchronous main-thread I/O — so the parsed value is cached +// per account and only re-read when this module is the one that changed it. +const cache = new Map() +const listeners = new Set<() => void>() + +export function subscribeLetterAvatar(onChange: () => void): () => void { + listeners.add(onChange) + return () => { + listeners.delete(onChange) + } +} + +export function readLetterAvatar(userId: string | undefined): string | null { + if (!userId) return null + const cached = cache.get(userId) + if (cached !== undefined) return cached + const stored = readStoredValue(keyFor(userId)) + const value = isLetterAvatarKey(stored) ? stored : null + cache.set(userId, value) + return value +} + +/** Pass `null` to drop the mirror — what a successful server write does. */ +export function storeLetterAvatar(userId: string | undefined, key: string | null): void { + if (!userId) return + const value = isLetterAvatarKey(key) ? key : null + if (value) writeStoredValue(keyFor(userId), value) + else removeStoredValue(keyFor(userId)) + cache.set(userId, value) + for (const listener of listeners) listener() +} + +/** Test seam: the module-level cache outlives a component tree. */ +export function resetLetterAvatarCache(): void { + cache.clear() +} diff --git a/src/components/Avatar/useAvatarKey.ts b/src/components/Avatar/useAvatarKey.ts new file mode 100644 index 0000000000..24959921b6 --- /dev/null +++ b/src/components/Avatar/useAvatarKey.ts @@ -0,0 +1,34 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { readLetterAvatar, subscribeLetterAvatar } from './avatar-letter.storage' + +/** + * Resolves the avatar key to render: the server's pick, or the device-local + * letter fallback when the API has not accepted one yet. + * + * The caller passes the server key rather than the hook reading it, because + * there is no single source to read — the home flow takes the user from the + * redux store and the profile surfaces take it from authContext, and a hook + * that picked one would silently return the wrong value on the other. + * + * Precedence is "local letter wins when it exists", which is only safe because + * every successful server write clears the mirror (AvatarPicker.save). So a + * mirror can only survive while the server holds nothing newer, and the two can + * never disagree about which pick came last. + * + * The server snapshot is null on purpose: reading localStorage during render + * would make the server and client markup differ and trip hydration. The letter + * lands on the first client commit instead, one frame after the day-0 initial — + * the same art whenever the letter matches the username's first character, so + * in the common case nothing visibly changes. + */ +export function useAvatarKey(serverKey: string | null | undefined, userId: string | undefined): string | null { + const localLetter = useSyncExternalStore( + subscribeLetterAvatar, + () => readLetterAvatar(userId), + () => null + ) + + return localLetter ?? serverKey ?? null +} diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx index 43e43dabc0..3d43337918 100644 --- a/src/components/Profile/components/ProfileHeader.tsx +++ b/src/components/Profile/components/ProfileHeader.tsx @@ -6,6 +6,7 @@ import posthog from 'posthog-js' import React, { useEffect, useRef } from 'react' import { twMerge } from '@/utils/tw' import AvatarWithBadge from '../AvatarWithBadge' +import { useAvatarKey } from '@/components/Avatar/useAvatarKey' import { UserAvatar } from '@/components/Avatar/UserAvatar' import { useTranslations } from 'next-intl' import { VerifiedUserLabel } from '@/components/UserHeader' @@ -41,9 +42,10 @@ const ProfileHeader: React.FC = ({ // (Sumsub-cleared), matching the counterparty badge logic (`isVerified` on // /users/:userId). Rail-approval is unrelated. const { isVerified: selfIsIdentityVerified } = useIdentityVerification() + const ownAvatarKey = useAvatarKey(authenticatedUser?.user.avatarKey, authenticatedUser?.user.userId) const isAuthenticatedUserVerified = selfIsIdentityVerified && authenticatedUser?.user.username === username const isSelfProfile = authenticatedUser?.user.username?.toLowerCase() === username.toLowerCase() - const ownAvatar = + const ownAvatar = // `shareableUrl` reads the live origin, so preview and staging share // themselves — the old BASE_URL import is non-null-asserted with no fallback. diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx index f60a244a6f..b3d644a920 100644 --- a/src/components/UserHeader/index.tsx +++ b/src/components/UserHeader/index.tsx @@ -1,6 +1,7 @@ 'use client' import { UserAvatar } from '@/components/Avatar/UserAvatar' +import { useAvatarKey } from '@/components/Avatar/useAvatarKey' import Link from 'next/link' import { Icon } from '../Global/Icons/Icon' import { twMerge } from '@/utils/tw' @@ -19,6 +20,7 @@ interface UserHeaderProps { export const UserHeader = ({ username }: UserHeaderProps) => { const { user: authenticatedUser } = useAuth() + const ownAvatarKey = useAvatarKey(authenticatedUser?.user.avatarKey, authenticatedUser?.user.userId) return ( diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts index 2223e8912f..1d8a9efa21 100644 --- a/src/features/home/useHomeFlow.ts +++ b/src/features/home/useHomeFlow.ts @@ -1,5 +1,6 @@ 'use client' +import { useAvatarKey } from '@/components/Avatar/useAvatarKey' import { useAuth } from '@/context/authContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' @@ -53,7 +54,7 @@ export function useHomeFlow() { // the picked avatar (TASK-22142); null keeps the first-letter fallback, // which the top nav seeds from the username, never the display name - const avatarKey = user?.user.avatarKey ?? null + const avatarKey = useAvatarKey(user?.user.avatarKey, user?.user.userId) return { isPageLoading: isFetchingUser && !username, From a3bdd3658ca376dd4af22e6d7815828c493cda13 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Sat, 5 Sep 2026 17:19:03 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(fx,avatars):=20answer=20Chip=20?= =?UTF-8?q?=E2=80=94=20no=20unbacked=20claims,=20and=20a=20grid=20that=20f?= =?UTF-8?q?its?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all regressions this PR introduced. FX: reserving the layout space also started ASSERTING it. Gating the fee card and the delivery line on the typed amount meant "Bank fee: Free" and "arrives in minutes" rendered before any quote, and — per the test that locked it in — next to "Rate currently unavailable". Marketing callers do not pass restrictToRoutable and seed ~20 currencies the FX feed quotes but no rail supports, so a "send money to Thailand" page carried a fulfilment promise for a corridor with neither a rate nor a route. The boxes now hold their height on the amount and their CONTENT waits for a landed, non-error quote, which keeps the anti-shift fix intact. The Peanut fee row goes entirely. mono/product/pricing.md is explicit — "there must NEVER be a separate visible 'Peanut fee' or 'deposit fee' line in the UI", the rate is the disclosure — and it cites a real May 2026 incident. The widget's own default USD->EUR corridor stacks ~50bps Bridge FX and ~50bps dev fee behind what read "Free!", so the line was wrong on the number as well as on the format. BRIDGE_DEVELOPER_FEE_RATE = 0 is a frontend display constant, not evidence the corridor is free. Initials grid back to five columns. Seven fitted 26 letters in four rows but left ~42px per track at 375px and ~34px at 320px, under both the 48px tile and the 44px touch target the rest of the app holds itself to. Five is what the sticker rows already use, so the roving helper's single AVATAR_PICKER_COLUMNS stays true and its data-columns indirection is gone with it. Six rows of letters, scrolled. The letter mirror now records the server key it was written against and only shadows THAT value. Before, a letter stored on this device outranked a pick made on another one forever: the refetch brought the newer key back and the mirror still won. Bare-string values from the previous build are read as mirrors of "no server pick", which is the only thing they could have stood in for. --- src/components/Avatar/AvatarPicker.tsx | 21 ++++--- .../Avatar/__tests__/AvatarPicker.test.tsx | 4 +- .../Avatar/__tests__/useAvatarKey.test.ts | 31 ++++++++-- .../Avatar/avatar-letter.storage.ts | 55 ++++++++++++---- src/components/Avatar/avatarPicker.utils.ts | 11 +--- src/components/Avatar/useAvatarKey.ts | 17 ++--- .../__tests__/no-quote-layout-shift.test.tsx | 62 +++++++++++++++---- .../Global/ExchangeRateWidget/index.tsx | 36 +++++------ 8 files changed, 162 insertions(+), 75 deletions(-) diff --git a/src/components/Avatar/AvatarPicker.tsx b/src/components/Avatar/AvatarPicker.tsx index ff308a2a56..cfb12d2a7c 100644 --- a/src/components/Avatar/AvatarPicker.tsx +++ b/src/components/Avatar/AvatarPicker.tsx @@ -11,7 +11,7 @@ import { twMerge } from '@/utils/tw' import { badgeAvatarKeys, letterAvatarKeys, offerBasics } from './avatar.utils' import { isLetterAvatarKey, storeLetterAvatar } from './avatar-letter.storage' import { useAvatarKey } from './useAvatarKey' -import { AVATAR_PICKER_COLUMNS, AVATAR_PICKER_LETTER_COLUMNS, roveAvatarTiles } from './avatarPicker.utils' +import { roveAvatarTiles } from './avatarPicker.utils' import { UserAvatar } from './UserAvatar' interface AvatarPickerProps { @@ -94,7 +94,9 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { * rejected sticker has nowhere to go, so it still reports. */ const rememberOrReport = (key: string | null) => { - if (isLetterAvatarKey(key)) storeLetterAvatar(userId, key) + // stamped with the server key it stands in for, so a pick made on another + // device supersedes it as soon as this one refetches + if (isLetterAvatarKey(key)) storeLetterAvatar(userId, key, user?.user.avatarKey ?? null) else toast({ type: 'error', message: t('saveFailed') }) } @@ -123,17 +125,18 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { return kind === 'letter' ? code.toUpperCase() : code } - const tiles = (keys: string[], groupLabel: string, columns: number = AVATAR_PICKER_COLUMNS) => { + // Five columns for every group, initials included. Seven fitted the 26 + // letters in four rows but left ~42px per track at 375px and ~34px at 320px, + // under both the 48px tile and the 44px touch target — and the roving helper + // steps by AVATAR_PICKER_COLUMNS, so a second column count would also have + // desynced arrow keys from the visual rows. + const tiles = (keys: string[], groupLabel: string) => { const focusIndex = Math.max(0, keys.indexOf(pick ?? '')) return (
{keys.map((key, index) => { @@ -174,7 +177,7 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
{t('initials')}
- {tiles(letters, t('initials'), AVATAR_PICKER_LETTER_COLUMNS)} + {tiles(letters, t('initials'))}
diff --git a/src/components/Avatar/__tests__/AvatarPicker.test.tsx b/src/components/Avatar/__tests__/AvatarPicker.test.tsx index ebb1d6bdfd..15dc6ac7d6 100644 --- a/src/components/Avatar/__tests__/AvatarPicker.test.tsx +++ b/src/components/Avatar/__tests__/AvatarPicker.test.tsx @@ -245,7 +245,7 @@ describe('AvatarPicker', () => { await server.settle(0, { error: 'body/avatarKey must match pattern' }) // the pick survives the rejection and the user is not told off for it - await waitFor(() => expect(readLetterAvatar('u1')).toBe('letter.k')) + await waitFor(() => expect(readLetterAvatar('u1')?.key).toBe('letter.k')) expect(mockToast).not.toHaveBeenCalled() await waitFor(() => expect(radio('K')).toHaveAttribute('aria-checked', 'true')) }) @@ -267,7 +267,7 @@ describe('AvatarPicker', () => { fireEvent.click(screen.getByRole('radio', { name: 'K' })) await server.settle(0, { error: 'body/avatarKey must match pattern' }) - await waitFor(() => expect(readLetterAvatar('u1')).toBe('letter.k')) + await waitFor(() => expect(readLetterAvatar('u1')?.key).toBe('letter.k')) // the API now accepts it (peanut-api-ts#1529 deployed) fireEvent.click(screen.getByRole('radio', { name: 'M' })) diff --git a/src/components/Avatar/__tests__/useAvatarKey.test.ts b/src/components/Avatar/__tests__/useAvatarKey.test.ts index fb5cdbe12c..90973cb958 100644 --- a/src/components/Avatar/__tests__/useAvatarKey.test.ts +++ b/src/components/Avatar/__tests__/useAvatarKey.test.ts @@ -20,15 +20,28 @@ describe('useAvatarKey', () => { expect(renderHook(() => useAvatarKey(undefined, undefined)).result.current).toBeNull() }) - it('prefers a device-local letter over the server pick it has not superseded yet', () => { - storeLetterAvatar('u1', 'letter.k') + it('prefers a device-local letter over the server pick it stood in for', () => { + storeLetterAvatar('u1', 'letter.k', null) expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBe('letter.k') - expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('letter.k') + }) + + it('yields to a pick made on another device — the mirror only shadows its own server value', () => { + // stored while the server held nothing + storeLetterAvatar('u1', 'letter.k', null) + + // ...then device B saved basic.frog and this device refetched + expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog') + }) + + it('still shadows the exact server value it was written against', () => { + storeLetterAvatar('u1', 'letter.k', 'basic.apple') + + expect(renderHook(() => useAvatarKey('basic.apple', 'u1')).result.current).toBe('letter.k') }) it('scopes the mirror per account — a second login does not inherit the first initial', () => { - storeLetterAvatar('u1', 'letter.k') + storeLetterAvatar('u1', 'letter.k', null) expect(renderHook(() => useAvatarKey(null, 'u2')).result.current).toBeNull() }) @@ -37,7 +50,7 @@ describe('useAvatarKey', () => { const { result } = renderHook(() => useAvatarKey(null, 'u1')) expect(result.current).toBeNull() - act(() => storeLetterAvatar('u1', 'letter.m')) + act(() => storeLetterAvatar('u1', 'letter.m', null)) expect(result.current).toBe('letter.m') act(() => storeLetterAvatar('u1', null)) @@ -50,4 +63,12 @@ describe('useAvatarKey', () => { expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBeNull() }) + + it('reads the bare-string shape an earlier build wrote as a mirror of "no server pick"', () => { + window.localStorage.setItem('peanut:avatarLetter:u1', 'letter.k') + resetLetterAvatarCache() + + expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBe('letter.k') + expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog') + }) }) diff --git a/src/components/Avatar/avatar-letter.storage.ts b/src/components/Avatar/avatar-letter.storage.ts index 89292aba0e..0740aa0590 100644 --- a/src/components/Avatar/avatar-letter.storage.ts +++ b/src/components/Avatar/avatar-letter.storage.ts @@ -15,9 +15,14 @@ * are unlocked for everyone, so there is nothing to enforce and nothing to * bypass. * - * Keys are scoped per account, like [[declared-residence.storage]]: localStorage - * is shared across every login on the device, and an unscoped key would let a + * Keys are scoped per account, like declared-residence.storage: localStorage is + * shared across every login on the device, and an unscoped key would let a * second account inherit the first one's initial. + * + * Each mirror also records the server key it was written AGAINST, so it can only + * shadow that exact value. Without it, a letter stored on this device would win + * forever over a pick made on another device — the server moving on to + * `basic.frog` would never surface here. */ import { readStoredValue, removeStoredValue, writeStoredValue } from '@/utils/safe-storage' @@ -28,10 +33,17 @@ const LETTER_KEY = /^letter\.[a-z]$/ export const isLetterAvatarKey = (key: string | null | undefined): boolean => !!key && LETTER_KEY.test(key) +/** The stored letter, plus the server key it was standing in for. */ +export interface LetterMirror { + key: string + /** `user.avatarKey` at the moment the mirror was written; null when unset. */ + serverKey: string | null +} + // getSnapshot must return a stable value for the same store state, and // localStorage is synchronous main-thread I/O — so the parsed value is cached // per account and only re-read when this module is the one that changed it. -const cache = new Map() +const cache = new Map() const listeners = new Set<() => void>() export function subscribeLetterAvatar(onChange: () => void): () => void { @@ -41,21 +53,40 @@ export function subscribeLetterAvatar(onChange: () => void): () => void { } } -export function readLetterAvatar(userId: string | undefined): string | null { +export function readLetterAvatar(userId: string | undefined): LetterMirror | null { if (!userId) return null const cached = cache.get(userId) if (cached !== undefined) return cached - const stored = readStoredValue(keyFor(userId)) - const value = isLetterAvatarKey(stored) ? stored : null - cache.set(userId, value) - return value + cache.set(userId, parse(readStoredValue(keyFor(userId)))) + return cache.get(userId) ?? null +} + +// Tolerates the bare-string shape an earlier build wrote, treating it as a +// mirror of "no server pick" — the only value it could have stood in for. +function parse(stored: string | null): LetterMirror | null { + if (!stored) return null + if (isLetterAvatarKey(stored)) return { key: stored, serverKey: null } + try { + const parsed: unknown = JSON.parse(stored) + if (typeof parsed !== 'object' || parsed === null) return null + const { key, serverKey } = parsed as Record + if (!isLetterAvatarKey(typeof key === 'string' ? key : null)) return null + if (serverKey !== null && typeof serverKey !== 'string') return null + return { key: key as string, serverKey: serverKey as string | null } + } catch { + return null + } } -/** Pass `null` to drop the mirror — what a successful server write does. */ -export function storeLetterAvatar(userId: string | undefined, key: string | null): void { +/** Pass a null `key` to drop the mirror — what a successful server write does. */ +export function storeLetterAvatar( + userId: string | undefined, + key: string | null, + serverKey: string | null = null +): void { if (!userId) return - const value = isLetterAvatarKey(key) ? key : null - if (value) writeStoredValue(keyFor(userId), value) + const value: LetterMirror | null = isLetterAvatarKey(key) ? { key: key as string, serverKey } : null + if (value) writeStoredValue(keyFor(userId), JSON.stringify(value)) else removeStoredValue(keyFor(userId)) cache.set(userId, value) for (const listener of listeners) listener() diff --git a/src/components/Avatar/avatarPicker.utils.ts b/src/components/Avatar/avatarPicker.utils.ts index 7df7a3c2ae..332c258abc 100644 --- a/src/components/Avatar/avatarPicker.utils.ts +++ b/src/components/Avatar/avatarPicker.utils.ts @@ -1,19 +1,14 @@ import type { KeyboardEvent } from 'react' export const AVATAR_PICKER_COLUMNS = 5 -/** The initials group is 26 tiles; 7 across keeps it four rows instead of six. */ -export const AVATAR_PICKER_LETTER_COLUMNS = 7 -/** One tab stop per radiogroup; arrows move between tiles and wrap. Vertical - * steps read the group's own column count off `data-columns`, so the initials - * grid roves by its 7 and not by the 5 of the sticker rows. */ +/** One tab stop per radiogroup; arrows move between tiles and wrap. */ export function roveAvatarTiles(event: KeyboardEvent): void { - const columns = Number(event.currentTarget.dataset.columns) || AVATAR_PICKER_COLUMNS const step = { ArrowRight: 1, ArrowLeft: -1, - ArrowDown: columns, - ArrowUp: -columns, + ArrowDown: AVATAR_PICKER_COLUMNS, + ArrowUp: -AVATAR_PICKER_COLUMNS, }[event.key] if (!step) return const radios = Array.from(event.currentTarget.querySelectorAll('[role="radio"]')) diff --git a/src/components/Avatar/useAvatarKey.ts b/src/components/Avatar/useAvatarKey.ts index 24959921b6..78669c6855 100644 --- a/src/components/Avatar/useAvatarKey.ts +++ b/src/components/Avatar/useAvatarKey.ts @@ -12,10 +12,11 @@ import { readLetterAvatar, subscribeLetterAvatar } from './avatar-letter.storage * redux store and the profile surfaces take it from authContext, and a hook * that picked one would silently return the wrong value on the other. * - * Precedence is "local letter wins when it exists", which is only safe because - * every successful server write clears the mirror (AvatarPicker.save). So a - * mirror can only survive while the server holds nothing newer, and the two can - * never disagree about which pick came last. + * A mirror only wins while the server key still matches what it was written + * against. Two things could otherwise strand it: a successful write on THIS + * device (handled by clearing the mirror in AvatarPicker.save), and a pick made + * on ANOTHER device, which this device only ever learns about by refetching — + * so the stored serverKey is what lets that refetch take precedence. * * The server snapshot is null on purpose: reading localStorage during render * would make the server and client markup differ and trip hydration. The letter @@ -23,12 +24,14 @@ import { readLetterAvatar, subscribeLetterAvatar } from './avatar-letter.storage * the same art whenever the letter matches the username's first character, so * in the common case nothing visibly changes. */ -export function useAvatarKey(serverKey: string | null | undefined, userId: string | undefined): string | null { - const localLetter = useSyncExternalStore( +export function useAvatarKey(rawServerKey: string | null | undefined, userId: string | undefined): string | null { + const serverKey = rawServerKey ?? null + const mirror = useSyncExternalStore( subscribeLetterAvatar, () => readLetterAvatar(userId), () => null ) - return localLetter ?? serverKey ?? null + const fresh = mirror && mirror.serverKey === serverKey ? mirror.key : null + return fresh ?? serverKey } diff --git a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx index cef0c66696..6c49504803 100644 --- a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx +++ b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx @@ -1,9 +1,16 @@ /** - * The card must not grow when the quote lands. Everything below the two amount - * fields — the fee rows, the delivery line — reads only the typed amount and - * the currency pair, both known synchronously from the URL, so gating any of it - * on `destinationAmount` meant the first paint was short and the CTA jumped down - * the moment the FX request resolved. + * Two rules that pull against each other. + * + * The card must not grow when the quote lands — that was the layout shift. + * But a fee or a delivery time is a CLAIM about a corridor, and marketing + * callers do not pass `restrictToRoutable`: they seed ~20 currencies the FX + * feed quotes but no rail supports. So the boxes hold their height on the + * typed amount, and the claims inside them wait for a landed quote. + * + * There is no "Peanut fee" row at all: mono/product/pricing.md forbids a + * separate visible Peanut-fee line outright — the displayed rate IS the + * disclosure — and the widget's own default USD→EUR corridor stacks ~100bps + * behind what used to read "Free!". */ import React from 'react' import { render, screen } from '@testing-library/react' @@ -39,30 +46,59 @@ const renderWidget = () => ) +/** The two boxes whose height was the layout shift, found without their text. */ +const feeCard = () => document.querySelector('.min-h-14') +const deliveryLine = () => document.querySelector('.min-h-4') + describe('ExchangeRateWidget before the quote arrives', () => { - it('already shows the fee rows and the delivery line while the rate is loading', () => { + it('holds the fee card and the delivery row open while the rate is loading', () => { mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isLoading: true })) renderWidget() - expect(screen.getByText('Bank fee')).toBeInTheDocument() - expect(screen.getByText('Peanut fee')).toBeInTheDocument() - expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() + // the space is reserved — this is the layout shift the PR set out to fix + expect(feeCard()).toBeInTheDocument() + expect(deliveryLine()).toBeInTheDocument() }) - it('keeps them when the rate fetch fails outright — the fees are free either way', () => { + it('makes no fee or delivery claim until a quote lands', () => { + mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isLoading: true })) + renderWidget() + + expect(screen.queryByText('Bank fee')).not.toBeInTheDocument() + expect(screen.queryByText('Should arrive in minutes.')).not.toBeInTheDocument() + }) + + it('never promises a delivery time next to "rate unavailable"', () => { mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isError: true })) renderWidget() expect(screen.getByText('Rate currently unavailable')).toBeInTheDocument() + expect(screen.queryByText('Should arrive in minutes.')).not.toBeInTheDocument() + expect(screen.queryByText('Bank fee')).not.toBeInTheDocument() + // the height is still held, so the CTA does not move when the retry lands + expect(feeCard()).toBeInTheDocument() + }) + + it('states the fee and the delivery time once the corridor is actually priced', () => { + mockUseExchangeRate.mockReturnValue(quote()) + renderWidget() + expect(screen.getByText('Bank fee')).toBeInTheDocument() expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() }) - it('drops them only when the user clears the amount', () => { + it('shows no separate Peanut fee line, priced or not', () => { + mockUseExchangeRate.mockReturnValue(quote()) + renderWidget() + + expect(screen.queryByText('Peanut fee')).not.toBeInTheDocument() + }) + + it('drops both boxes only when the user clears the amount', () => { mockUseExchangeRate.mockReturnValue(quote({ sourceAmount: '', destinationAmount: '' })) renderWidget() - expect(screen.queryByText('Bank fee')).not.toBeInTheDocument() - expect(screen.queryByText('Should arrive in minutes.')).not.toBeInTheDocument() + expect(feeCard()).not.toBeInTheDocument() + expect(deliveryLine()).not.toBeInTheDocument() }) }) diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index dc406d7fcb..8576dbea4c 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -238,10 +238,14 @@ const ExchangeRateWidget: FC = ({ // Determine delivery time text based on destination currency const deliveryTimeText = destinationCurrency === 'USD' ? l.arrivesHours : l.arrivesMinutes - // The source amount is known synchronously (URL, default 10); the quote is - // not. Reading it here is what lets the fee card and the delivery line hold - // their space from the first paint. + // Space is reserved whenever there is an amount, but a CLAIM about that + // corridor needs a landed quote. Marketing callers do not pass + // restrictToRoutable and seed ~20 currencies the FX feed quotes but no rail + // supports (see the prop comment), so "arrives in minutes" gated on the + // typed amount alone promised fulfilment on corridors with neither a rate + // nor a route — including while loading and alongside "rate unavailable". const hasAmount = typeof sourceAmount === 'number' && sourceAmount > 0 + const hasQuote = typeof destinationAmount === 'number' && destinationAmount > 0 && !isError // no exchange-rate board exists in figma (checked 2026-08-20) — container // rebuilt on the DS Card primitive (board 17802:61536) as the conservative @@ -374,22 +378,14 @@ const ExchangeRateWidget: FC = ({ )}
- {/* Gated on what the user typed, not on the quote. Both fees are free - on every pair, so nothing here needs the rate — and gating them on - `destinationAmount` meant the fee card and the delivery line were - absent on first paint and pushed the CTA down the moment the quote - landed. */} {hasAmount && ( -
-
-

{l.bankFee}

-

{l.free}

-
- -
-

{l.peanutFee}

-

{l.free}

-
+
+ {hasQuote && ( +
+

{l.bankFee}

+

{l.free}

+
+ )}
)} @@ -402,7 +398,9 @@ const ExchangeRateWidget: FC = ({ {ctaLabel} - {hasAmount &&

{deliveryTimeText}

} + {hasAmount && ( +

{hasQuote ? deliveryTimeText : ''}

+ )} ) } From f3bc12441418e979837b31612c0e9f571ae0399c Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Sat, 5 Sep 2026 17:44:32 +0100 Subject: [PATCH 4/5] fix(fx): a quote is not a route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip's remaining finding, and it predates this PR: the FX feed prices ~20 currencies no rail serves, so a landing page seeded with THB gets a positive destinationAmount and the widget answered with "Bank fee — Free!" and a delivery time for a corridor Peanut cannot fulfil. Gating on a landed quote fixed the loading and error states but not this one. The claims now also require both sides to resolve through toSupportedExchangeCurrency. That is asked of the PAIR, not of the caller: `restrictToRoutable` only says whether a caller clamps its own URL, so a marketing page opts out of it and would have kept the promise. The rate chip is deliberately untouched — a displayed rate is a quote, not a guarantee, and the "send money to Thailand" pages exist to show it. Covers the path Chip noted the previous tests missed: both marketing variants, one servable corridor and one quote-only. --- .../__tests__/no-quote-layout-shift.test.tsx | 28 +++++++++++++++++++ .../Global/ExchangeRateWidget/index.tsx | 10 ++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx index 6c49504803..08cdbd0e31 100644 --- a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx +++ b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx @@ -46,6 +46,14 @@ const renderWidget = () => ) +/** A landing page: no `restrictToRoutable`, seeded with a quote-only currency. */ +const renderMarketingWidget = (to: string) => + render( + + + + ) + /** The two boxes whose height was the layout shift, found without their text. */ const feeCard = () => document.querySelector('.min-h-14') const deliveryLine = () => document.querySelector('.min-h-4') @@ -94,6 +102,26 @@ describe('ExchangeRateWidget before the quote arrives', () => { expect(screen.queryByText('Peanut fee')).not.toBeInTheDocument() }) + it('makes no fee or delivery claim on a corridor with a rate but no rail', () => { + // THB is one of the ~20 the FX feed prices and no rail serves, so the + // quote lands and the promise still must not + mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: 340.2, exchangeRate: 34.02 })) + renderMarketingWidget('THB') + + expect(screen.queryByText('Bank fee')).not.toBeInTheDocument() + expect(screen.queryByText('Should arrive in minutes.')).not.toBeInTheDocument() + // the rate itself is fine to show — it is a quote, not a guarantee + expect(screen.getByText(/34\.0200 THB/)).toBeInTheDocument() + }) + + it('still states them on a marketing page whose corridor Peanut actually serves', () => { + mockUseExchangeRate.mockReturnValue(quote()) + renderMarketingWidget('EUR') + + expect(screen.getByText('Bank fee')).toBeInTheDocument() + expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() + }) + it('drops both boxes only when the user clears the amount', () => { mockUseExchangeRate.mockReturnValue(quote({ sourceAmount: '', destinationAmount: '' })) renderWidget() diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 8576dbea4c..7adbac7689 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -245,7 +245,15 @@ const ExchangeRateWidget: FC = ({ // typed amount alone promised fulfilment on corridors with neither a rate // nor a route — including while loading and alongside "rate unavailable". const hasAmount = typeof sourceAmount === 'number' && sourceAmount > 0 - const hasQuote = typeof destinationAmount === 'number' && destinationAmount > 0 && !isError + // A quote is not a route. The FX feed prices ~20 currencies no rail serves, + // so a marketing page can land a positive destinationAmount for a corridor + // Peanut cannot fulfil — checked here rather than via `restrictToRoutable`, + // which only says whether the CALLER clamps its URL, not whether this + // particular pair is servable. + const isRoutablePair = + toSupportedExchangeCurrency(sourceCurrency) !== null && + toSupportedExchangeCurrency(destinationCurrency) !== null + const hasQuote = typeof destinationAmount === 'number' && destinationAmount > 0 && !isError && isRoutablePair // no exchange-rate board exists in figma (checked 2026-08-20) — container // rebuilt on the DS Card primitive (board 17802:61536) as the conservative From 3b9024087233e1410f11d5bccb646829306e6ca5 Mon Sep 17 00:00:00 2001 From: innolope-dev Date: Sat, 5 Sep 2026 17:48:01 +0100 Subject: [PATCH 5/5] =?UTF-8?q?Revert=20"drop=20the=20Peanut=20fee=20row"?= =?UTF-8?q?=20=E2=80=94=20the=20fee=20really=20is=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BRIDGE_DEVELOPER_FEE_RATE is 0 in payment.consts.ts and the sibling backend constant matches, so "Peanut fee — Free!" states the truth today. Chip's own first review verified exactly that and cleared the label; the finding that removed it came from the third-opinion model, whose footer says to treat it as advice, and I weighted it over the verification instead of the other way round. My commit message then compounded it by calling the line wrong "on the number" — the ~100bps in pricing.md is the documented stack for when the FX margin is re-enabled, not what the code charges now. What pricing.md does say, independently of the number, is that fee visibility should be the rate rather than a line item. That is a display question about a zero fee, it predates this PR, and it is not a polish PR's to settle. The row keeps the quote gating from the previous commit: it states a fee only for a corridor that is priced AND routable. Reserved height goes to min-h-17, the two rows plus their gap. --- .../__tests__/no-quote-layout-shift.test.tsx | 18 +++++++++++------- .../Global/ExchangeRateWidget/index.tsx | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx index 08cdbd0e31..507fa45c56 100644 --- a/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx +++ b/src/components/Global/ExchangeRateWidget/__tests__/no-quote-layout-shift.test.tsx @@ -7,13 +7,13 @@ * feed quotes but no rail supports. So the boxes hold their height on the * typed amount, and the claims inside them wait for a landed quote. * - * There is no "Peanut fee" row at all: mono/product/pricing.md forbids a - * separate visible Peanut-fee line outright — the displayed rate IS the - * disclosure — and the widget's own default USD→EUR corridor stacks ~100bps - * behind what used to read "Free!". + * The "Peanut fee — Free!" row stands: BRIDGE_DEVELOPER_FEE_RATE is 0 here and + * on the backend, so the label is accurate today. Whether a zero fee should be + * a visible LINE at all is a separate question — mono/product/pricing.md wants + * fee visibility to be the rate — and it is not this PR's to settle. */ import React from 'react' -import { render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import ExchangeRateWidget from '../index' @@ -55,7 +55,7 @@ const renderMarketingWidget = (to: string) => ) /** The two boxes whose height was the layout shift, found without their text. */ -const feeCard = () => document.querySelector('.min-h-14') +const feeCard = () => document.querySelector('.min-h-17') const deliveryLine = () => document.querySelector('.min-h-4') describe('ExchangeRateWidget before the quote arrives', () => { @@ -95,10 +95,14 @@ describe('ExchangeRateWidget before the quote arrives', () => { expect(screen.getByText('Should arrive in minutes.')).toBeInTheDocument() }) - it('shows no separate Peanut fee line, priced or not', () => { + it('states both fees once priced, and neither before', () => { mockUseExchangeRate.mockReturnValue(quote()) renderWidget() + expect(screen.getByText('Peanut fee')).toBeInTheDocument() + cleanup() + mockUseExchangeRate.mockReturnValue(quote({ destinationAmount: '', exchangeRate: 0, isLoading: true })) + renderWidget() expect(screen.queryByText('Peanut fee')).not.toBeInTheDocument() }) diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 7adbac7689..8e76d729c2 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -387,12 +387,19 @@ const ExchangeRateWidget: FC = ({
{hasAmount && ( -
+
{hasQuote && ( -
-

{l.bankFee}

-

{l.free}

-
+ <> +
+

{l.bankFee}

+

{l.free}

+
+ +
+

{l.peanutFee}

+

{l.free}

+
+ )}
)}