Appearance
[UX] members — My memberships (B-Active)
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: bcc-nancy/members · Branch:
develop@2c22f5a· Files reviewed: 20 Patterns:forms/selection-input(consulted here);content-management/accordionandforms/form-validationwere mined in the BCC sibling audit and are cross-referenced rather than re-fetched.
Summary
B-Active is the BCC widget plus money: the same accordion, the same shared Condition / SelectField / Toast components (so it inherits the BCC draft's findings one-for-one — see Inherited below), with a club selection, a cotisation total and a six-checkbox financial commitment layered on top. Every one of those additions is weaker than the part it was copied from. The member is asked to commit — in writing, via a required checkbox — to "la somme totale indiquée ci-dessus", a figure the code itself documents as an estimate the server will recompute, whose composition (10 €/month × remaining months + club prices) is never shown and whose per-club prices are omitted from the very field (secondary) that exists to carry them. Separately, the widget derives a member's club enrolments from a legacy scalar the API keeps only as a derived value, not from the Clubs relation it actually returns — which makes a no-op save file phantom change requests for multi-club members, and risks dropping club rows outright.
Findings
1. Club enrolments are read from the wrong field — a no-op save files phantom change requests, and can drop a club — High · MSG-05
Where: widgets/src/widgets/my-memberships/composables/cotisation.ts:46-49, widgets/src/widgets/my-memberships/stores/bactive.store.ts:29-38,109-124, widgets/src/widgets/my-memberships/components/MembershipBActive.vue:47-57,67-81What: clubIdsOfRow(m) reads m.ClubIds ?? m.Club and never looks at m.Clubs — but Clubs (the Adhesions_clubs relation) is the only club data the API returns for an existing row (api/src/adhesions/widget-adhesions.controller.ts:156-167); Club is a single legacy scalar the server derives as "the club whose Debut/Fin bracket today" (adhesions.service.ts:76-88, null when none does), and ClubIds is a client-only field that does not exist until MembershipBActive's clubIds getter runs during render (MembershipBActive.vue:67-74).
Two consequences follow.
(a) Certain, once the panel has rendered. initialize() snapshots each existing row before anything renders (useMembershipsBase.ts:82-86), so the snapshot's clubs value is [legacy Club] — one id, or none. After render, ClubIds holds the full set from the relation. submit compares the two (useMembershipsBase.ts:125-128), sees a difference, and for a locked row files a change request. Any member enrolled in two clubs (or whose active-club scalar is null) therefore gets a "Demande en attente" tag and an admin approval task every time the family presses Save, having changed nothing.
(b) Conditional on how PrimeVue mounts collapsed accordion panels — see Unverified. If BccAccordionContent mounts its slot lazily, a member whose panel was never expanded reaches onBeforeSave with ClubIds undefined, so m.Clubs is rebuilt from the single legacy scalar (bactive.store.ts:109-123) — and the PATCH's O2M semantics disassociate every omitted row (adhesions.service.ts:227-247). A second club, or all clubs when Club is null, would be deleted and Cotisation recomputed lower. Why it matters: In (a) the member's record is modified without them acting, and an admin is asked to approve a change nobody requested — exactly the trust the grace-period mechanism exists to protect. In (b) a paid-for club enrolment disappears from a save the member thought was about someone else. Fix: Make clubIdsOfRow read Clubs as the primary source (m.ClubIds ?? m.Clubs?.map(c => c.Club) ?? [m.Club]), so the snapshot taken at initialize() and the value computed at submit are derived from the same field. Do not let the payload's club set depend on whether a component rendered: derive ClubIds once in initialize(), not in a computed getter that also mutates its own model.
2. The member commits in writing to a total that is never explained, never announced, and known to be an estimate — High · FORM-04, MSG-01
Where: widgets/src/widgets/my-memberships/MyMembershipsBActive.vue:18-30, widgets/src/widgets/my-memberships/components/ConditionsBActive.vue:5-8, widgets/src/widgets/my-memberships/composables/cotisation.ts:1-22,52-55, widgets/src/widgets/my-memberships/stores/clubs.store.ts:9-12What: The first condition — required before Save unlocks — reads « Je m'engage à régler la somme totale indiquée ci-dessus ». The sum in question is the banner at lines 18-30, and:
- Its composition is never stated.
estimateCotisationis10 € × remaining months + Σ club prices; the UI shows one number. A member cannot tell why registering in July costs less than in January, or what a club adds. - Per-club prices are not shown.
clubs.optionsbuilds{ value, label, secondary: '' }—Selectbox.vue:15renderssecondarynext to each option and the club catalogue carriesPrix(bactive.store.ts:41), so the affordance exists and is left empty. The member picks clubs blind and discovers the cost only if they scroll back up. - The total is explicitly an estimate.
cotisation.ts:1-22documents that the server is authoritative and that thefoot_u13month exemption is deliberately not replicated, so the widget knowingly shows some members a higher figure than they will be billed. Nothing in the UI says « estimation ». - The change is silent. Selecting a club updates the banner, which on mobile (
grid-cols-1 lg:grid-cols-2, line 32; banner above the accordion, Save in a sticky footer below) is off-screen while you are choosing — and there is noaria-liveregion anywhere in the widget, so a screen-reader user gets no signal that the price moved at all. - The progress variant (
modeis initialised to'progress'and never changed,bactive.store.ts:26) renders a bare<div>bar with norole="progressbar"and no accessible name, above « 0 / 250 € » — the left-hand number (amount already paid, formatted without a currency symbol bynumber()) is never labelled. Why it matters: This is the financial commitment the whole widget exists to capture. The member ticks a box binding them to a number they cannot break down, that will not match the invoice, and that changes without telling them. Fix: Show the breakdown ("Adhésion : 10 € × 6 mois = 60 € · Foot O18 : 90 € · Total : 150 €"), put each club's price in thesecondaryfield of its option, label the paid/total figures, mark the total « estimation — le montant définitif est calculé à l'enregistrement », wrap the banner in arole="status" aria-live="polite"region, and give the barrole="progressbar"witharia-valuenow/aria-valuetext.
3. Six conditions attest to documents the widget never shows — High · FORM-12 (proposed in the BCC draft)
Where: widgets/src/widgets/my-memberships/components/ConditionsBActive.vue:16-18What: « J'ai pu consulter les statuts, le règlement intérieur de B Active ainsi que les règlements des clubs et je m'engage à les respecter » is a plain <Condition> with no link, no help slot, and no accordion — the only condition of the six with nothing behind it. Three separate documents (statuts, règlement intérieur, and one règlement per club) are attested to and none is reachable from the widget. This is the same rule as BCC finding 2, one step worse: BCC at least intends a link and only breaks it outside 2025-2026; here there was never one. Why it matters: The association is recording an attestation ("j'ai pu consulter") that the interface makes impossible to have satisfied. As a consent record it is worth nothing, and a member who wants to read the club rules before committing money has nowhere to go. Fix: Link each document (served per year/club from the API, as FORM-12 in the BCC draft argues), or reword the condition to what is actually true.
4. The decisive question can be left unanswered, and silently saves as "not a member" — High · FORM-04
Where: widgets/src/widgets/my-memberships/components/MembershipBActive.vue:3, widgets/src/components/Selectbox.vue:10, widgets/src/widgets/my-memberships/stores/bactive.store.ts:55-65, widgets/src/widgets/my-memberships/composables/useMembershipsBase.ts:87-91What: « Je souhaite être membre de B Active » is a Oui/Non select whose schema entry is yup.boolean() — not required. Selectbox passes placeholder=" " (a single space), so an unanswered select is visually indistinguishable from an answered one: it renders as an empty box with no "Sélectionner…" text, no required marker, and no error. initialize() pushes a blank row for every family member (line 90), so an unanswered row is submitted for each of them; Est_membre undefined is persisted as null, i.e. not a member, Cotisation 0 — while Conditions_acceptees: true is stamped on the row regardless (bactive.store.ts:107). The same applies to Droit_image (.nullable()) and Allergique. Why it matters: A parent who fills in one child and misses another gets a successful save, a green toast, and a child who is silently not a member — with the consequence the widget itself warns about two lines below (« Attention ! … il est obligatoire d'être membre de B Active pour participer aux camps et aux activités du samedi soir »). Nothing distinguishes "I chose Non" from "I never looked". Fix: Make Est_membre (and Droit_image when Est_membre) required in the schema; give Selectbox a real placeholder (« Choisis une réponse ») and mark unanswered rows in the accordion header, so the member sees which of their children is still blank before saving. Do not stamp Conditions_acceptees on rows the member never answered.
5. The club field: an option that does nothing, a singular label, and no programmatic label at all — Medium · FORM-01
Where: widgets/src/widgets/my-memberships/components/MembershipBActive.vue:6,13-14,20, widgets/src/widgets/my-memberships/stores/clubs.store.ts:9-12, widgets/src/components/Selectbox.vue:38-55, .../SelectField.vue:3-4What: Four defects in the B-Active-only fields: (a) clubs.options prepends { value: null, label: 'Aucun' } and the field is rendered multiple. In a multi-select "Aucun" is meaningless, and it is also inert: Selectbox's setter maps it to null, clubIds's setter drops it with .filter(Boolean) (MembershipBActive.vue:77), and the getter then re-derives the checkbox list — so ticking « Aucun » makes the option tick and immediately untick itself. forms/selection-input's functional-testing checklist calls this out directly: "confirm selecting an option updates form state and submitted value". (b) The label is singular — « Je m'inscris au club » — on a field that accepts several, with no hint that a second club costs extra or that Foot O18 is treated specially (computeMainClubId, lines 59-65, silently designates a non-Foot club as the "main" one; nothing in the UI mentions this rule). (c) SelectField renders <label><slot/></label> with no for and passes no inputId to BccSelect/BccMultiSelect — identical to BCC finding 5, and it now covers five comboboxes per member instead of four. The forms/selection-input reference implementation is <label for="fruits"> + <select id="fruits">. (d) B-Active-only bug: both textareas are name="allergies" (lines 14 and 20 — the BCC file correctly uses name="renseignements" for the second). With <label for="allergies"> at line 13, the allergies label is ambiguous; if the library forwards name to id (unconfirmed, see Unverified), two controls share an id and clicking « Précisez lesquelles » can focus the « Autres renseignements » box instead. Both names also repeat across every family member's panel. Why it matters: A screen-reader member hears five unlabelled comboboxes per child, one of which decides image rights and one of which costs money; a sighted member gets an option that appears broken and a label that understates what the field does. Fix: Drop « Aucun » from a multi-select (deselecting all is the empty state); pluralise the label and state that additional clubs are chargeable; generate ids with useId() in SelectField/Selectbox and pass them as input-id plus the label's for; rename the second textarea and give both per-row unique ids.
6. Toggling membership to Non silently deletes the club selection and the image-rights answer — Medium · MSG-05
Where: widgets/src/widgets/my-memberships/components/MembershipBActive.vue:83-90What: Same shape as BCC finding 9, with more at stake: the watcher clears Club, ClubIds, Clubs and Droit_image with no confirmation and no notice. Setting Non then Oui again therefore loses the club enrolments — the part of the form that carries the price — and the member must notice the field has gone blank. On save the emptied Clubs array disassociates the stored rows server-side (adhesions.service.ts:227-247), so a mis-click that is corrected before saving is fine, but one that is not is a real deletion. Why it matters: A resignation is a consequential status change taken on one select, with no statement of what it costs the member and no warning that their club registrations go with it. Fix: Confirm the switch to Non and name the consequence ("tes inscriptions aux clubs seront supprimées"), or keep the values and clear them server-side.
Inherited verbatim from the BCC draft — do not fix twice
These are the same source files or byte-identical templates; see members--my-memberships-bcc.md for the full write-ups. Confirmed present here at the line numbers below.
| BCC finding | Rule | B-Active location | Note |
|---|---|---|---|
| 1 — blocked save does nothing, and a hidden field can block it | MSG-01 | bactive.store.ts:60-63, useMembershipsBase.ts:119-155, MembershipBActive.vue:12-15 | Identical Allergies-required-when-Allergique rule, identical unbound textarea, identical zero-feedback outcome. Still zero role="alert"/aria-live in the bundle. |
| 3 — consent checkbox nested inside the accordion header | A11Y-03 | Condition.vue:8-12 (shared file) | Applies to six conditions here, all of which gate Save. |
| 4 — the RGPD terms are collapsed behind the consent control itself | FORM-13 (proposed) | ConditionsBActive.vue:29-36 | Same ~90-word notice, same :value="null" accordion. Four of the six conditions hide substantive terms this way, including the payment-by-instalment terms (line 7) and the no-refund clause (line 11) — the latter is arguably more consequential than BCC's. |
| 6 — failed mount leaves a skeleton forever | MSG-03 | MyMembershipsBActive.vue:100-105 | Same unguarded onMounted chain, plus one more await (clubs.get()) that can throw. |
| 7 — Save disabled until every condition is ticked, never says why | FORM-05 | MyMembershipsBActive.vue:66,110-113 | Six conditions instead of three, stacked below the accordion on mobile; Condition.vue's error styling is correspondingly unreachable. |
| 8 — pending change requests are invisible and cannot be withdrawn | MSG-04 | MyMembershipsBActive.vue:49-52, useAdhesionLock.ts:24-33 | Same; compounded by finding 1 above, which can create the pending tag without the member acting. |
| 10 — toast timer leak, heading levels, English « Close », empty-state copy | NAV-02, A11Y-04, A11Y-05 | Toast.vue (shared), MembershipBActive.vue:10,18, MyMembershipsBActive.vue:8-11 | The empty-state copy reads worse here: « Ce formulaire est réservé aux personnes majeures » on a widget whose own body text is addressed to « les jeunes ». |
One shared defect worth restating because its B-Active symptom differs: session.year is Number(props.year) (session.store.ts:24), so a host page that omits or mistypes the attribute yields NaN. In BCC that broke a PDF link; here it renders the title « Mon adhésion B Active NaN » and puts NaN inside the financial commitments the member is ticking (« par mensualité jusqu'à décembre NaN inclus », « la totalité de la saison NaN »), while ?annee=NaN returns no rows and the widget falls through to the misleading "réservé aux personnes majeures" empty state (MSG-03, Medium).
Unverified
- A11Y-01 (contrast) — needs computed colours. The banner is white-on-
bg-brand-800withopacity-80/opacity-60text (MyMembershipsBActive.vue:19,25) and the helper text istext-neutral-400/500/600attext-xs(lines 44;MembershipBActive.vue:5,8,19,23). Theopacity-60price denominator on a dark teal fill is the most likely failure. Not asserted. - A11Y-06 (responsive / short viewport) — needs a rendered page. Two specifics to check: the price banner is above the accordion while the club select is inside it, so on mobile the number the member is committing to is off-screen while they change it; and
ConfirmToastpositions itself from the host page's geometry (see the BCC draft). - Whether
BccAccordionContentmounts collapsed panels (v-show) or renders them lazily (v-if).node_modulesis not installed in this checkout, so I could not read PrimeVue 4'sAccordionContent. Finding 1 branch (a) holds either way for panels the member does open; branch (b) — actual deletion of club rows — only occurs if mounting is lazy. This one is worth five minutes with the app open: if it is lazy, finding 1 is a Blocker, not a High. - Whether
BccTextareaforwardsnametoid— affects finding 5(d) and BCC finding 5. Same reason. - A11Y-02 (24×24 targets) for
BccCheckboxand the multi-select's option rows — library CSS, unreadable here. - CONTENT-01 — not-applicable, see project-level i18n finding.
Baseline additions
FORM-12 and FORM-13 as proposed in the BCC draft both fire again here (findings 3 and the inherited RGPD one), which is the second sighting each — they look ready to promote. One new proposal, and one supporting note:
| ID | Proposed rule |
|---|---|
| FORM-14 | An unanswered choice control is visually and programmatically distinguishable from an answered one, and a form never persists "unanswered" as a consequential default. If a question decides membership, payment or consent, it is required; a blank placeholder is not an answer. |
Supporting note for a money rule (seen once so far — hold until a second project shows it, likely customer-portal's marketplace or playout's checkout): a figure the user is asked to commit to states its basis — what it is composed of, and whether it is final or an estimate. An amount the system knows it will recompute is never presented as the amount being agreed to. Finding 2 is currently written against FORM-04 + MSG-01.
The silent-validation-failure candidate from the BCC draft now has its second sighting (inherited finding 1 is the same code, so this is arguably still one sighting — treat customer-portal or tt-time-tracker as the tiebreaker).
Cross-project note
- members / My memberships — BCC (queue #3) shares findings 4 and 6 of this draft, which the BCC audit did not raise:
Est_membre/Droit_image/Aptitudeare equally non-required inbcc.store.ts, andSelectbox'splaceholder=" "is the same shared component. Findings 1, 2, 3 and 5(a)(b)(d) are B-Active-only. - The unlabelled-select defect (5c) is the shared
SelectField/Selectboxpair, so it also hits every other widget that uses them, and the equivalent PrimeVueinputIdrequirement makes it a strong candidate in customer-portal and tt-time-tracker. - "Unanswered saves as a default" (FORM-14) is worth grepping for wherever a yup/zod schema uses a bare
.boolean()/.nullable()on a question that drives behaviour — all three other projects hand-roll their schemas. - No
aria-liveanywhere — as in the BCC draft,grep -r "aria-live\|role=\"status\"\|role=\"alert\"" widgets/srcreturns nothing. Whatever is fixed for MSG-01 should be a shared component, not a per-widget patch.