All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
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>
336 lines
15 KiB
Markdown
336 lines
15 KiB
Markdown
# 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-modal` off `main`.
|
|
|
|
## Context
|
|
|
|
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](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](templates/adults.html#L631-L648),
|
|
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
|
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:
|
|
|
|
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
|
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
|
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
|
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
|
already produce the correct query string + Czech `MM/YYYY` format
|
|
for the `message=` 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.BankAccount` to every
|
|
template that has a Pay button.
|
|
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
|
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
|
inline `onclick`, no globals leaked.
|
|
|
|
## Approach
|
|
|
|
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
|
|
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
|
|
(both have identical Pay markup at lines 65 and 72):
|
|
|
|
```gotmpl
|
|
<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>
|
|
```
|
|
|
|
```gotmpl
|
|
<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](templates/adults.html#L966-L968).
|
|
`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](templates/adults.html#L631-L648) but
|
|
using `class="active"` toggling (the convention M6.5 picked) instead of
|
|
`style.display = 'block'`:
|
|
|
|
```gotmpl
|
|
<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 `#qrModal` already 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](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](templates/adults.html#L963-L986))
|
|
plus event wiring:
|
|
|
|
```js
|
|
(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.js` listens
|
|
for `Escape` too, but only acts when `#memberModal` is active; the
|
|
new module similarly only acts when `#qrModal` is active. No collision.
|
|
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
|
and only fires when the user clicks.
|
|
- **Inline `onclick`** on `[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](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 `/adults` and `/juniors` contains
|
|
`<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-btn` does **not** include `href=`.
|
|
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
|
(matching the test's `fixtureHandler` config).
|
|
|
|
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
|
|
|
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
|
[render.go:63-85](go/internal/web/render.go#L63-L85) 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](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](go/internal/web/templates/juniors.tmpl) — same |
|
|
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
|
| edit | [go/internal/web/render.go](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](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](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](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
|
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
|
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
|
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
|
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
|
|
|
## Verification
|
|
|
|
End-to-end, after copying the plan into `docs/plans/`:
|
|
|
|
1. `cd go && make go-build go-test go-lint` — all green.
|
|
2. `make web-go &` — boot Go on `:8080`.
|
|
3. Browser: `http://localhost:8080/adults`
|
|
- Click `Pay` on any unpaid month → `#qrModal` opens with
|
|
- title `Payment for <Month YYYY>`,
|
|
- `qrAccount` matching `BANK_ACCOUNT`,
|
|
- `qrAmount` matching the cell amount,
|
|
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
|
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
|
- Tab does **not** navigate; URL stays `/adults`.
|
|
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
|
- Click outside the modal-content → modal closes.
|
|
- Click `[close]` → modal closes.
|
|
- Press `Esc` → modal closes.
|
|
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
|
(verify both modals don't fight each other).
|
|
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
|
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
|
`unpaid`/`partial` cells).
|
|
5. Cross-check against Python: `make web-py &` →
|
|
`http://localhost:5001/adults` modal looks visually equivalent for
|
|
the same fixture (modulo any nav / theme differences already accepted
|
|
in M6).
|
|
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
|
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
|
`<Name>: MM/YYYY`.
|
|
7. 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](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](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 `qrHref` and have
|
|
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
|
helpers if asked.
|
|
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
|
inline `onclick` for `[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** — `/qr` is fixed at
|
|
Medium/256, same as M6.6. Configurable via query param can be added if
|
|
printing larger physical posters becomes a use case.
|
|
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
|
doesn't render Pay buttons in either backend.
|