Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
29 changes: 28 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,32 @@ 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 now carries the row's own cancelledAt.
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
Loading