Files
fuj-management/docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Jan Novak 919845518c
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors
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>
2026-05-08 14:49:35 +02:00

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-modal off main.

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:

  • /qr endpoint ships PNGs (go/internal/web/qr.go, tested by TestServeQR).
  • CSS for #qrModal .modal-content, .qr-image, .qr-details is 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 + 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 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 #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

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.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 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 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

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 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 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.