Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/components/AddMoney/components/AddMoneyBankDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.`

Expand Down Expand Up @@ -274,11 +274,11 @@ Please use these details to complete your bank transfer.`
<p className="text-xs font-normal text-gray-1">Deposit reference</p>
<div className="flex items-baseline gap-2">
<p className="text-xl font-extrabold text-black md:text-4xl">
{onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'}
{shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'}
</p>
{onrampData?.depositInstructions?.depositMessage && (
<CopyToClipboard
textToCopy={onrampData.depositInstructions.depositMessage?.slice(0, 10)}
textToCopy={shortDepositReference(onrampData.depositInstructions.depositMessage)}
fill="black"
iconSize="4"
/>
Expand Down Expand Up @@ -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)`,
]}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> 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(<SupportDrawer />)

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()
Expand Down
10 changes: 8 additions & 2 deletions src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body> 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). */}
<div
className={`fixed inset-0 z-[999998] bg-black/80 transition-opacity duration-300 ${
isSupportModalOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
isSupportModalOpen ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'
}`}
onClick={() => setIsSupportModalOpen(false)}
aria-hidden="true"
Expand All @@ -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%)',
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,6 +41,18 @@ export function CancelDepositActions({
}) {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(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<void> } | null>(null)
Comment thread
abalinda marked this conversation as resolved.
// 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 = () =>
Expand All @@ -63,11 +76,53 @@ export function CancelDepositActions({
}
}

// Render the active cancel button (if any) alongside any failure message.
const armCancel = (noun: string, run: () => Promise<void>) => {
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) => (
<div className="flex w-full flex-col gap-2">
{button}
{error && <ErrorAlert description={error} />}
<ActionModal
visible={confirmOpen}
onClose={() => 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, <strong>don't cancel</strong> β€” 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,
},
]}
/>
</div>
)

Expand All @@ -84,7 +139,7 @@ export function CancelDepositActions({
<CancelButton
disabled={!!isLoading}
onClick={() =>
wrapAction(async () => {
armCancel('deposit', async () => {
const result = await cancelOnramp(transaction.id)
if (result.error) throw new Error(result.error)
})
Expand All @@ -101,7 +156,7 @@ export function CancelDepositActions({
<CancelButton
disabled={!!isLoading}
onClick={() =>
wrapAction(async () => {
armCancel('deposit', async () => {
const result = await mantecaApi.cancelDeposit(transaction.id)
if (result.error) throw new Error(result.error)
})
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
jest.mock('@/components/Global/ErrorAlert', () => ({
__esModule: true,
default: ({ description }: { description: string }) => <div data-testid="error">{description}</div>,
}))
jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => <span /> }))
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 ? (
<div data-testid="confirm-modal">
<p>{title}</p>
{ctas?.map((cta) => (
<button key={cta.text} onClick={cta.onClick}>
{cta.text}
</button>
))}
<button onClick={onClose}>Dismiss</button>
</div>
) : 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(
<CancelDepositActions
transaction={pendingBridgeOnramp}
isPendingBankRequest={false}
isLoading={false}
setIsLoading={jest.fn()}
onClose={jest.fn()}
/>
)

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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -36,12 +37,14 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa
}
value={
<div className="flex items-center gap-2">
{/* 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. */}
<span className="break-all">{instructions.deposit_message}</span>
<CopyToClipboard textToCopy={instructions.deposit_message} iconSize="4" />
{/* 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. */}
<span className="break-all">{shortDepositReference(instructions.deposit_message)}</span>
<CopyToClipboard
textToCopy={shortDepositReference(instructions.deposit_message)}
iconSize="4"
/>
</div>
}
hideBottomBorder={false}
Expand Down
Loading
Loading