Replace bare <a href=/qr> Pay buttons with <button data-*> elements that open an in-page #qrModal (matching Python's showPayQR UX), driven by a new payment-qr.js vanilla-JS IIFE module. Remove the now-dead qrHref / qrHrefAll template helpers from render.go. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15 KiB
M6.6.1 — Pay-button QR popup modal (/adults, /juniors)
Plan-mode note: per project convention this plan should live at
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md. The first execution step after approval is to copy this file there. Branch:feat/go-m6-6-1-payment-qr-modaloffmain.
Context
M6.6 (docs/plans/2026-05-08-1334-go-m6-6-action-pages.md)
landed the backend /qr PNG endpoint plus /sync-bank, /flush-cache,
/version. While doing it, the client-side half of the Pay flow was
collapsed from a modal-with-details into a plain <a href="/qr?..."> —
clicking Pay now navigates the whole tab to the raw PNG.
The Python app instead opens an in-page #qrModal showing the QR image
plus labelled details (account, amount, message, title), then closes
the modal on outside-click or [close]
(templates/adults.html:631-648,
JS at templates/adults.html:963-993).
This is the UX the user expects from the dashboard — staying on the
table while displaying the QR is significantly more practical than
losing context to a fullscreen image.
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
#qrModal, identical in structure to the Python original, driven by a
new vanilla-JS module payment-qr.js that mirrors the member-detail.js
convention from M6.5.
All the building blocks are already in place:
/qrendpoint ships PNGs (go/internal/web/qr.go, tested by TestServeQR).- CSS for
#qrModal .modal-content,.qr-image,.qr-detailsis already in go/internal/web/static/css/app.css:445-478 — lifted during M6.1. - Template helpers
qrHref/qrHrefAll(go/internal/web/render.go:63-85) already produce the correct query string + CzechMM/YYYYformat for themessage=param. They're useful for sanity (and for any no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope. - Bank account is already exposed as
$.Data.BankAccountto every template that has a Pay button. - JS module convention:
member-detail.jsis the M6.5 baseline — IIFE,'use strict', event delegation offdata-*attributes, no inlineonclick, no globals leaked.
Approach
1. Replace <a href="{{qrHref ...}}"> with <button data-...>
In go/internal/web/templates/adults.tmpl and go/internal/web/templates/juniors.tmpl (both have identical Pay markup at lines 65 and 72):
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$cell.Amount}}"
data-month="{{$cell.Month}}"
data-raw-month="{{$cell.RawMonth}}">Pay</button>
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$row.PayableAmount}}"
data-month="{{$row.UnpaidPeriods}}"
data-raw-month="{{$row.RawUnpaidPeriods}}"
data-pay-all="1">Pay All</button>
Why <button type="button"> and not <a>: the click no longer
navigates — the JS handles it. type="button" prevents accidental form
submission. A no-JS fallback could be added later by re-introducing
href="{{qrHref ...}}" and letting the JS call e.preventDefault() —
deferred until anyone actually asks (see §Out of scope).
data-month carries the human-readable period (e.g. January 2026,
or January 2026 + February 2026 for Pay-All) which the modal title
displays. data-raw-month carries the machine format (2026-01,
2026-01+2026-02) which the JS converts to MM/YYYY for the QR
message — same conversion Python's showPayQR does at
templates/adults.html:966-968.
data-pay-all is purely informational; the JS doesn't actually need
it (the same code path handles single + multi-period), but keeping it
helps debugging and any future analytics.
2. Add #qrModal markup
Append after the #memberModal block in both adults.tmpl and
juniors.tmpl (around line 185 of adults, line 165 of juniors), mirroring
templates/adults.html:631-648 but
using class="active" toggling (the convention M6.5 picked) instead of
style.display = 'block':
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for —</div>
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
The CSS already targets #qrModal .modal-content directly. To make the
"hidden by default / visible when .active" toggle behave the same
way #memberModal does, the existing CSS rules for #memberModal
(display:none, #memberModal.active { display: flex; }) need a
sibling pair for #qrModal. Audit app.css during implementation:
- if
#qrModalalready has both rules, no change. - if it has only
display:none, add#qrModal.active { display: flex; }. - if it has neither, add both.
This is a 2-line CSS adjustment at most; tracked under §Critical files.
3. Bank account on the page
The modal needs the bank account string for the qrAccount label and
for building the /qr?account=… URL. Two clean options:
(a) Read it from the <button>'s data-bank-account attribute.
Per-button duplication, but trivially done in templates and keeps the JS
module's surface tiny.
(b) Stamp it once on the page as <body data-bank-account="…">
(or on the existing #filterContainer).
Decision: (b) — single stamping site, follows the same pattern
M6.5 used for data-page on #filterContainer. Add
data-bank-account="{{.Data.BankAccount}}" to #filterContainer in
both templates (one-line edit each).
4. Create go/internal/web/static/js/payment-qr.js
A new module loaded with <script src="/static/js/payment-qr.js" defer></script>
right after member-detail.js in both templates. Pure ES, no build
step, IIFE, 'use strict'. Skeleton — port of Python's showPayQR
(templates/adults.html:963-986)
plus event wiring:
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const bankAccount = container.dataset.bankAccount || '';
const modal = document.getElementById('qrModal');
const titleEl = document.getElementById('qrTitle');
const imgEl = document.getElementById('qrImg');
const accountEl = document.getElementById('qrAccount');
const amountEl = document.getElementById('qrAmount');
const messageEl = document.getElementById('qrMessage');
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
// Mirrors templates/adults.html:966-968.
function toCzechMonth(rawMonth) {
return rawMonth.split('+')
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
.join('+');
}
function showQR(name, amount, month, rawMonth) {
const numericMonth = toCzechMonth(rawMonth);
const message = `${name}: ${numericMonth}`;
titleEl.innerText = `Payment for ${month}`;
accountEl.innerText = bankAccount;
amountEl.innerText = amount;
messageEl.innerText = message;
const url = '/qr?'
+ 'account=' + encodeURIComponent(bankAccount)
+ '&amount=' + encodeURIComponent(amount)
+ '&message=' + encodeURIComponent(message);
imgEl.src = url;
modal.classList.add('active');
}
function closeQR() { modal.classList.remove('active'); }
// Event delegation: any .pay-btn click anywhere in the page.
document.addEventListener('click', ev => {
const btn = ev.target.closest('.pay-btn');
if (!btn) return;
ev.preventDefault();
showQR(
btn.dataset.name,
btn.dataset.amount,
btn.dataset.month,
btn.dataset.rawMonth,
);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
});
}());
Notes:
- Event delegation (single listener on
document) handles both Pay and Pay-All without enumerating buttons. It also survives any future re-render of the table without needing rebinding. - Coexistence with
member-detail.js:member-detail.jslistens forEscapetoo, but only acts when#memberModalis active; the new module similarly only acts when#qrModalis active. No collision. - Pre-warming: nothing to pre-warm —
/qris a tiny on-demand PNG and only fires when the user clicks. - Inline
onclickon[close]and outside-click in the markup are carried over from the M6.5 modal style; consistent with#memberModal. (Switching the project's modal close pattern to data attributes is a separate cleanup, out of scope here.)
5. Tests
Extend go/internal/web/html_handler_test.go with markup-level checks. Headless-browser JS is out of scope (same rationale as M6.5).
In TestModalMarkup (or a sibling test, e.g. TestPaymentQRMarkup):
- Body for
/adultsand/juniorscontains<script src="/static/js/payment-qr.js". - Body contains
id="qrModal",id="qrImg",id="qrAccount",id="qrAmount",id="qrMessage",id="qrTitle". - The fixture row's Pay button is rendered as
<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01". - The fixture row's
.pay-btndoes not includehref=. #filterContainerhasdata-bank-account="CZ0000000000000000000000"(matching the test'sfixtureHandlerconfig).
6. Decide fate of qrHref / qrHrefAll template helpers
Once Pay/Pay-All buttons are JS-driven, the helpers in
render.go:63-85 have no callers.
Decision: delete them, plus their entries in tmplFuncs. A
no-JS fallback is not on the roadmap; if it ever becomes one, the
helpers are easy to resurrect from git.
The unit-test file currently has no direct tests for these helpers — nothing to remove from tests.
Critical files
| Action | Path |
|---|---|
| edit | go/internal/web/templates/adults.tmpl — Pay/Pay-All → <button data-…>; add #qrModal; add data-bank-account on filter container; add <script src="/static/js/payment-qr.js" defer> |
| edit | go/internal/web/templates/juniors.tmpl — same |
| new | go/internal/web/static/js/payment-qr.js — modal module |
| edit | go/internal/web/render.go — drop qrHref, qrHrefAll, and their tmplFuncs entries (and the net/url, strconv imports if no longer needed) |
| edit | go/internal/web/static/css/app.css — verify / add #qrModal { display:none; } + #qrModal.active { display:flex; } rules |
| edit | go/internal/web/html_handler_test.go — markup assertions |
No changes to: assets.go (embed.FS glob picks up new JS automatically),
server.go, html_handler.go, the /qr handler, cmd/fuj/main.go, the
API handlers, or any domain code.
Reused existing infrastructure
- go/internal/web/qr.go —
/qr?account=…&amount=…&message=…endpoint serves the PNG already - go/internal/web/static/css/app.css:445-478 — modal CSS already lifted whole
- go/internal/web/static/js/member-detail.js — IIFE /
'use strict'/data-*style to mirror - go/internal/web/api/types.go —
MemberRow.UnpaidPeriodsandRawUnpaidPeriodsalready populated for Pay-All - templates/adults.html:631-648,963-986 — Python reference for both modal markup and
showPayQRJS (logic to port, not literal copy)
Verification
End-to-end, after copying the plan into docs/plans/:
cd go && make go-build go-test go-lint— all green.make web-go &— boot Go on:8080.- Browser:
http://localhost:8080/adults- Click
Payon any unpaid month →#qrModalopens with- title
Payment for <Month YYYY>, qrAccountmatchingBANK_ACCOUNT,qrAmountmatching the cell amount,qrMessageformatted as<Name>: MM/YYYY,- QR image rendered from
/qr?…(Network tab shows PNG response).
- title
- Tab does not navigate; URL stays
/adults. - Click
Pay All→ modal opens with+-joinedMM/YYYY+MM/YYYYmessage. - Click outside the modal-content → modal closes.
- Click
[close]→ modal closes. - Press
Esc→ modal closes. - Open
[i]member-detail modal first, thenEsc→ only that closes (verify both modals don't fight each other).
- Click
- Browser:
http://localhost:8080/juniors— same checks. Verify Pay on a"?"-expected row doesn't appear (Pay button is conditional onunpaid/partialcells). - Cross-check against Python:
make web-py &→http://localhost:5001/adultsmodal looks visually equivalent for the same fixture (modulo any nav / theme differences already accepted in M6). - Scan a QR with a Czech banking app (or
zbarimgon the saved PNG) — payload starts withSPD*1.0*ACC:and the message decodes to<Name>: MM/YYYY. - After the user merges in Gitea: append CHANGELOG entry (timestamp via
date "+%Y-%m-%d %H:%M %Z"); add a new tick line for M6.6.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md immediately under the M6.6 line, in the same format as the other sub-bullets.
Branching
Per CLAUDE.md: create feat/go-m6-6-1-payment-qr-modal off
main, commit with Co-Authored-By trailer, push with -u, open MR via
tea pr create. Do not merge or delete from CLI.
Out of scope (deferred)
- Pay button no-JS fallback — feasible to keep
qrHrefand have the JSpreventDefaultan<a>, but no current need. Resurrect the helpers if asked. - Modal pattern unification —
#memberModaland#qrModaluse inlineonclickfor[close]and outside-click. Consistent with M6.5 but worth a follow-up to delegate-event everything. - Pay-from-modal inside
#memberModal— Python doesn't have it, and no one's asked for it on the Go side. - Per-button QR error-correction / size —
/qris fixed at Medium/256, same as M6.6. Configurable via query param can be added if printing larger physical posters becomes a use case. /paymentsPay buttons — the payments page is a ledger view; it doesn't render Pay buttons in either backend.