diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 7cbf2592d..44af109d3 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -9,7 +9,7 @@ import { useRouter, useParams } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { countryData } from '@/components/AddMoney/consts' import { formatCurrencyAmount } from '@/utils/currency' -import { formatBankAccountDisplay } from '@/utils/format.utils' +import { formatBankAccountDisplay, shortDepositReference } from '@/utils/format.utils' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils' import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { formatAmount } from '@/utils/general.utils' @@ -236,7 +236,7 @@ ${routingLabel}: ${routingValue}` } bankDetails += ` -Deposit Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} +Deposit Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'} Please use these details to complete your bank transfer.` @@ -274,11 +274,11 @@ Please use these details to complete your bank transfer.`

Deposit reference

- {onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} + {shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'}

{onrampData?.depositInstructions?.depositMessage && ( @@ -416,7 +416,7 @@ Please use these details to complete your bank transfer.` title="Double check in your bank before sending:" items={[ `Amount: ${formattedCurrencyAmount} (exact)`, - `Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} (included)`, + `Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'} (included)`, ]} /> diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index 9ae96095b..59c94c252 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -149,6 +149,31 @@ describe('SupportDrawer — Crisp load-failure fallback', () => { }) }) +describe('SupportDrawer — pointer-events when opened inside a vaul drawer', () => { + beforeEach(() => { + mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined }) + mockUseCrispTokenId.mockReset().mockReturnValue(undefined) + mockIsCapacitor.mockReset().mockReturnValue(false) + }) + + it('backdrop and panel explicitly re-enable pointer events while open', () => { + // vaul sets pointer-events:none on while the transaction drawer is + // open. Without an explicit pointer-events-auto, the support overlay + // inherits none and becomes click-transparent — taps fall through to the + // receipt underneath (one landed on "Cancel deposit" and cancelled a + // user's funded bank deposit). + const { container } = render() + + const backdrop = container.querySelector('[aria-hidden="true"]') + const panel = screen.getByRole('dialog', { name: 'Support' }) + + expect(backdrop?.className).toContain('pointer-events-auto') + expect(panel.className).toContain('pointer-events-auto') + expect(backdrop?.className).not.toContain('pointer-events-none') + expect(panel.className).not.toContain('pointer-events-none') + }) +}) + describe('SupportDrawer Crisp session gate — native (Capacitor)', () => { beforeEach(() => { mockUseCrispUserData.mockReset() diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 83dc7d740..3fe482d63 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -131,9 +131,15 @@ const SupportDrawer = () => { return ( <> {/* backdrop */} + {/* pointer-events-auto is load-bearing on BOTH divs: when this drawer is + opened from inside a vaul drawer (transaction receipt), vaul sets + pointer-events:none on and these divs inherit it — the whole + support overlay becomes click-transparent, and taps fall through to + the receipt underneath (a fall-through tap on "Cancel deposit" + cancelled a user's funded bank deposit). */}
setIsSupportModalOpen(false)} aria-hidden="true" @@ -147,7 +153,7 @@ const SupportDrawer = () => { aria-label="Support" aria-modal={isSupportModalOpen} className={`fixed inset-x-0 bottom-0 z-[999999] flex max-h-[85vh] flex-col rounded-t-[10px] border bg-background pt-4 ${ - isSupportModalOpen ? 'translate-y-0' : 'pointer-events-none translate-y-full' + isSupportModalOpen ? 'pointer-events-auto translate-y-0' : 'pointer-events-none translate-y-full' }`} style={{ transform: isSupportModalOpen ? `translateY(${dragOffset}px)` : 'translateY(100%)', diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx index daa566a9c..7aae89c8c 100644 --- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx +++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, type ReactNode } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { Button } from '@/components/0_Bruddle/Button' +import ActionModal from '@/components/Global/ActionModal' import ErrorAlert from '@/components/Global/ErrorAlert' import { Icon } from '@/components/Global/Icons/Icon' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' @@ -40,6 +41,18 @@ export function CancelDepositActions({ }) { const queryClient = useQueryClient() const [error, setError] = useState(null) + // Cancels are irreversible and the button sits next to the support link — + // a real user cancelled a funded deposit while trying to report a problem + // (no way to match the wire once cancelled). Every cancel confirms first. + const [pendingCancel, setPendingCancel] = useState<{ noun: string; run: () => Promise } | null>(null) + // Visibility is separate from pendingCancel so the noun stays rendered + // during the modal's fade-out (nulling it mid-fade flashed 'deposit' + // over 'request' titles). + const [confirmOpen, setConfirmOpen] = useState(false) + // Ref, not state: a double-tap on the confirm CTA during the modal's + // fade-out lands both clicks before a re-render, so a state guard would + // let the cancel fire twice. Refs are synchronous. + const isCancelRunning = useRef(false) if (!setIsLoading || !onClose) return null const refetchAndClose = () => @@ -63,11 +76,53 @@ export function CancelDepositActions({ } } - // Render the active cancel button (if any) alongside any failure message. + const armCancel = (noun: string, run: () => Promise) => { + setPendingCancel({ noun, run }) + setConfirmOpen(true) + } + + const confirmThenRun = async () => { + if (!pendingCancel || isCancelRunning.current) return + isCancelRunning.current = true + setConfirmOpen(false) + try { + await wrapAction(pendingCancel.run) + } finally { + isCancelRunning.current = false + } + } + + // Render the active cancel button (if any) alongside the shared + // confirmation modal and any failure message. const withError = (button: ReactNode) => (
{button} {error && } + setConfirmOpen(false)} + icon="ban" + title={`Cancel this ${pendingCancel?.noun ?? 'deposit'}?`} + modalClassName="!z-[9999] pointer-events-auto" + description={ + <> + This can't be undone. If you already sent the bank transfer, don't cancel — a + cancelled deposit can no longer be matched to your account, and the money will be returned to + your bank instead. + + } + modalPanelClassName="max-w-sm mx-8 !z-[9999] pointer-events-auto" + contentContainerClassName="relative pointer-events-auto" + classOverlay="!bg-black/40 !z-[9998]" + ctas={[ + { + text: `Yes, cancel ${pendingCancel?.noun ?? 'deposit'}`, + shadowSize: '4', + className: 'md:py-2', + onClick: confirmThenRun, + }, + ]} + />
) @@ -84,7 +139,7 @@ export function CancelDepositActions({ - wrapAction(async () => { + armCancel('deposit', async () => { const result = await cancelOnramp(transaction.id) if (result.error) throw new Error(result.error) }) @@ -101,7 +156,7 @@ export function CancelDepositActions({ - wrapAction(async () => { + armCancel('deposit', async () => { const result = await mantecaApi.cancelDeposit(transaction.id) if (result.error) throw new Error(result.error) }) @@ -123,7 +178,7 @@ export function CancelDepositActions({ label="Cancel Request" disabled={!!isLoading} onClick={() => - wrapAction(async () => { + armCancel('request', async () => { const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId if (!bridgeTransferId) { throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction') diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx new file mode 100644 index 000000000..93dc9497d --- /dev/null +++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx @@ -0,0 +1,155 @@ +/** + * CancelDepositActions — cancel buttons for pending bank-deposit flows. + * + * Regression tests for the instant-cancel bug: the cancel button fired + * cancelOnramp straight from onClick with no confirmation, sitting right next + * to the support link — a real user cancelled a funded deposit while trying to + * report a problem, making the wire unmatchable. Every cancel must go through + * an explicit confirmation first. Nested primitives are stubbed so only this + * component's own logic is under test. + */ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const mockCancelOnramp = jest.fn() +jest.mock('@/app/actions/onramp', () => ({ + cancelOnramp: (...args: unknown[]) => mockCancelOnramp(...args), +})) +const mockMantecaCancel = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { cancelDeposit: (...args: unknown[]) => mockMantecaCancel(...args) }, +})) +jest.mock('@/services/charges', () => ({ + chargesApi: { cancel: jest.fn() }, +})) +const mockInvalidateQueries = jest.fn() +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) +jest.mock('@/components/TransactionDetails/transaction-predicates', () => ({ + isMantecaOnrampEntry: () => false, + isRequestEntry: () => false, +})) +jest.mock('@/hooks/useTransactionHistory', () => ({ + EHistoryUserRole: { SENDER: 'SENDER' }, +})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +jest.mock('@/components/Global/ErrorAlert', () => ({ + __esModule: true, + default: ({ description }: { description: string }) =>
{description}
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => })) +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: ({ + visible, + title, + ctas, + onClose, + }: { + visible: boolean + title: React.ReactNode + ctas?: Array<{ text: string; onClick?: () => void }> + onClose: () => void + }) => + visible ? ( +
+

{title}

+ {ctas?.map((cta) => ( + + ))} + +
+ ) : null, +})) + +// import must come after jest.mock +import { CancelDepositActions } from '../CancelDepositActions' + +const pendingBridgeOnramp = { + id: 'tx-1', + direction: 'bank_deposit', + status: 'pending', + extraDataForDrawer: { depositInstructions: { deposit_message: 'BRGTESTREF1234567890' } }, +} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails + +const renderCancel = () => + render( + + ) + +beforeEach(() => { + mockCancelOnramp.mockReset().mockResolvedValue({}) + mockMantecaCancel.mockReset() + mockInvalidateQueries.mockReset().mockResolvedValue(undefined) +}) + +describe('CancelDepositActions confirmation gate', () => { + it('does NOT cancel on the first click — it asks for confirmation instead', () => { + renderCancel() + + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Cancel deposit')) + + expect(mockCancelOnramp).not.toHaveBeenCalled() + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('Cancel this deposit?')).toBeInTheDocument() + }) + + it('cancels only after the user confirms', async () => { + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + fireEvent.click(screen.getByText('Yes, cancel deposit')) + + await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledWith('tx-1')) + }) + + it('double-tapping the confirm button fires the cancel exactly once', async () => { + // keep the cancel in-flight so the second tap lands while the first runs + let resolveCancel: (v: unknown) => void + mockCancelOnramp.mockReturnValue(new Promise((resolve) => (resolveCancel = resolve))) + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + const confirmButton = screen.getByText('Yes, cancel deposit') + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + + resolveCancel!({}) + await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledTimes(1)) + }) + + it('dismissing the confirmation leaves the deposit untouched', () => { + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + fireEvent.click(screen.getByText('Dismiss')) + + expect(mockCancelOnramp).not.toHaveBeenCalled() + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx index 0d9b016a5..18e75a1e6 100644 --- a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx +++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx @@ -7,6 +7,7 @@ import CopyToClipboard from '@/components/Global/CopyToClipboard' import MoreInfo from '@/components/Global/MoreInfo' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME } from '@/constants/payment.consts' +import { shortDepositReference } from '@/utils/format.utils' import { formatIban } from '@/utils/general.utils' /** @@ -36,12 +37,14 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa } value={
- {/* Display can wrap / be truncated visually via CSS, but - the copyable text MUST be the full reference — Bridge - won't reconcile a deposit if the user enters the - truncated form. */} - {instructions.deposit_message} - + {/* Same shortened form as the Add Money screen — rationale on + shortDepositReference. Showing the full form only here made + users think they wired with the "wrong" code. */} + {shortDepositReference(instructions.deposit_message)} +
} hideBottomBorder={false} diff --git a/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx new file mode 100644 index 000000000..38872cc8b --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx @@ -0,0 +1,59 @@ +/** + * BridgeDepositInstructions — the deposit-instructions block in the + * transaction drawer. + * + * The reference here must be the SAME shortened 10-char form the Add Money + * screen shows (some banks cap reference fields at 10 chars; Bridge matches + * deposits on the partial reference). Showing the full form only here made a + * user believe they wired with the wrong code — the two-different-codes + * confusion. Nested primitives are stubbed. + */ +import React from 'react' +import { render, screen } from '@testing-library/react' + +jest.mock('@/components/Payment/PaymentInfoRow', () => ({ + PaymentInfoRow: ({ label, value }: { label: React.ReactNode; value: React.ReactNode }) => ( +
+ {label} + {value} +
+ ), +})) +jest.mock('@/components/Global/CopyToClipboard', () => ({ + __esModule: true, + default: ({ textToCopy }: { textToCopy: string }) =>
, +})) +jest.mock('@/components/Global/MoreInfo', () => ({ __esModule: true, default: () => })) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => })) + +// import must come after jest.mock +import { BridgeDepositInstructions } from '../BridgeDepositInstructions' + +const FULL_REFERENCE = 'BRGTESTREF1234567890' +const SHORT_REFERENCE = FULL_REFERENCE.slice(0, 10) + +const transaction = { + extraDataForDrawer: { + depositInstructions: { + deposit_message: FULL_REFERENCE, + bank_name: 'Deutsche Bank', + bank_address: 'Frankfurt, Germany', + account_holder_name: 'Peanut Protocol', + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', + }, + }, +} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails + +describe('BridgeDepositInstructions deposit message', () => { + it('shows and copies the same shortened 10-char reference as the Add Money screen', () => { + render() + + expect(screen.getByText(SHORT_REFERENCE)).toBeInTheDocument() + expect(screen.queryByText(FULL_REFERENCE)).not.toBeInTheDocument() + + const copyTexts = screen.getAllByTestId('copy').map((el) => el.getAttribute('data-text')) + expect(copyTexts).toContain(SHORT_REFERENCE) + expect(copyTexts).not.toContain(FULL_REFERENCE) + }) +}) diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index 773f78129..90c061ba4 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -53,3 +53,14 @@ export const formatBankAccountDisplay = (value: string | undefined, type?: 'iban } export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + +// Some banks (e.g. Wise) cap the transfer-reference field at 10 characters, and +// Bridge matches incoming deposits on this partial reference. Every surface that +// shows or copies a Bridge deposit reference must use this same shortened form — +// different lengths on different screens made a user believe he wired with the +// wrong code (two-different-codes confusion, peanut-ui#2416). +export function shortDepositReference(reference: string): string +export function shortDepositReference(reference: string | undefined): string | undefined +export function shortDepositReference(reference: string | undefined): string | undefined { + return reference?.slice(0, 10) +}