PR: #768: feat(channels): enrolment QR from server deep link — countdown, expiry [Story-3067] by @ayush-pramaan
View PR →

Files changed

14 file(s), +441 / -183 lines

Path Role + -
.env.exampleSource+0-6
messages/en.jsonConfig+9-9
messages/hi.jsonConfig+9-9
package.jsonConfig+1-0
pnpm-lock.yamlConfig+12-0
src/components/settings/channels/channels-page.tsxComponent+3-1
src/components/settings/channels/enrollment-code-card.stories.tsxComponent+43-11
src/components/settings/channels/enrollment-code-card.test.tsxComponent+74-51
src/components/settings/channels/enrollment-code-card.tsxComponent+59-82
src/components/settings/channels/enrollment-code-details.tsxComponent+87-0
src/components/settings/channels/enrollment-qr.stories.tsxComponent+36-0
src/components/settings/channels/enrollment-qr.tsxComponent+60-0
src/components/settings/channels/enrollment-timer-pill.tsxComponent+40-0
tests/unit/channels-page.test.tsxTest+8-14

Story-3067: enrolment — QR, one-time code, countdown, expiry

The enrolment half of My connection now leads with a scannable QR. Scanning with the phone you want to connect is self-verifying: the phone that scans is the phone that sends, which is exactly what the ceremony binds. The QR collapses the desktop → phone device switch into one gesture.

Connect WhatsApp → scan the QR with the phone to connect (or tap Open WhatsApp with code on that phone) → send the pre-filled one-time code → keep the page open while it confirms the binding.

What changed

SurfaceChange
Primary action A fixed 216px QR column renders real SVG modules via <QRCodeSVG> (qrcode.react@4.2.0, version-pinned; ISC; zero transitive deps). No canvas, no data: URL, no dangerouslySetInnerHTML, no image-rendering: pixelated.
Link source The QR and the Open WhatsApp with code link both encode enrollment.deepLink — the server-built wa.me link (Story-3062). The component never receives a phone number, so the browser can never re-encode a wrong number into a pixel-identical dead QR.
Deleted NEXT_PUBLIC_WHATSAPP_BUSINESS_NUMBER, the "15553039991" literal fallback, plausibleWhatsAppNumber(), and the module-load env read are gone. The desktop-fallback line now uses enrollment.displayNumber from the server.
Countdown A status pill in the header, driven by the existing use-countdown hook off expiresAt: amber while live, red once it lapses (the same red the revoked badge uses). Announced with aria-live="polite" role="status" and tabular-nums so it does not jitter.
Expiry Struck-through code, faded QR with an Expired veil, red timer, hidden send affordances, and the Get a new code action in the code column.
Structure (design-matched) The card composes three siblings — EnrollmentQr (the cream 216px column with a framed QR), EnrollmentCodeDetails (the “Or send this code” eyebrow, code, copy, and live/expired affordances), and EnrollmentTimerPill — laid out as the mock's header / body-grid / footer sections with dividers. The card owns the single countdown so one expired verdict veils the QR, strikes the code, and reddens the timer in one place. All colours map to existing tokens (cream = --surface-app-page-subtle, amber = --surface-app-warning); the three deliberate deviations from the mock are SVG-not-pixelated-img, Vani-not-Mira, and server-displayNumber-not-hardcoded.

Why SVG, not canvas

SVG avoids CSP coupling (img-src data: is one hand-edit away — see LI-5), is present in the server-rendered HTML instead of appearing after hydration, stays sharp at every device pixel ratio (QR scanning depends on edge contrast), and is reviewable in Storybook. Pure black-on-white is the library default and is required for scan reliability, so no colour token is passed to the QR.

The #3064 poll-stop guard is preserved

This Story restructures channels-page.tsx — the exact file Bug-3064 fixed — but touches only the <EnrollmentCodeCard> props (adds deepLink, displayNumber). The refetchInterval predicate still calls shouldPollForBinding(enrollment, bindings, Date.now()) unchanged, and presentation.ts / presentation.test.ts are byte-for-byte unchanged — including the test that proves polling stops once the code expires. Both pass.

Storybook proof

Automated proof

Acceptance boundary — the real-phone scan is NOT claimed here

Storybook proves the SVG draws; it cannot prove the encoded link is right. A QR encoding the wrong payload is pixel-identical to one that works — the only proof is a completed bind. This build was verified by unit + integration tests and Storybook only; a real phone scan against a live enrolment code was not performed in this environment. The Bug-3179 acceptance step — scan with a phone, confirm WhatsApp opens with the code pre-filled, confirm the message completes a bind — remains open and must be done on a deployed environment (UAT M-01) before this is marked done. The design makes the encoded payload correct-by-construction (it is the server deepLink, byte-for-byte), which is what the automated link-equality assertion pins.