diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx index 8154976a6b..72a033da0c 100644 --- a/src/components/Card/PhysicalCardScreen.tsx +++ b/src/components/Card/PhysicalCardScreen.tsx @@ -76,7 +76,9 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {

You are on the list!

- {`You are #${data.position} on the list. We'll let you know when cards are ready to be shipped.`} + {data.position === null + ? `You are on the list. We'll let you know when cards are ready to be shipped.` + : `You are #${data.position.toLocaleString()} on the list. We'll let you know when cards are ready to be shipped.`}

) : ( diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx new file mode 100644 index 0000000000..c787501ce0 --- /dev/null +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -0,0 +1,63 @@ +/** + * PhysicalCardScreen — waitlist "on the list" copy contract. + * + * The joined branch is gated on joinedAt, but the API types position as + * nullable, so a joined user can have no queue position. Interpolating it + * blindly renders "You are #null on the list." + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PhysicalCardScreen from '@/components/Card/PhysicalCardScreen' +import { rainApi } from '@/services/rain' + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), +})) +jest.mock('next/image', () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element -- test stub, not real markup + default: (props: Record) => {String(props.alt, +})) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/services/rain', () => ({ + rainApi: { getPhysicalWaitlist: jest.fn(), joinPhysicalWaitlist: jest.fn() }, +})) + +const mockGet = rainApi.getPhysicalWaitlist as jest.Mock + +const renderScreen = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + ) +} + +describe('PhysicalCardScreen — joined waitlist copy', () => { + beforeEach(() => jest.clearAllMocks()) + + it('shows the queue position when the API returns one', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 42 }) + renderScreen() + expect(await screen.findByText(/You are #42 on the list\./)).toBeInTheDocument() + }) + + it('omits the position rather than rendering "#null" when there is none', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: null }) + renderScreen() + const body = await screen.findByText(/You are on the list\./) + expect(body).toBeInTheDocument() + expect(body.textContent).not.toMatch(/#/) + }) + + // Derived rather than hardcoded: the separator is locale-dependent, and the + // contract is that the copy delegates to toLocaleString, not that it says "1,234". + it('formats large positions with thousands separators', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 1234 }) + renderScreen() + const body = await screen.findByText(/on the list\./) + expect(body.textContent).toContain(`You are #${(1234).toLocaleString()} on the list.`) + }) +})