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
4 changes: 3 additions & 1 deletion scripts/__tests__/ds-lint-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ describe('fontWeightOnTypeToken (countWeightStacks)', () => {
'font-[650.5]',
'font-(weight:--my-weight)',
'font-[weight:var(--my-font-weight)]',
// untyped custom property = font-weight in tailwind 4
'font-(--my-weight)',
]) {
expect(countWeightStacks(`<p className="text-body-s ${w}" />`)).toBe(1)
}
for (const notWeight of ['font-sans', 'font-roboto', 'font-(--brand-face)', 'font-blackout']) {
for (const notWeight of ['font-sans', 'font-roboto', 'font-(family-name:--brand-face)', 'font-blackout']) {
expect(countWeightStacks(`<p className="text-body-s ${notWeight}" />`)).toBe(0)
}
})
Expand Down
7 changes: 4 additions & 3 deletions scripts/ds-lint-rules.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ function countOffScaleSpacing(text) {
// per-line pass over the remaining text.
// stock weight names + the theme's own extraBlack (globals.css
// --font-weight-extraBlack, registered in tw.ts) + arbitrary numeric brackets
// (decimals included) + the font-(weight:…) custom-property form. bare
// font-(--x) is a font-FAMILY custom property, not a weight — excluded.
// (decimals included) + the font-(weight:…) and bare font-(--x) custom-property
// forms: tailwind 4 compiles an untyped font-(--x) as font-WEIGHT, and a family
// needs the family-name: hint, so only that typed form is excluded.
const WEIGHT_STACK_RE =
/\bfont-(?:thin|extralight|light|normal|medium|semibold|extrabold|extraBlack|bold|black|\[[0-9]+(?:\.[0-9]+)?\]|\[weight:[^\]]+\]|\(weight:[^)]+\))(?![a-zA-Z0-9-])/
/\bfont-(?:thin|extralight|light|normal|medium|semibold|extrabold|extraBlack|bold|black|\[[0-9]+(?:\.[0-9]+)?\]|\[weight:[^\]]+\]|\(weight:[^)]+\)|\(--[^)]+\))(?![a-zA-Z0-9-])/
const TYPE_TOKEN_RE = /\btext-(?:body|heading|label|button)-[a-z-]+\b/

function classNameExpressions(text) {
Expand Down
8 changes: 4 additions & 4 deletions src/components/Claim/Claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ export const Claim = ({}) => {

// find the claimed event for timestamp
const claimedEvent = claimLinkData.events?.find((e) => e.status === 'CLAIMED')
// the sender's cancel/reclaim stamp; the events fallback only carries a
// date when a claim attempt was recorded against the link
const cancelledStamp = claimLinkData.cancelledAt ?? claimLinkData.events?.[0]?.timestamp
Comment thread
innolope-dev marked this conversation as resolved.

let details: Partial<TransactionDetails> = {
id: claimLinkData.pubKey,
Expand All @@ -179,10 +182,7 @@ export const Claim = ({}) => {
initials: getInitialsFromName(recipientName),
memo: claimLinkData.textContent,
attachmentUrl: claimLinkData.fileUrl,
cancelledDate:
status === 'cancelled' && claimLinkData.events?.[0]
? new Date(claimLinkData.events[0].timestamp)
: undefined,
cancelledDate: status === 'cancelled' && cancelledStamp ? new Date(cancelledStamp) : undefined,
txHash: claimLinkData.claim?.txHash,
extraDataForDrawer: {
isLinkTransaction: true,
Expand Down
30 changes: 29 additions & 1 deletion src/components/Claim/__tests__/claim-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ jest.mock('@/components/TransactionDetails/transactionTransformer', () => ({
REWARD_TOKENS: {},
}))

const mockReceipt = jest.fn((_props: any) => <div data-testid="transaction-details-receipt">Receipt</div>)
jest.mock('@/components/TransactionDetails/TransactionDetailsReceipt', () => ({
TransactionDetailsReceipt: (_props: any) => <div data-testid="transaction-details-receipt">Receipt</div>,
TransactionDetailsReceipt: (props: any) => mockReceipt(props),
}))

jest.mock('@/context/ModalsContext', () => ({
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof rtlRender>[0]) => rtlRender(ui, { wrapper: IntlWrapper })

Expand All @@ -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,
}),
}))
Expand Down Expand Up @@ -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(<SupportDrawer />)

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(<SupportDrawer />)

let consumed = true
act(() => {
consumed = dispatchBackPress()
})

expect(consumed).toBe(false)
expect(mockSetIsSupportModalOpen).not.toHaveBeenCalled()
})
})
9 changes: 9 additions & 0 deletions src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions src/components/LandingPage/StickyMobileCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,24 @@ export function StickyMobileCTA({ strings }: { strings: LandingStrings }) {
</Button>
</a>
) : (
<Link prefetch={false} href="/setup" className="pointer-events-auto block">
<Button variant="purple" shadowSize="4" className="w-full py-3 text-base font-extrabold">
{strings.signUpNow}
</Button>
</Link>
<div className="pointer-events-auto flex items-center gap-4">
<Link prefetch={false} href="/setup" className="block flex-1">
<Button
variant="purple"
shadowSize="4"
className="w-full py-3 text-base font-extrabold"
>
{strings.signUpNow}
</Button>
</Link>
<Link
prefetch={false}
href="/setup?step=login"
className="shrink-0 text-body-s text-n-1 underline"
>
{strings.logIn}
</Link>
</div>
)}
</div>
}
Expand Down
10 changes: 10 additions & 0 deletions src/components/LandingPage/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ export function Hero({
</span>
{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). */}
<Link
prefetch={false}
href="/setup?step=login"
className="mt-4 block text-center text-body-s text-n-1 underline"
>
{strings.logIn}
</Link>
<AnimateOnView
className="absolute bottom-[-4%] left-[1%] w-8 sm:bottom-[11%] sm:left-[12%] md:bottom-[18%] md:left-[5%] md:w-12"
y="20px"
Expand Down
2 changes: 2 additions & 0 deletions src/components/LandingPage/landingStrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { LandingProblemStrings, LandingSupportedRailsStrings } from './land
// catalogs (same reason ContentLanding/HelpLanding take `strings`).
export interface LandingStrings {
signUp: string
logIn: string
signUpNow: string
sendNow: string
sendMoney: string
Expand Down Expand Up @@ -48,6 +49,7 @@ export interface LandingStrings {
export function landingStrings(i18n: Translations): LandingStrings {
return {
signUp: i18n.landingSignUp,
logIn: i18n.landingLogIn,
signUpNow: i18n.landingSignUpNow,
sendNow: i18n.landingSendNow,
sendMoney: i18n.sendMoney,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"noContentResults": "Nothing matches your search.",
"backTo": "Back to {name}",
"landingSignUp": "SIGN UP",
"landingLogIn": "Log in",
"landingLearnMore": "Learn more",
"landingGlobalCashLine1": "GLOBAL CASH.",
"landingGlobalCashLine2": "LOCAL FEEL",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"noContentResults": "Nada coincide con tu búsqueda.",
"backTo": "Volver a {name}",
"landingSignUp": "CREAR CUENTA",
"landingLogIn": "Iniciar sesión",
"landingLearnMore": "Más información",
"landingGlobalCashLine1": "DINERO GLOBAL.",
"landingGlobalCashLine2": "SABOR LOCAL",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/es-ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"noContentResults": "Nada coincide con tu búsqueda.",
"backTo": "Volver a {name}",
"landingSignUp": "CREAR CUENTA",
"landingLogIn": "Iniciar sesión",
"landingLearnMore": "Más información",
"landingGlobalCashLine1": "PLATA GLOBAL.",
"landingGlobalCashLine2": "SABOR LOCAL",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"noContentResults": "Nada corresponde à sua busca.",
"backTo": "Voltar para {name}",
"landingSignUp": "CRIAR CONTA",
"landingLogIn": "Entrar",
"landingLearnMore": "Saiba mais",
"landingGlobalCashLine1": "DINHEIRO GLOBAL.",
"landingGlobalCashLine2": "COM JEITO LOCAL",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface Translations {

// Landing page — shared chrome
landingSignUp: string
landingLogIn: string
landingLearnMore: string

// Landing page — "global cash, local feel" section
Expand Down
2 changes: 2 additions & 0 deletions src/services/services.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ export type SendLink = {
* claim path is answered 202 before the broadcast, so this is the only
* thing a poller can read to tell a retryable outage from a dead end. */
claimFailureCode?: string | null
/** Stamped on cancel/reclaim — the receipt's cancellation date. */
cancelledAt?: Date | string | null
createdAt: Date
senderAddress: string
amount: bigint
Expand Down
7 changes: 7 additions & 0 deletions src/utils/__tests__/native-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,13 @@ describe('redactNativePath (deep-link telemetry)', () => {
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', () => {
Expand Down
9 changes: 6 additions & 3 deletions src/utils/native-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading