Show the payment QR on desktop and wallet copy on mobile - #1275
Show the payment QR on desktop and wallet copy on mobile#1275joshuakrueger-dfx wants to merge 23 commits into
Conversation
Review — full runTwo independent review lanes (conformity + logic), every finding re-verified against the source. Measured locally on Bugs introduced by this PR1. An explicit 2. "Request desktop site" hands the payer a QR they cannot scan. 3. The new German string breaks the file's form of address. 4. The same predicate is spelled out three times. Pre-existing bugs in the files this PR touchesThese predate the diff. Reporting them as bugs, not as observations — a bug does not get less severe for being older. 5. The no-quote counter branch tells the payer to scan a QR that is never rendered. 6. The pay-request and callback calls bypass the SDK. Hard requirements for this PR
Checked and dismissedThe claim that |
Review round 2 — all six findings addressed, plus one blocker they surfacedHead One of your points I am answering rather than following — the workflow path filter, with the 1 — the product decision
The three-valued alternative ( 2 — the device check, and why
|
| file | statements | branches | functions | lines |
|---|---|---|---|---|
src/hooks/device.hook.ts (new) |
100 | 100 | 100 | 100 |
src/screens/payment-link.screen.tsx |
100 (was 49.67) | 90.42 (was 46.30) | 100 (was 36.20) | 100 (was 48.96) |
src/screens/payment-routes.screen.tsx |
98.88 (was 0) | 90.35 (was 0) | 100 (was 0) | 100 (was 0) |
Functions and lines are at 100 % on all three files, statements on two of three. What is left:
- Three statements in
payment-routes.screen.tsx. 591 is anonClickguard sitting behind a
.filter(item => item.text)that has already removed the falsy case. 1078 and 1082 arebreak
arms for RECIPIENT/CONFIG/PAYMENT without apaymentLinkId— a state in which the Save button is
not rendered at all. - The branch percentages. These are
?? '',|| fallbackand optional chains whose alternative
arm is unreachable behind an earlier guard. Forcing them means writing tests for states the app
cannot enter, which pins nothing.
No istanbul ignore was added and neither screen was restructured to be cheaper to test — the
production diff is still 15 lines. If you want the last three statements covered anyway, say so and
I will do it; I would rather name them than reach 100 % by making the code lie about itself.
Worth flagging: payment-routes.screen.tsx (1526 lines) only entered the coverage scope because
you asked for the dropdown to be renamed. One string change pulled a whole screen into a 100 %
requirement — the tests are worth having, but the rule as written couples the two.
Verification
Mutations, each with the match count asserted before patching, the diff shown, and the file verified
restored by blob hash afterwards:
| mutation | result |
|---|---|
isMobile || hasCoarsePointer → && |
6 of 9 device-hook tests fail |
showLargeQr || → && |
4 of 23 payment-link tests fail |
showsOcpSection && removed |
2 of 26 payment-link tests fail |
German Deine → deine |
1 of 7 translation tests fail |
French portefeuille → wallet |
2 of 7 translation tests fail |
a French value with {{wallet}} and the common noun |
1 of 7 fail — but only after dropping {{wallet}} from the exception list; with it there the guard let the string through, which is why that entry is gone |
Not verified
- No physical phone, tablet or desktop browser. The device split rests on mocked unit tests and an
emulated Playwright profile. It is a checkbox at the top of the PR body. - The remaining Playwright specs were not run with the newly pinned
REACT_APP_API_URL. - One fixture detail worth knowing before touching these tests:
PosLinkButtoncallsfetchPosUrl
fromonMounton every render, so with two or more links the effects thrashsetIsLoadingPos
whileposUrlsis still empty and React aborts with "Maximum update depth exceeded". Multi-link
cases are therefore split into separate renders. The screen itself is untouched — but that is a
real re-render loop in the component, not a test artefact. - Baselines exist for
chromium-darwinonly, as do all 154 already in the repo, and no workflow
compares them. - The Docker build and container smoke test from
handbook-check.yamlwere not run locally, only
build.js.
|
@marassteiner please review This is a PR, not an issue: 20 files against |
|
Sorry — I've reached my hourly job limit and can't take this on right now. I'll pick it up automatically once the limit resets. |
|
@marassteiner please review This is a PR, not an issue: 20 files against |
|
Sorry — I've reached my hourly job limit and can't take this on right now. I'll pick it up automatically once the limit resets. |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
Recommendation: Approve
No merge-blockers. CI is green (7/7). Local run OK (see Gate B). Follow-ups only — filed as issues and linked below.
Merge-blockers
None.
Severity-first findings (follow-up)
Major
src/screens/payment-link.screen.tsx:147-149— OCP assumed while form standard is unset
showsOcpSectiontreats!selectedPaymentStandardas OpenCryptoPay. The form value is set only afterpaymentStandardsloads (:214-216); iffetchPaymentStandardsfails, it stays unset. After this PR, desktop +displayQr: falseyieldsshowLargeQr = truein that window and can render the OCP large QR even whenpayRequest.standardis non-OCP.
The unset→OCP section wrapper is pre-existing; this PR amplifies it for the large QR on desktop defaults. Happy path self-corrects once standards hydrate. Not treated as a merge-blocker.
→ #1350
Minor
-
src/screens/payment-link.screen.tsx:633-635— wallet copy when lists are empty
Handheld /!showLargeQralways shows “Choose your wallet…”, even if all wallet lists are empty after load. Collapsible QR remains available.
→ #1351 -
playwright.config.ts:39-45— API URL pin skipped when reusing existing server
webServer.env.REACT_APP_API_URLdoes not apply whenreuseExistingServertakes over a locally running app; baseline QR matrix can still drift.
→ #1352 -
src/translations/languages/it.json:914— formal Lei next to informal tu
Cashier string uses Lei (“Dica… vuole”); neighbouring payment strings use tu (“Scansiona…”, “Scegli il tuo…”). de/fr are consistent within themselves.
→ #1353
Explicit non-findings
- Device split via
matchMedia('(pointer: coarse)')+isMobileis coherent with the PR product decision (survives “Request Desktop Site”). - Label rename “Always show QR code” matches force-switch semantics for
displayQr. - Non-OCP desktop no longer loses all QR access (
showLargeQrknows the OCP section guard) — intentional fix. - Declaring
react-device-detectindependenciesmatches prior imports. - Open SDK item (packages#201) correctly deferred in the PR body.
Gate B — local run
| Step | Result |
|---|---|
npm ci |
Failed on review host: npm 11 / Node 24 stricter lockfile (Missing: @esbuild/* from lock file). Environmental — CI on Node 20 is green; PR only adds one lock line for react-device-detect. Used npm install fallback. |
npm run lint |
pass (exit 0, max-warnings 0) |
CI=true npm test -- --watchAll=false |
pass — 80 suites / 910 tests |
BROWSER=none PORT=3456 npm run start:dev |
pass — webpack compiled, TypeScript “No issues found”; curl http://127.0.0.1:3456/ → HTTP 200; process listening on :3456 |
CI
All required/visible checks SUCCESS at head a53798ad.
Notes
- Real-device confirmation (iOS Safari / Android Chrome + “Request Desktop Site”) remains open as stated in the PR body — not a code merge-blocker.
- No merge from this review.
TaprootFreak
left a comment
There was a problem hiding this comment.
EN: Changes required: payment-routes has no visual/handbook baseline for the new QR label, and e2e-stack does not cover the desktop/handheld split.
DE: Änderung zwingend erforderlich: Für payment-routes fehlt die visuelle/Handbook-Baseline zum neuen QR-Label, und e2e-stack deckt den Desktop/Handheld-Split nicht ab.
Details
The /pl device split itself has a Playwright spec, two committed baselines, and a scripts/handbook/metadata.json entry (payment-qr-device). That part is in good shape.
Handbook + visual E2E — incomplete
src/screens/payment-routes.screen.tsx is also changed: the user-visible label Display QR code became Always show QR code in every occurrence. That is a screen/flow change.
CONTRIBUTING.md § Handbook: every changed screen needs a committed baseline for each visual variant and a metadata entry. There is no e2e/ spec and no handbook entry for the payment-routes screen in this PR.
Required: add a Playwright screenshot (or regenerate the existing routes baseline) for the new label, and add/update the matching scripts/handbook/metadata.json entry.
E2E (e2e-stack) — not met
/pl and /routes are claimed by e2e-stack/specs/registry/payment-links.ts. The full-stack spec was not updated to open the payment page on both sides of the device/mode split (desktop QR vs handheld wallet copy) that this PR introduces.
Required: update e2e-stack/specs/payment-links.spec.ts so the new device-aware behaviour is actually exercised.
Unit 100% on payment-link.screen.tsx, payment-routes.screen.tsx and device.hook.ts is not proven by a coverage run. The PR adds large tests including payment-link.coverage.test.tsx; state the per-file numbers. If any touched file is below 100% on all four metrics, that is also blocking.
ff48259 to
7a85487
Compare
|
@marassteiner please review This is a PR, not an issue: 25 files against |
|
EN: Working on this now — job |
marassteiner
left a comment
There was a problem hiding this comment.
EN: Approved: the device split, handbook baselines, and e2e-stack coverage meet the last change request; remaining items are follow-ups, not merge-blockers.
DE: Genehmigt: Geräte-Split, Handbook-Baselines und e2e-stack-Abdeckung erfüllen die letzte Änderungsbitte; der Rest sind Follow-ups, keine Merge-Blocker.
Details
Verdict
Approve. No merge-blocker on head b8f959b5.
The 2026-08-13 request (handbook label, e2e-stack device split, stated coverage numbers) is met. The payment page now shows a large QR on desktop and wallet copy on handheld when displayQr is off; the routes label matches the force-switch meaning.
Merge-blockers
None.
Declared deviations — accepted
- Coverage below 100 % on the two screens.
CONTRIBUTING.mdrequires 100 % on touched instrumented files. The PR states the numbers (device.hook.ts100/100/100/100;payment-link.screen.tsx100/96.7/100/100;payment-routes.screen.tsx98.88/92.03/100/100) and lists the remaining arms as unreachable optional-chaining / nullish-coalescing. That is a declared deviation. Accepted: the remaining arms are not production paths this change introduces, and forcing them would test states the app cannot enter. - Raw
fetchon the pay-request path. Declared, blocked on packages#201. No new raw API call in this PR. Accepted.
Follow-up findings (not blocking)
| Sev | Finding | Issue |
|---|---|---|
| Medium | Visual e2e specs mock /v1//v2 and a synthetic JWT; only the e2e-stack quote fake is declared in docs/test-architecture.md:116 |
#1381 |
| Medium | Handheld e2e-stack test title claims the collapsed QR row; :513-515 never assert it |
#1384 |
| Low | Pre-existing any in the two touched screens (payment-link.screen.tsx:393,511,531; payment-routes.screen.tsx:170,464,602,689,896,993,1023) |
#1382 |
| Low | Leftover console.log in e2e/payment-qr-device.spec.ts:139-143 |
#1383 |
Still valid from the previous pass, not worsened by this head:
- #1350 —
showsOcpSectiontreats an unset form standard as OCP (payment-link.screen.tsx:144-149) - #1351 — wallet copy when all wallet lists are empty (
:638-640) - #1352 —
reuseExistingServerskips the newREACT_APP_API_URLpin (playwright.config.ts:39-45) - #1353 — Italian cashier string still uses formal Lei next to informal tu
Round-1 close-out
- Handbook + visual baseline for “Always show QR code”:
e2e/payment-routes-qr-label.spec.ts, baseline PNG,scripts/handbook/metadata.json:162. - e2e-stack desktop/handheld split:
e2e-stack/specs/payment-links.spec.ts:425-517(quote fulfill declared indocs/test-architecture.md:116-122). - Per-file coverage numbers: in the PR body.
Gate B — local run
| Step | Result |
|---|---|
npm ci |
Failed on this host: npm 11 / Node 24 lockfile (Missing: @esbuild/* from lock file). Environmental — CI on Node 20 is green at this head; the PR only adds one lock line for react-device-detect. Continued with npm install. |
npm install |
pass (exit 0); lockfile restored afterwards so the tree stayed clean |
npm run lint |
pass (exit 0, max-warnings 0) |
CI=true npm test -- --watchAll=false |
pass — 102 suites / 1405 tests |
BROWSER=none PORT=3456 npm run start:dev |
pass — webpack compiled, TypeScript “No issues found”; curl http://127.0.0.1:3456/ → HTTP 200; node listening on :3456 |
npm ci is Case 2 (outside the diff): same lockfile mismatch on Node 24 as on develop; CI “Build and test” is SUCCESS on Node 20 for b8f959b5.
CI
All 7 checks SUCCESS on b8f959b5 (Build and test, Full-stack E2E, handbook, review, CodeQL, Analyze).
Notes
- Real-device confirmation (iOS Safari / Android Chrome + “Request Desktop Site”) remains open as stated in the PR body — not a code merge-blocker.
- No merge from this review.
…bile The payment page always told the payer to scan a QR code, but only rendered one when the route had displayQr enabled - which is not the default. A payer on the default configuration read an instruction pointing at nothing, with the QR buried two levels deep under the payment details. A fixed setting cannot serve both sides: on a phone the QR is useless, since the screen showing it is the screen you would have to scan it with, while on a desktop it is the only way to pay from a phone wallet. The page now follows the device, and an explicit displayQr keeps precedence so a merchant who wants the code always gets it. On mobile the instruction now names what is actually there: the wallet list. react-device-detect backs the check. Six files already import it although it was never a direct dependency, so it is now declared with the version the lock file already resolves.
A fixed 2030 date goes stale on the calendar and would silently move the fixture out of the window the test is meant to cover.
…lone
isMobile is read once from the user agent when the module loads. Turning on
"Request Desktop Site" - a standard toggle in iOS Safari and Android Chrome -
flips it to false on an actual phone, and the payer then got the large QR plus
"scan it with a compatible app" on the very device they would have to scan
with. matchMedia('(pointer: coarse)') describes the input hardware instead of
the UA string and survives that toggle; the window width cannot, because
window.context caps it at 768 and a phone requesting the desktop site lands on
the same value as any wide desktop.
The three places that spelled out the device rule now derive it once. displayQr
keeps its new meaning as a force switch: true shows the QR everywhere, false
lets the device decide.
The counter branch is fixed in the same file: it told the payer to scan a QR
while no quote exists, and both QrBasic sites sit behind paymentHasQuote, so in
that state the page has no QR anywhere. It now names only what is there.
The dropdown said "Display QR code" while a "No" no longer suppresses the code on desktop - it only lets the device decide. A merchant reading the old label would expect a promise the page cannot keep. "Always show QR code" describes the switch that is actually there: yes forces the code on every device, no hands the decision to the payer's device.
… untranslated de.json capitalizes the informal address in 222 of 227 places, including the neighbouring line, but the new payment string wrote "deine" in lower case. fr.json and it.json use portefeuille and portafoglio 18 times each, yet the new string kept the English "wallet"; it.json carried one more of those from before, in the Safe send copy. The guard is a test over the translation files themselves, with the pre-existing exceptions listed by name so a new violation fails instead of joining them silently. Also moves the QR setting key and drops the counter sentence that pointed at a QR the page does not render.
The payment screen appeared in no handbook entry at all, and the existing e2e spec for it asserts nothing but a visible body. The handbook check did not even trigger on this change, because its path filter matched none of the files. Two baselines now show the split the change is about: desktop with the large QR and the scan sentence, handheld with the wallet list instead. The payment APIs are served from fixtures with a fixed quote expiry and the wait poll held open, so the shot is byte-stable rather than tied to a live backend.
…ryptoPay The large QR sits behind a second guard that renders it only for the OpenCryptoPay standard, and the derived showLargeQr did not know about it. On a desktop, showLargeQr was therefore true, which removed the collapsed "QR Code" row - while the large QR was never rendered in the first place. A payment link whose standard is PayToAddress or LightningBolt11 showed no QR at all, from first load, on the only screen whose purpose is paying. The guard is now derived once as showsOcpSection and feeds both the derivation and the place that spelled it out. Taken along deliberately: displayQr true on a non-OpenCryptoPay standard showed no QR either, and now falls back to the collapsed row.
The QR encodes Api.url, which comes from REACT_APP_API_URL - the one input the spec did not pin. Regenerating the baselines with the .env.sample default produced a different matrix and a red test without any code change. The web server now gets the same value scripts/e2e-test.sh already uses. The spec also stops holding the door open: the catch-all route is replaced by the repo's usual **/v1/** filter, the wait endpoints are matched before the general payment one so a POS long-poll cannot answer itself into a loop, and the screenshot waits for the QR to leave its loading state instead of sleeping half a second. The three extra path filters on the handbook check are reverted. build.js reads committed PNGs only and Playwright runs in no workflow here, so a screen diff would have started a 25-minute image build that cannot tell a stale baseline from a fresh one - and the twin list in handbook-deploy.yaml would have had to move with it. The new baselines already trigger the check through e2e/screenshots/**.
The wallet guard exempted every string containing the {{wallet}} placeholder
instead of the placeholder itself, so a sentence using both the placeholder and
the common noun passed silently; the strip-and-recheck path already handles the
placeholder correctly. The renamed label is now pinned to a count instead of
"at least one", the map assertion identifies the map instead of any iframe,
and the titles name the behaviour rather than an item number from a review
document. The hook test carries no JSX and is renamed to .ts, matching 11 of the
13 hook tests in this directory.
The review asked for full coverage on every file this PR touches. The payment screen sat at 80.64 % statements with the uncovered ranges holding the wallet detail view, the MetaMask paths, the public-mode forms and the standard selection - including the branch whose guard this PR had to repair. It now reaches 100 % of statements, functions and lines, and 90.42 % of branches. The remaining branches are listed in the PR body line by line. Removing the OpenCryptoPay condition from the derivation turns 4 of 67 payment-link tests red, so the cases hold the behaviour rather than just executing it.
The routes screen had no tests at all, and the rename this PR makes to the QR setting reaches four places - two of them inside PaymentLinkForm, where nothing rendered them. An incomplete rename limited to the edit flow would have passed. Coverage on the screen goes from 17.53 % to 98.88 % of statements, with functions and lines at 100 %. Renaming the form label back turns 3 of 54 routes tests red, so the labels are pinned rather than merely executed. The action tests needed one non-obvious fixture: PosLinkButton calls fetchPosUrl from onMount on every render, and with two or more links the effects thrash setIsLoadingPos while posUrls is still empty, which ends in "Maximum update depth exceeded". Multi-link cases are therefore split into separate renders - the screen itself is untouched.
Handbook baseline for the renamed Always show QR code setting. The full-stack spec opens /pl on desktop and handheld with a quoted payload, because the loc API cannot build a Lightning/BTC quote.
The routes label check was clicking the first cursor-pointer on the page. Desktop now requires the large QR svg; handheld requires it absent — the collapsed Payment-details "QR Code" row is not in the DOM until opened.
separate file. The same fake belongs with the others.
develop DFXswiss#1404 added two /account-merge strings that call the wallet "wallet" in Italian, while fr.json of the same commit says portefeuille and the other 18 Italian occurrences say portafoglio. The convention guard added on this branch reads the whole file, so the rebase turned it red. Follow the file (and fr.json) instead of widening the exception list, and let prettier re-wrap the quote row it now shares a table with.
b8f959b to
6f0b6d1
Compare
The hint prefers the local error over a context "permission denied". Replacing that formula with apiError ?? error left all 58 tests in the three payment-routes suites green: the two existing cases are true under both formulas. The new case sets the context error and lets the config save fail; under the mutation it is the one test that turns red.
The pageerror and console handlers only printed; they assert nothing and the sibling routes-label spec has no such block.
The line kept the formal Lei ("Dica ... vuole") next to tu in every
neighbouring payment string, including the wallet line this branch adds.
CONTRIBUTING documents the local run as REACT_APP_API_URL=http://localhost:3000 npx playwright test. The pin added here swallowed that variable, so the documented call still built against dev.api.dfx.swiss.
…has none The form value arrives from an effect after the standards load, so until then every payment counted as OpenCryptoPay — and stays that way when the payment standard is missing from the list. On a desktop that meant a scannable OpenCryptoPay QR for a Pay-to-Address payment. The derivation now falls back to payRequest.standard. Restoring the old expression turns the new case red, 1 of 27 in the screen suite.
The route matcher was bound to dev.api.dfx.swiss. Now that an explicit REACT_APP_API_URL reaches the dev server, that spec would have left its own mocks and talked to whatever backend the run points at.
Both fulfil the APIs they need; one also pins wall-clock time and the other mounts on a synthetic JWT. Only the full-stack quote fake was declared, so a green visual run read as more than it is.
The entry listed wallet and standard payloads; the spec answers the user call, the route list, the payment-link list with its config and POS endpoints, and the info banner.
|
EN: DE: DetailsRebaseRebased onto What the passes foundPass 1 — two lanes over the full diff.
Pass 2.
Pass 3 — findings caused by pass 2's own fix. Letting a foreign Pass 4. The wording of that new declaration named payloads the spec does not fake. Corrected to the endpoints it actually answers. Pass 5. Conformity lane: 0 findings. Logic lane: the deeplink request in Follow-ups closed here#1350 (unset standard), #1353 (formal Lei), #1381 (undeclared visual fakes), #1383 (debug listeners). Still filed: #1351, #1352, #1382, #1384. Evidence
One check is green without running
GatesNo unresolved review threads. |
|
@marassteiner please review This is a PR, not an issue: 25 files against Your last review was on |
|
EN: Working on this now — job |
|
Job dfxswiss__services__1275__pr-review__r3: Der Lauf hat das Zeitlimit von 40 Minuten überschritten und wurde beendet. |
EN:
The payment page now shows the large QR on a desktop and wallet copy on a handheld, so
displayQr: falseno longer leaves a payer reading "scan the QR code" with no code on screen; the merchant setting is renamed to what it now means. Rebased onto currentdevelop, with the Italian wallet noun, the OpenCryptoPay standard fallback, the visual specs' reality declaration and two untested derivations fixed on top. Eight mutations were re-run on the final head and all eight turn tests red.Full-stack E2Ereports success without running anything in this repository at the moment — that is an open item, not a claim.DE:
Die Zahlungsseite zeigt den grossen QR jetzt auf dem Desktop und die Wallet-Auswahl auf dem Handgerät, damit
displayQr: falsekeinen Zahler mehr vor "Scanne den QR-Code" ohne QR-Code stehen lässt; die Händler-Einstellung heisst, was sie bewirkt. Auf aktuellesdeveloprebased, dazu behoben: das italienische Wort für Wallet, der Rückfall auf den Standard der Zahlung, die Reality Declaration der Visual-Specs und zwei ungetestete Ableitungen. Acht Mutationen wurden auf dem finalen Head neu gefahren, alle acht färben Tests rot.Full-stack E2Emeldet derzeit Erfolg, ohne etwas auszuführen — das ist ein offener Punkt, keine Behauptung.Details
Open — cannot be closed from this branch
Consume the SDK for the pay-request path instead of raw
fetch. Review finding 6.The contract belongs in
DFXswiss/packages, and packages#201(open since 2 Aug, two commits, no review yet) already carries the types —
PaymentLinkPayRequest,PaymentLinkPayTerminal,PaymentQuote,TransferAmount,hasPaymentQuote.What it does not carry is exactly the three calls this repo makes by hand: the unauthenticated
GET paymentLink/payment, the LNURL callback, andlnurlp/wait.Why not from here: it needs a merge plus an npm release of
@dfx.swiss/reactin another repobefore services can import anything — building a second contract beside [NO-TASK] Block direct login with address #201 would collide on
merge. Who: @TaprootFreak owns [NO-TASK] Block direct login with address #201; the three missing calls need an owner there.
Confirm the device split on real hardware — iOS Safari and Android Chrome with
"Request Desktop Site" on, plus one desktop browser.
Why not from here: the split is covered by mocked unit tests and an emulated Playwright device
profile only; neither is a phone. Who: anyone with a deployed build and a phone.
Restore the API checkout credential for the
Full-stack E2Ejob. Since 2026-08-18 itends after 10 s with
No API checkout credential — not starting the e2e stackon every pullrequest in this repository and still reports success, so the e2e-stack half of this branch is
currently unproven on CI.
Why not from here: a repository secret and workflow permissions, not a branch.
Who: whoever owns the CI secrets.
Symptom (verbatim): "Scanne den QR-Code mit einer kompatiblen App, um die Zahlung durchzuführen." — shown on the payment page while no QR code was rendered anywhere on it (screenshot 2026-08-05, CHF 12'333 payment, route "Revoltaris GmbH", default configuration).
Scale:
DefaultPaymentLinkConfig.displayQrisfalse, so this is what every OpenCryptoPay merchant gets who never changed the setting; the first merchant printing invoice QR codes bills more than 100 invoices a month.Smaller fix considered: Setting
displayQr = trueon that one route, which needs no code at all — insufficient because on a phone the QR is unusable (the screen showing it is the screen you would have to scan it with) and it pushes the wallet list down, so a fixed setting can only ever serve one of the two devices.Context
A merchant wants one single static QR code printed on every paper invoice they mail: the customer scans it, types in the invoice number and the amount, and pays in crypto. That is the whole point — no QR generated per invoice, no separate mail per customer, one code on the template and done.
DFX already supports exactly that:
app.dfx.swiss/invoice?recipient=<route label>registers the invoice on first scan and hands the customer straight to the payment page, and the invoice number travels with the payment into the merchant's history. What it was never built for is a payer standing in front of it — every screen on that path was written for the merchant creating a link. This PR comes out of walking that path end to end with a merchant who is about to print it on more than 100 invoices a month.Problem
The payment page always tells the payer to scan a QR code, but only renders one when the route has
displayQrenabled — andDefaultPaymentLinkConfig.displayQrisfalse. On the default configuration the payer reads an instruction pointing at nothing, while the QR sits two levels deep under "Payment details" → "QR Code".Turning
displayQron per route does not fix it. On a phone the QR is useless — the screen showing it is the screen you would have to scan it with — and it pushes the wallet list down. A fixed setting can only serve one of the two devices.Change
The page follows the device, inside the OpenCryptoPay section where the large QR lives:
displayQrtrueThe setting changes meaning, and its label changes with it.
displayQris now a force switch:"Yes" shows the code on every device, "No" hands the decision to the device. There is no longer a
configuration that hides the large QR on desktop. The dropdown was therefore renamed from
"Display QR code" to "Always show QR code" (
payment-routes.screen.tsx:449, 674, 1346, 1416), so itstops promising something the page no longer delivers. Merchants who deliberately set
displayQr: falsewill see a changed payer view without a migration and without an opt-out — thatis a deliberate product decision, not an oversight, and the API field keeps its name
displayQr.The device is read from the pointer, not from the user agent alone.
isMobilefromreact-device-detectis computed once at module import. "Request Desktop Site" — a standard togglein iOS Safari and Android Chrome — flips it to
falseon an actual phone, and the payer then getsthe large QR plus "scan it with a compatible app" on the very device they would have to scan with.
matchMedia('(pointer: coarse)')describes the input hardware instead of the UA string and survivesthat toggle. The reactive width from
useWindowContext()cannot do this job:window.context.tsx:18caps it at
Math.min(offsetWidth, 768), so a phone requesting the desktop site reports the samevalue as any wide desktop.
react-device-detectis now declared independencies. Eight files already imported it although itonly arrived transitively; the version matches what the lock file already resolved, and the lock
diff is that single line.
Review round
All six findings from the review are fixed in this PR. Three independent passes over the result
(conformity, logic, security) found one blocker of their own, which is fixed as well.
Finding 1 — an explicit
displayQr = falsecould no longer be honoured on desktop. Resolved bythe product decision above: the switch is a force switch and the label says so.
Finding 2 — "Request desktop site" handed the payer an unscannable QR. Fixed via the coarse
pointer, in a reactive hook (
src/hooks/device.hook.ts) that also survives a missingmatchMediaand older Safari's
addListener-only API.Finding 3 — the new German string broke the file's form of address. Fixed, together with the
untranslated "wallet" in
fr.jsonandit.json. The guard against a repeat is a test over thetranslation files themselves (
src/__tests__/translation-conventions.test.ts), with thepre-existing exceptions listed by name so a new violation fails instead of joining them silently.
That guard is deliberately repo-wide, not scoped to the four new strings: the finding was a break of
the file's convention, and a test that only looks at the new strings would not have caught it.
The price is one unrelated correction it forced —
it.json:1135, "wallet esterno" →"portafoglio esterno" in the Safe send copy.
Finding 4 — the same predicate spelled out three times. Now derived once as
showLargeQr, andthe OpenCryptoPay guard is derived once as
showsOcpSectionrather than written out twice.Finding 5 — the no-quote counter branch told the payer to scan a QR that is never rendered.
The sentence now names only what is on screen.
Finding 6 — the pay-request and callback calls bypass the SDK. See the open item at the top.
Not fixed here, because the fix has to land and be released in another repo first.
Blocker found by the review passes, not in the original report: with
showLargeQrtrue ondesktop, the collapsed "QR Code" row disappeared — but the large QR sits behind a second guard that
only renders it for the OpenCryptoPay standard. A payment link whose standard is
PayToAddressorLightningBolt11therefore showed no QR at all on desktop, from first load. It came in with thefirst commit of this PR.
showLargeQrnow knows that guard. Side effect, taken deliberately: thepre-existing case
displayQr: trueon a non-OCP standard, which showed no QR either, is fixed too.Review round 3 — handbook label, e2e-stack split, coverage numbers
Head
b8f959b5, rebased onto currentdevelop(a801ba1d, includes #1270) soe2e-stack/is on the branch. Three items fromthe 2026-08-13 review:
e2e/payment-routes-qr-label.spec.tsopens/routeson mocked APIs, expands Default configuration, and snapshots"Always show QR code". Entry
payment-routes-qr-labelinscripts/handbook/metadata.json.e2e-stack/specs/payment-links.spec.tsnow opens/plon desktop(scan sentence, no collapsed QR row) and handheld (wallet copy + collapsed QR row). The loc API
cannot build a Lightning/BTC quote (
404 No BTC transfer amount found); those two tests fulfillpaymentLink/paymentthemselves. Declared indocs/test-architecture.md(the file Make the collection-IBAN toggle readable and let the QR code follow it #1270 added for this). The/routesmixed testalso expands Default configuration and asserts the renamed label against the real stack.
100 % branches on the two screens; the remaining arms are listed there.
Handbook
The payment screen appeared in no handbook entry, and
e2e/payment-and-special.spec.tsassertsnothing about it beyond a visible body. Two baselines now show the split this PR is about — desktop
with the large QR and the scan sentence, handheld with the wallet list — plus a
metadata.jsonentry. A third baseline shows the payment-routes Default configuration row with the renamed
"Always show QR code" label. The payment APIs are served from fixtures with a fixed quote expiry
and the wait poll held open, so the shot does not depend on a live backend.
playwright.config.tsnow pinsREACT_APP_API_URLfor the web server, defaulting to the same valuescripts/e2e-test.shalready uses, andREACT_APP_PUBLIC_URLtohttp://localhost:3001soLnurl.prependLnurlcan build a URL when the routes baseline renders a payment-link QR. Withoutthe API pin the payment baselines were not reproducible: the QR encodes
Api.url, so regeneratingthem with the
.env.sampledefault produced a different matrix and a red test with no code changeat all.
The path filter in
handbook-check.yamlis deliberately left as it was. The review asked for itto trigger on this change, and it does — the new baselines live under
e2e/screenshots/**, which theexisting filter already matches. Adding the screen and the hook to that list was tried and reverted:
scripts/handbook/build.jsreads committed PNGs only, Playwright runs in no workflow of this repo,and the twin list in
handbook-deploy.yamlwould have had to move with it. A screen diff would havestarted a 25-minute ARM Docker build that cannot detect a stale baseline.
Review round 4 — rebase onto current
develop(2026-08-19)Rebased onto
develop0bba3597. Three text conflicts, all in documentation, all resolved by keeping the newerdeveloptable and re-adding this branch's row;git range-diffshows commits 1–11 identical, 12 and 14 differing only in that resolution, and the last one — a pure table-width commit — dropped because prettier reformats the merged table anyway.Two things the rebase surfaced, plus four findings from the review lanes that ran twice over the whole diff:
develop01a00f83 - Describe adding a wallet address on /account-merge #1404 added two/account-mergestrings calling the wallet "wallet" in Italian, while its ownfr.jsonsays portefeuille and the other eighteen Italian occurrences say portafoglio. The convention guard this branch adds reads the whole file, so the rebase turned it red. Fixed by following the file, not by widening the exception list.apiError ?? errorleft all 58 tests in the three payment-routes suites green. There is now a case for it; under that mutation it is the one test that turns red.showsOcpSection, finding 1 of the 2026-08-12 review, filed as payment-link: derive OCP section from payRequest.standard when form standard unset #1350). SinceshowLargeQris!isHandheldon desktop, that window rendered a scannable OpenCryptoPay QR for a Pay-to-Address payment — and stayed that way when the standard is missing from the list. The derivation now falls back topayRequest.standard. Closes payment-link: derive OCP section from payRequest.standard when form standard unset #1350.dev.api.dfx.swiss. Both specs are now in the reality declaration with what a green run does not prove, and the matcher goes by path. Closes Declare visual-e2e payment fakes in docs/test-architecture.md #1381.payment-qr-device.spec.ts(e2e/payment-qr-device: drop leftover console.log pageerror listeners #1383) removed, and the Italian cashier line moved from formal Lei to tu like its neighbours (it.json: cashier payment string uses formal Lei amid informal tu payment copy #1353). Closes both.payment-link.screen.tsx:161has no generation guard and nocatch. Real, but outside every hunk of this diff —git diff -U0puts the changes at +142…150 and +268, never at 161. Filed as payment-link.screen: guard the deeplink request against superseded responses #1413 instead of widened into this pull request.Verification
Measured with Node 20 on the exact revision of this branch (
cd3ed6ad), after the rebase:npm run lint→ empty output, exit 0.npm run format:md:check→ clean.CI=true npm test, full suite → 112 suites / 1545 tests green, locally and on GitHub Actions.Full-stack E2Ereports success without executing anything. The job ends after 10 s withNo API checkout credential — not starting the e2e stack. The job ran.Not specific to this branch: every pull-request run in this repository does that since 2026-08-18 (checked onfeat/app2-previewandfix/tx-uid-guest-actions), while thedeveloppush run of that day still ran for 20 minutes. The e2e-stack device split last really ran on headb8f959b5(231 passed). Until the credential is back, no branch here can prove full-stack coverage — added as an open item above.Playwright
payment-qr-device.spec.ts→ 2 passed, twice in a row without--update-snapshots. Regenerating the baselines after pinning the API URL producedbyte-identical files (54 900 and 33 342 bytes), so reproducibility is shown, not claimed.
node scripts/handbook/build.js→ no orphan warning, grouppayment-qr-devicepresent inindex.htmlandmanifest.json.Counter-check against the reviewed revision
397f9b3: production files reset to that state,tests kept → 3 suites / 9 tests fail, the suite green again after restoring. Every file was
compared by blob hash between the machine that ran the tests and the one that made the commits;
all 13 match.
Mutations, re-run on the final head
cd3ed6ad, each asserting its match count before patching and each verified restored afterwards (working tree clean). All eight turn tests red:isMobile || hasCoarsePointer→&&displayQr || !isHandheld→&&showsOcpSection &&dropped fromshowLargeQrDeine→deine(103 values)portefeuille→wallet(21 values)portafoglio→wallet(21 values)Earlier mutations, measured on head
b8f959b5before the rebase and not re-run since:{{wallet}}and the common noun : 1 of 7 fail (this one only afterdropping
{{wallet}}from the exception list — with it there, the guard let the string through)payment-routes.screen.tsx:1346reverted to "Display QR code" : 3 of 54routes tests fail — the place a rename could have been missed before these tests existed
:1416reverted : 2 fail; deactivate sendingACTIVE: 1 fail;the public/multiple toggle inverted : 2 fail; the
permission deniedfilter inverted : 2 failTest coverage
The review asked for 100 % on every file this PR touches. Reached on the new file, not on the two
screens, and the numbers are:
src/hooks/device.hook.ts(new)src/screens/payment-link.screen.tsxsrc/screens/payment-routes.screen.tsxRe-measured with the full suite on head
cd3ed6ad, Node 20 — not carried over from an earlier head. The open branches onpayment-link.screen.tsxare now 357, 455, 484, 667, 795, 802.payment-link.screen.tsxis fully covered on statements, functions and lines. The branches stillopen are 357, 454, 459-460, 479, 662, 790, 797 — optional-chaining and nullish-coalescing arms
whose alternative value never occurs in a reachable state.
payment-routes.screen.tsxis fully covered on functions and lines. Three statements stay open:591 is an
onClickguard behind a.filter(item => item.text)that already removed the falsycase, and 1078/1082 are
breakarms for RECIPIENT/CONFIG/PAYMENT without apaymentLinkId, astate in which the Save button is not rendered. The open branches on both screens are
?? '',|| fallbackand optional chains whose alternative arm is unreachable behind an earlier guard;forcing them would mean writing tests for states the app cannot enter.
Neither screen was restructured to make it cheaper to test, and no
istanbul ignorewas added —the production diff is unchanged at 15 lines.
That second block of screen tests is a contribution of its own, not evidence for the QR fix. The QR
fix is carried by the mutation numbers above.
Not verified
REACT_APP_API_URL; onlypayment-qr-device.spec.tswas. WithreuseExistingServerand a dev server already running, thepin does not apply — it takes a fresh web server start.
:449,:674) and insidePaymentLinkForm(
:1346,:1416) plus a handbook baseline of the expanded default-configuration row.chromium-darwinonly, like all 154 already in the repo. No workflow runsPlaywright, so no CI job compares them.
handbook-check.yamlwere not run locally,only
build.js.pointer: coarsestays true on every touch device with "Request Desktop Site" across thefull browser matrix — verified in Chromium via the Playwright device profile and in jsdom, nowhere
else.
Final pass (cd3ed6a):
Coherent: The core is one piece — the payment page decides by device, the setting is renamed to what it now means, the translations follow, and three baselines show the split plus the renamed routes label. The e2e-stack device tests sit next to that because the review required the full-stack spec to open both sides of the split. The commits added on 2026-08-19 stay inside that frame: they repair what the rebase broke and pin the two derivations the QR decision rests on.
Nothing extra: The smallest change that removes the symptom is still ~4 lines. Everything beyond it is named by a review: the routes handbook baseline, the e2e-stack assertions, the stated coverage, the quote-fulfill declaration, and this round's four fixes. Deliberately not built: 100 % branches on the two screens (remaining arms listed above), a real Lightning quote in loc, the SDK consumption (blocked on packages#201), and no generation guard for the deeplink request — that one is outside every hunk of this diff and now sits in #1413.
Sources closed: Review 2026-08-13 (TaprootFreak, CHANGES_REQUESTED on a53798a) — handbook routes label: implemented; e2e-stack device split: implemented, but the job runs nothing today (see Verification); per-file coverage: numbers stated and re-measured on this head, not 100 % branches, remaining arms listed. Review 2026-08-07 (findings 1–6) — 1–5 implemented; 6 open with owner at the top. Mara 2026-08-12 / 2026-08-13 APPROVE — follow-ups #1350, #1353, #1381 and #1383 are now fixed here; #1351, #1352, #1382 and #1384 stay filed. Review lanes 2026-08-19 — four findings implemented, one rejected with the diff ranges as evidence (#1413). Issues — none linked for this work. Commits — each says what its diff does and, where it fixes a mutation gap, which mutation turns red. Plan — product decision on
displayQris in the Change section.