Skip to content

[UX] members — My events, checkout & payment ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch: develop @ 2c22f5a · Files reviewed: 26 (11 in-scope widget files + 15 supporting: widgets/src/api.ts, defineWidget.ts, stores/session.store.ts, stores/family.store.ts, components/Container.vue, components/SignatureCanvas.vue, composables/useFormat.ts, utils/format.ts, utils/adoptStyles.ts, assets/tailwind.css, and the server side that this flow depends on: api/src/events/widget-events.controller.ts, events.service.ts, sumup.ts, api/prisma/schema.prisma, admin/.../ModalEvenement.vue) Patterns: e-commerce/checkout, content-management/modal

Summary ​

This is the only flow in the programme that takes real money, and it is the least defended. The server side is solid — assertSumupCheckoutPaid really does verify with SumUp before confirming, so there is no payment-spoofing hole — but the client above it misstates prices, shows a false "checkout not found" message underneath every working card form, never actually blocks a double registration (so a parent can pay twice for the same child), and swallows every async failure without a word. The single most important thing: the guard that is supposed to stop duplicate family registrations evaluates against a dataset the view never loads, so it is inert on every fresh page load (Checkout.vue:257).

Findings ​

1. "Checkout introuvable." renders under every working payment form — Blocker · MSG-06 (proposed) ​

Where: widgets/src/widgets/my-events/components/Checkout.vue:26-31What: The v-else is chained to v-if="paymentError", not to v-if="checkoutId" on the SumupPaymentWidget above it. paymentError is null in the normal path, so the else branch always wins:

<SumupPaymentWidget v-if="checkoutId" ... />
<div v-if="paymentError" class="text-sm text-danger">{{ paymentError }}</div>
<div v-else class="text-sm text-secondary">Checkout introuvable.</div>

Every member who reaches the payment step sees the SumUp card fields with "Checkout introuvable." (checkout not found) printed directly beneath them. Why it matters: The one screen where trust matters most tells the user their transaction does not exist while asking for their card number. The e-commerce/checkout corpus names this exact failure — "Treating trust as secondary UI" — as its first anti-pattern. Expect abandonment and support tickets from users who assume it is broken. Fix: Move the fallback under the checkoutId condition: <SumupPaymentWidget v-if="checkoutId">, <div v-else>Checkout introuvable.</div>, and render paymentError as an independent role="alert" block.

2. Duplicate-registration guard is inert; a parent can pay twice for the same child — Blocker · FORM-12 (proposed) ​

Where: widgets/src/widgets/my-events/components/Checkout.vue:257-278; widgets/src/widgets/my-events/stores/events.store.ts:116-149; api/src/events/events.service.ts:64-119What: isMemberAlreadyRegistered reads inscriptionsByEventId[eventId]. That map is only populated by fetchInscriptions() (fired when the user opens the Inscriptions tab) or after a create. The family's own registrations land in a different map, myInscriptionsByEventId, populated by fetchInscriptionsForMembers at mount (MyEvent.vue:50). So on a normal page load registrationsForEvent is [], no member is ever disabled or dimmed, and the checkbox list happily re-selects an already-registered child. EventsService.createInscriptions has no dedupe either — it creates a second SumUp checkout (events.service.ts:89-93) and a second inscription row. Even after the tab is opened, fetchInscriptions filters status=confirmed, so a sibling sitting in waiting_for_payment is still not excluded. Why it matters: A parent with three children re-registers one of them and is charged the full unit price a second time, with no refund path anywhere in the product. The disabled state and the opacity-50 styling exist in the template, so the team believes this is handled. Fix: Have Checkout.vue read myInscriptionsByEventId (which is loaded) rather than inscriptionsByEventId, or await eventsStore.fetchInscriptions(eventId) in the open-watcher before rendering the member list. Independently, add a server-side uniqueness check on (Evenement, Membre) for non-cancelled rows in createInscriptions — a client guard alone cannot protect a payment.

3. Cancelling a paid registration: no confirmation, and on touch the button reads "Inscrit" — High · MSG-05 ​

Where: widgets/src/widgets/my-events/components/EventTile.vue:226-230 and 306-318What: The primary button's label is swapped on hover: return isHoveringButton.value ? "Se désinscrire" : "Inscrit";. On a touch device mouseenter never fires, so the label stays "Inscrit" and a tap goes straight to cancelInscription — no confirmation dialog, no mention that the event was paid for and that no refund flow exists in this product. Why it matters: A member taps a button that says "Registered" and their paid place is destroyed. Irreversible, financially consequential, and mislabelled at the moment of the tap. Baseline MSG-05 requires a confirm step that says what will be lost. Fix: Stop using hover to carry meaning — render a persistent "Inscrit" status chip and a separate, explicitly labelled "Se désinscrire" control. Gate that control behind a confirm dialog naming the event, the amount paid, and the refund policy.

4. Every async failure in the flow is silent — High · MSG-07 (proposed) · MSG-01 ​

Where: Checkout.vue:404-422 (no catch — try/finally only); EventTile.vue:314-316 (console.error); events.store.ts:13-24 (error is set and never rendered); MyEvent.vue:40-56 (session.loginApi rejection is unhandled in onMounted) What: Four separate failure paths, none of which produces any UI:

  • registration create fails → submitting resets, modal sits there, button appears dead;
  • cancellation fails (e.g. the server's "la date limite de désinscription est dépassée") → logged to the console only, tile unchanged;
  • /api/widget/evenements fails → error.value is set but MyEvent.vue never reads it;
  • the widget session login rejects → nothing downstream runs, nothing is shown. Why it matters: The user clicks a button and nothing happens, repeatedly. In a payment flow that reads as "the site is broken" or, worse, as "it worked". The server writes useful French messages that never reach anyone. Fix: Surface eventsStore.error in MyEvent.vue and add catch blocks to handlePrimary and handlePrimaryAction that render a role="alert" block with the mapped message and a retry control. console.error is not user feedback.

5. Payment succeeded, confirmation failed: raw error, no way out — High · MSG-04 · MSG-02 ​

Where: widgets/src/widgets/my-events/components/Checkout.vue:425-435What: paymentError.value = e instanceof Error ? e.message : "Impossible de valider l'inscription après le paiement." — the friendly French sentence is only used for non-Error throws, so in practice the user sees the raw thrown string (HTTP 500, or a NestJS message). This is the state after the card has been charged: the modal stays open, the only footer buttons are "Retour" and "Fermer", and there is no retry, no reference number, no support contact. Why it matters: The member has paid and is now looking at HTTP 500 with nothing to do but close the dialog. They cannot tell whether the money is gone. This is the worst dead end in the feature. Fix: Map known codes to French sentences and fall back to one generic line (MSG-02). Show the SumUp checkout reference, a "Réessayer" button that re-calls confirmCheckoutPayment, and a support contact. Same treatment for the raw e.message paths at SumupPaymentWidget.vue:69, WaiverModal.vue:217 and WaiverModal.vue:246.

6. Prices are displayed rounded to whole euros while SumUp charges the exact amount — High · CONTENT-05 (proposed) ​

Where: widgets/src/composables/useFormat.ts:2 — consumed at Checkout.vue:53, 59, 84, EventTile.vue:241-246, EventDetails.vue:47-52What: new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0, style: 'currency', currency: 'EUR' }). Verified in node: 12.5 → "13 €", 49.99 → "50 €", 7.25 → "7 €". The server charges the unrounded value: createSumupCheckout({ totalAmount: unitPrice * group.length }) (events.service.ts:89-93). Evenements.Prix is VarChar(255) free text (schema.prisma:135), so nothing stops an admin entering 12.50. Why it matters: The summary step immediately before "Procéder au paiement" can state a total that is not the total charged. Misstating a price at the point of sale is a consumer-protection problem, not just a polish issue. Fix: Use a currency formatter with the currency's natural precision (minimumFractionDigits: 2) for anything on the money path. Keep the 0-digit formatter, if wanted, for aggregate dashboard figures only. Related, same root: because Prix is free text, a French-style "12,50" yields NaN → unitPrice = 0 on both client (Checkout.vue:190-193) and server (events.service.ts:32-35), silently turning a paid event into a free one. Worth a guard in the admin event form (queue #21).

7. Event capacity is collected from admins but never enforced or shown — High · SEC-06 (proposed) ​

Where: admin/src/client/components/app/modals/ModalEvenement.vue:68 (admins set Limite_inscriptions); no reader anywhere — grep over api/src, admin/src, widgets/src finds only the type declarations and the admin input. What: The capacity value is stored and then ignored. EventsService.createInscriptions validates the deadline and the eligibility conditions but never counts existing inscriptions against Limite_inscriptions, and the widget never displays remaining places. Why it matters: A capped event silently over-subscribes, and because over-subscription happens through a SumUp checkout, members pay for places that do not exist. The e-commerce/checkout corpus flags exactly this class ("Ignoring abuse and fraud paths — plan rate limits and authorization checks as part of the pattern itself"). Staff currently have no signal that the limit they typed does nothing. Fix: Enforce the count server-side inside createInscriptions, before creating the SumUp checkout, and return a French BadRequestException. Show "X places restantes" on the tile and disable the register button at zero.

8. The expand/collapse control is a click-only <div> — High · A11Y-03 ​

Where: widgets/src/widgets/my-events/components/EventTile.vue:3-6What: <div class="... cursor-pointer select-none" @click="expanded = !expanded"> — no tabindex, no role="button", no aria-expanded, no keyboard handler. Why it matters: Keyboard and screen-reader users cannot open the tile, which means the event description, dates, price breakdown, registration deadline and the whole Inscriptions panel are unreachable for them. The register button inside is separately focusable, so a keyboard user can pay for an event whose details they were never able to read. The checkout corpus's first accessibility requirement is that the flow be completable by keyboard alone. Fix: Make it a real <button type="button"> with :aria-expanded="expanded" and :aria-controls pointing at the panel id, keeping the existing @click.stop on the nested action area.

9. No live regions anywhere in the widget — High · MSG-01 ​

Where: verified by grep — zero occurrences of role="alert", role="status" or aria-live under widgets/src. Affected: Checkout.vue:26 (payment error), Checkout.vue:11-17 (step changes), WaiverModal.vue:15, 19, 117, SumupPaymentWidget.vue:3-4, EventRegistrations.vue:5-10What: Every error, every stepper transition, the "Chargement du paiement…" state and the "Merci 🌟 / la décharge est enregistrée" success state are silent DOM swaps. Why it matters: A screen-reader user moves through a four-step payment flow with no announcement of which step they are on, whether the payment failed, or whether the legally binding waiver was actually recorded (WCAG 4.1.3). Fix: role="alert" on the error blocks, role="status" / aria-live="polite" on the loading and success blocks and on a step-change announcer. Verify whether BccMessage/BccDialog already supply roles before adding them in this repo — the fix may belong upstream in @bcc-code/component-library-vue (see per-project note in BASELINE.md).

10. Every failure state collapses into "Événement non trouvé", with no way out — High · MSG-02 · MSG-03 · MSG-04 · CONTENT-02 ​

Where: widgets/src/widgets/my-events/components/MyEvent.vue:13 → EventNotFound.vue:1-8What: <EventNotFound v-if="events.length === 0" /> is the only non-happy surface the widget has. Because loading starts false and initialize() swallows its own error, this same info message is what the user sees when: they genuinely have no events; the API is down; or the widget session login (MyEvent.vue:41) rejected. It offers no retry. Why it matters: This is an embedded bundle inside someone else's host page — there is no router, no app shell, no navigation to escape to. A member whose session token expired is told the event does not exist and has literally nothing to click. And the copy is wrong for the commonest case: an empty list means "no events", not "event not found". Fix: Split the three states: a genuine empty state ("Aucun événement pour le moment"), an error state with a "Réessayer" button bound to initialize(), and an auth state telling the member to reload the portal page. Keep loading true until the first fetch settles.

11. The comment field has no label and no indication that it is published to every member — High · FORM-01 · FORM-04 ​

Where: widgets/src/widgets/my-events/components/Checkout.vue:63-69 (the field); widgets/src/widgets/my-events/components/EventRegistrations.vue:37-39 (the disclosure) What: The step renders a bare <BccTextarea rows="6"> with no label, no placeholder, no hint and no character limit — the only clue to its purpose is the word "Commentaire" in the stepper, which is not programmatically associated with the control. That text is then rendered in the public Inscriptions table to any member who expands the tile. The server comment at widget-events.controller.ts:64-65 scopes the intended disclosure to names ("names on the public registration list are intentionally visible to members"), not to free-text comments. Why it matters: An unlabelled six-row box in a family registration flow invites exactly the content people should not broadcast — a child's allergies, medical notes, "we'll arrive late because of a hospital appointment". Commentaire is VarChar(255) with no client-side limit either, so long input is silently truncated by the database. Fix: Add an associated label and a hint that states the audience ("Visible par les autres participants"), plus a maxlength="255" with a counter. If the audience was not intended to include comments, drop the column from EventRegistrations.vue instead.

12. A blocked registration is explained only by a hover tooltip — or not at all — Medium · MSG-03 · A11Y-03 · FORM-05 ​

Where: widgets/src/widgets/my-events/components/EventTile.vue:18-32 and 221-224What: The register button is disabled for four different reasons (isLoading || isOutsideTargetGroup || eligibilityLoading || isRegistrationLocked) and only one of them is ever explained: v-tooltip.top on the wrapping <span> for the target-group case. A disabled button is not focusable and the <span> has no tabindex, so that explanation is mouse-hover only and invisible on touch. The deadline case (isRegistrationLocked) has no explanation at all — the label still reads "S'inscrire" on a permanently greyed button. Why it matters: A member arriving after the registration deadline sees a dead button with no reason, no date and nothing to act on. Nothing tells them the deadline has passed or when it was. Fix: Render the reason as visible text next to the button ("Inscriptions closes depuis le 12 mars", "Tu ne fais pas partie du groupe cible") rather than as a tooltip. Per FORM-05, prefer keeping the control enabled and explaining the block on activation.

13. The checkout modal can be dismissed mid-payment and mid-submit — Medium · NAV-06 (proposed) ​

Where: widgets/src/widgets/my-events/components/Checkout.vue:6-7What: :dismissable-mask="true" and :closable="true" are unconditional, including while submitting is true and while the SumUp card iframe is mounted. Its sibling WaiverModal.vue:6-7 gets this right — :dismissable-mask="!submitting", :closable="!submitting". Why it matters: A stray backdrop click during card entry tears down the SumUp widget mid-transaction. Recovery exists (the tile shows "En attente de paiement" and reopens the payment modal), so this is friction rather than loss — but the content-management/modal corpus is explicit: "Only allow closing on background click if it's non-critical info. For important data, require an explicit close action." Fix: Mirror WaiverModal: :dismissable-mask="!submitting && mode !== 'payment'", same for :closable.

14. Submit buttons are disabled both when invalid and while submitting, with no message — Medium · FORM-05 · FORM-06 ​

Where: Checkout.vue:99-103 + 364-377; Checkout.vue:93-98 ("Retour" disabled while submitting); WaiverModal.vue:130-135 + 183-189What: primaryDisabled returns true when no member is selected or when submitting; WaiverModal disables on !canSubmit || submitting. In neither case is any text rendered saying which requirement is unmet — the waiver needs name, email, relationship, a signature and the consent checkbox, and the user gets a grey button and no clue which one is missing. Why it matters: FORM-05 — a disabled submit gives the user nothing to act on. FORM-06 — disabling the button the user just activated moves focus to <body>, so a keyboard user loses their place at the exact moment the "Envoi…" state appears. Fix: Keep both buttons enabled, guard inside the handler, and use aria-busy="true" plus the "Envoi…" label for the in-flight state. Render a short list of what is still required instead of relying on the disabled state to communicate it.

15. Waiver form: required fields are unmarked and none carry autocomplete — Medium · FORM-04 ​

Where: widgets/src/widgets/my-events/components/WaiverModal.vue:49-79What: Four of the seven inputs are required by canSubmit (Nom, Email, Lien de parenté, signature, consent) but only the optional ones are marked — "(optionnel)" on the emergency contact and medical fields. Nothing marks the required ones, and no field states its format. The inputs are hand-rolled <input class="border border-gray-300 ..."> rather than the component library's field components, and none sets autocomplete (name, email, tel would all apply). Why it matters: This is a legal document a parent signs for a minor. Constraints must be visible before typing, not inferred from a button that refuses to activate. The raw border-gray-300 also bypasses the BCC tokens the rest of the repo mandates. Fix: Mark required fields, add autocomplete="name" | "email" | "tel", and move to the shared field components so focus, error and token styling come for free. Labels themselves are correctly associated here (wrapping <label>) — that part passes FORM-01.

16. Copy and polish — Low · CONTENT-02 · CONTENT-04 ​

Where: MyEvent.vue:2-5; EventTile.vue:13-14; SignatureCanvas.vue:87 + assets/tailwind.css:1; SumupPaymentWidget.vue:35,44,51What:

  • The container heading is the English literal title="Event" in an otherwise entirely French UI, and it does not echo anything the member clicked (CONTENT-02).
  • When Prix is present but unparseable, formattedPrice returns "" while the · separator still renders, leaving a dangling bullet in the tile subtitle.
  • SignatureCanvas renders the typed signature in 'Dancing Script', which is never imported — tailwind.css:1 loads Archivo only, so it silently falls back to the browser's generic cursive. (Shared with queue #2.)
  • The SumUp loader's failure strings are untranslated English technical text ("Failed to load SumUp SDK", "SumUp SDK not available") shown directly to French users — see finding 5.
  • "Chargement du paiement…" is good practice under CONTENT-04; the checkout's own submitting state has no equivalent waiting text at all.

Unverified ​

  • A11Y-01 (contrast) — cannot be settled from code. The semantic classes used throughout (text-secondary, text-tertiary, text-danger, border-on-primary) resolve inside @bcc-code/component-library-vue/theme.css, which is not installed in this checkout (node_modules absent), so I could not even confirm the tokens exist. If text-danger does not resolve, every error message in this feature renders in body colour — worth checking with the app running. The amber waiver banner (EventTile.vue:39-43, text-amber-800 on bg-amber-50) also needs measuring.
  • A11Y-06 (responsive / short viewport) — needs a rendered viewport. The waiver modal is a long form (min-w-70 max-w-140, seven fields plus an 800×200 signature canvas) inside a dialog, and the checkout dialog embeds a third-party card iframe. Both are the kind of thing that breaks with a mobile keyboard open.
  • A11Y-02 (target size) — every control uses size="small", including the "Signer" buttons in the waiver banner and the Détails/Inscriptions toggles. Plausibly under 24×24 but not measurable from class names.
  • Focus management in BccDialog — whether focus is trapped, moved on open and restored to the trigger on close is PrimeVue behaviour I could not inspect without node_modules.
  • Whether cents actually occur in Prix — finding 6 is a defect in the formatting code regardless, but its blast radius depends on production data I cannot see.
  • CONTENT-01 — not-applicable, see the project-level i18n finding.

Baseline additions ​

Six proposed rules, each seen concretely here and each plausible in the other three repos:

  • MSG-06 — Status and error text is bound to the state it describes. A message must not render when its condition is false; v-else chains must be checked against the condition they actually follow. (finding 1)
  • MSG-07 — Every async user action that can fail renders a visible failure state. console.error, a silently-set store error field, and an unhandled promise rejection are not user feedback. (finding 4)
  • CONTENT-05 — Monetary amounts are displayed at the currency's full precision. The figure shown at the point of payment must equal the figure charged. (finding 6)
  • SEC-06 — Limits that protect a finite resource (capacity, stock, quota) are enforced server-side at write time, before any payment is initiated; the client may only mirror them. (finding 7)
  • NAV-06 — A modal holding an in-flight transaction is not dismissable by backdrop click or Esc until the transaction settles. (finding 13)
  • FORM-12 — A guard that blocks an invalid or duplicate submission must be driven by data the view has actually loaded. A guard evaluating against an empty dataset is worse than no guard, because the UI advertises a protection that is not there. (finding 2)

Cross-project note ​

  • MSG-07 (silent failures) and MSG-01 (no live regions) are the two most likely to generalise. Both are cheap-to-write omissions rather than deliberate choices, and tt-time-tracker and customer-portal should be grepped for console.error in a catch and for zero role="alert" occurrences as a first pass.
  • CONTENT-05 (currency precision) — useFormat is a members-local composable, but customer-portal's marketplace/bids paths and tt-time-tracker's invoicing both format money. Check their formatters for maximumFractionDigits.
  • SEC-06 (server-side limits) and FORM-12 (inert guards) map directly onto customer-portal's bids flow, which has the same shape: a client-side eligibility check in front of a server write that takes money.
  • NAV-06 / modal dismissal — worth checking wherever PrimeVue Dialog wraps a submit; customer-portal and tt-time-tracker both use PrimeVue v4 directly, so the same dismissable-mask default applies.
  • Notably not a cross-project risk: the SumUp integration itself. The server verifies payment with the provider before confirming (api/src/events/sumup.ts:49-58, called from events.service.ts:146-150), and the widget session scopes every write to the caller's family (widget-events.controller.ts:29-44). The problems here are all in the layer above.