,
+ TransactionDetailsReceipt: (props: any) => mockReceipt(props),
}))
jest.mock('@/context/ModalsContext', () => ({
@@ -415,6 +416,33 @@ describe('GROUP 3: Already Claimed / Cancelled', () => {
})
})
+ // A sender's cancel/reclaim leaves no SEND_LINK_CLAIM intent behind, so the
+ // `events` fallback is empty and the receipt used to show no cancellation
+ // date at all. GET /send-links carries the row's own cancelledAt as of
+ // peanut-api-ts#1525; until that ships the fallback keeps today's behaviour.
+ test('CANCELLED receipt shows the cancellation date from cancelledAt', async () => {
+ mockUseAuth.mockReturnValue({
+ user: { user: { userId: 'sender-123' } },
+ isFetchingUser: false,
+ fetchUser: jest.fn(),
+ })
+ mockSendLinksApi.get.mockResolvedValue(
+ makeSendLink({
+ status: 'CANCELLED',
+ cancelledAt: '2026-04-20T12:00:00.000Z',
+ sender: { userId: 'sender-123', username: 'alice' },
+ })
+ )
+
+ renderClaim()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('transaction-details-receipt')).toBeInTheDocument()
+ })
+ const { transaction } = mockReceipt.mock.calls.at(-1)![0]
+ expect(transaction.cancelledDate).toEqual(new Date('2026-04-20T12:00:00.000Z'))
+ })
+
test('CLAIMING link (in progress) shows as already claimed', async () => {
const link = makeSendLink({ status: 'CLAIMING' })
mockSendLinksApi.get.mockResolvedValue(link)
diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
index a26cd977a8..cba2e22ef7 100644
--- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
+++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx
@@ -19,6 +19,7 @@ import { IntlWrapper } from '@/test-utils/intl'
import SupportDrawer from '../index'
import { isCapacitor } from '@/utils/capacitor'
import { SUPPORT_EMAIL } from '@/constants/crisp'
+import { dispatchBackPress, resetBackHandlersForTests } from '@/utils/back-handler'
const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper })
@@ -40,10 +41,11 @@ const modalsState: { supportPrefilledMessage: string | undefined; isSupportModal
supportPrefilledMessage: undefined,
isSupportModalOpen: true,
}
+const mockSetIsSupportModalOpen = jest.fn()
jest.mock('@/context/ModalsContext', () => ({
useModalsContext: () => ({
isSupportModalOpen: modalsState.isSupportModalOpen,
- setIsSupportModalOpen: jest.fn(),
+ setIsSupportModalOpen: mockSetIsSupportModalOpen,
supportPrefilledMessage: modalsState.supportPrefilledMessage,
}),
}))
@@ -671,3 +673,42 @@ describe('SupportDrawer — native open runs once per open cycle', () => {
expect(nativeCrisp.sendMessage).not.toHaveBeenCalled()
})
})
+
+// The hand-rolled overlay was left off the LIFO back stack PR #2920 gave the DS
+// Drawer and Modal, so Android back with the sheet open navigated the page
+// underneath instead of closing the sheet.
+describe('SupportDrawer — Android hardware back', () => {
+ beforeEach(() => {
+ mockUseCrispUserData.mockReset().mockReturnValue({})
+ mockUseCrispTokenId.mockReset().mockReturnValue(undefined)
+ mockIsCapacitor.mockReset().mockReturnValue(false)
+ mockSetIsSupportModalOpen.mockReset()
+ resetBackHandlersForTests()
+ })
+
+ it('closes the sheet and consumes the press while open', () => {
+ modalsState.isSupportModalOpen = true
+ render()
+
+ let consumed = false
+ act(() => {
+ consumed = dispatchBackPress()
+ })
+
+ expect(consumed).toBe(true)
+ expect(mockSetIsSupportModalOpen).toHaveBeenCalledWith(false)
+ })
+
+ it('leaves the press to the page while closed', () => {
+ modalsState.isSupportModalOpen = false
+ render()
+
+ let consumed = true
+ act(() => {
+ consumed = dispatchBackPress()
+ })
+
+ expect(consumed).toBe(false)
+ expect(mockSetIsSupportModalOpen).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index dcb3b52326..0ecda33cc0 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -6,6 +6,7 @@ import { useModalsContext } from '@/context/ModalsContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
import { useCrispTokenId } from '@/hooks/useCrispTokenId'
import { useVisualViewport } from '@/hooks/useVisualViewport'
+import { useBackHandler } from '@/hooks/useBackHandler'
import Loading from '../Loading'
import { Button } from '@/components/0_Bruddle/Button'
import {
@@ -389,6 +390,14 @@ const SupportDrawer = () => {
return () => window.removeEventListener('message', handleMessage)
}, [])
+ // Android hardware back closes the sheet instead of navigating the page
+ // underneath. Hand-rolled overlay, so it registers itself (the DS Drawer
+ // and Modal do this internally).
+ useBackHandler(() => {
+ setIsSupportModalOpen(false)
+ return true
+ }, isSupportModalOpen)
+
// close on escape
useEffect(() => {
if (!isSupportModalOpen) return
diff --git a/src/components/LandingPage/StickyMobileCTA.tsx b/src/components/LandingPage/StickyMobileCTA.tsx
index 5c502e08a7..45ab2e73dc 100644
--- a/src/components/LandingPage/StickyMobileCTA.tsx
+++ b/src/components/LandingPage/StickyMobileCTA.tsx
@@ -93,11 +93,24 @@ export function StickyMobileCTA({ strings }: { strings: LandingStrings }) {
) : (
-
-
-
+
+
+
+
+
+ {strings.logIn}
+
+
)}
}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index d1c8ceab4a..79a3cc41b2 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -240,6 +240,16 @@ export function Hero({
{primaryCta ? renderCTAButton(primaryCta, 'primary') : customCta ? renderCustomCta() : null}
{secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
+ {/* Returning users with an expired session had no way back in from the
+ marketing site: every CTA pointed at signup. `?step=login` lands on
+ the passkey Log In step (setup-entry.ts). */}
+
+ {strings.logIn}
+
{
expect(redactNativePath('/not-a-declared-route/secret-value')).toBe('/:id/:id')
})
+ // The authority can carry userinfo, which is attacker-controlled on a link
+ // and would otherwise survive into `raw` next to the redacted path.
+ it('drops userinfo from the authority', () => {
+ expect(redactNativePath('https://CLAIM_SECRET@peanut.me/qr/aB3xK9mQ2pL7vN4z')).toBe('https://peanut.me/qr/:id')
+ expect(redactNativePath('https://user:pass@peanut.me/home')).toBe('https://peanut.me/home')
+ })
+
// A locale or other prefix must not shift the root out of a positional
// window and turn the whole path into placeholders.
it('finds the route family behind a prefix segment', () => {
diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts
index 3990ae9e59..bb408e2980 100644
--- a/src/utils/native-routes.ts
+++ b/src/utils/native-routes.ts
@@ -370,9 +370,12 @@ const TELEMETRY_SAFE_SEGMENTS = new Set(['success', 'bank', 'manteca', 'crypto',
export function redactNativePath(value: string): string {
const beforeQuery = value.split('#')[0].split('?')[0]
// Keep scheme://host so a peanut.me universal link stays distinguishable
- // from a custom-scheme launch — neither carries an identifier.
- const prefix = beforeQuery.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i)?.[0] ?? ''
- const path = beforeQuery.slice(prefix.length)
+ // from a custom-scheme launch — neither carries an identifier. Userinfo
+ // (`https://secret@peanut.me/…`) is dropped: it is attacker-controlled
+ // input on a link and would otherwise ride into telemetry intact.
+ const authority = beforeQuery.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i)?.[0] ?? ''
+ const prefix = authority.replace(/\/\/[^/]*@/, '//')
+ const path = beforeQuery.slice(authority.length)
const redacted = path
.split('/')
.map((segment) => {