Skip to content

[UX] customer-portal — Marketplace ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 24 Patterns: data-display/filter-panel

Summary ​

The marketplace grid and listing detail are well-built by this repo's own conventions — PageLayout/DataTable shells, server-side filters that really do reach the API, and complete en/nb parity for every marketplace.* key. The problems are concentrated in the money path: the bid dialog can post the same bid twice (no in-flight guard on the Enter-key path, fresh idempotency key per call), a multi-machine bid never shows which machines it covers or whether the amount is per machine or for the lot, and opening a listing to check one of them destroys the whole selection because the list holds all of its state in component refs with no keep-alive and nothing in the URL. Prices are also rendered with maximumFractionDigits: 0 against a Float column, so a listing at 499 999,60 reads "500 000 kr".

Two things the assignment asked me to test specifically: filters are not inert — priceMin/priceMax/yearMin/yearMax travel from the bucket selects through useMarketplace → fetchPage → the controller → real Prisma gte/lte clauses, so the tt-time-tracker buildApiFilters defect does not reproduce here. Currency precision does reproduce, in nine separate hand-rolled formatters.

Findings ​

1. A bid can be submitted twice, creating two offers — High · FORM-06 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:44-60, :75-78; apps/web/src/domains/bids/bids.mutations.ts:17-31What: handleSubmit guards only on canSubmit (if (!canSubmit.value || bidAmount.value === null) return;) — it never checks isSubmitting. The only in-flight protection is PrimeVue's :loading on the footer button, and the footer button is outside the <form>; the form itself submits via @submit.prevent="handleSubmit". The form has exactly one implicit-submission-blocking field (the amount input), so Enter in that field submits directly, bypassing the disabled button entirely. Each invocation of the mutation calls generateRequestId() and sends it as a new idempotency-key, so the backend's idempotency store cannot collapse the duplicates — they are, by construction, two different commands. Why it matters: Pressing Enter twice, or Enter while the first request is in flight on a slow mobile connection, creates two bid commands for the same machines. The user gets two success toasts (or one success and one confusing error), and only discovers the duplicate later in My bids. This is exactly the case BASELINE FORM-06 calls out: "use aria-busy and guard in the handler." Fix: Add if (isSubmitting.value) return; at the top of handleSubmit, set aria-busy on the form while pending, and generate the idempotency key once per dialog-open (or once per attempt, reused on retry) rather than once per mutation call, so a genuine retry after a network failure is deduplicated server-side.

2. Inspecting a listing wipes the multi-machine bid selection — High · FORM-09 ​

Where: apps/web/src/pages/MarketplacePage.vue:15-34 and :191-196; apps/web/src/domains/marketplace/marketplace.composable.ts:14-42; apps/web/src/router/index.ts:179-194What: All list state — selectedIds, search, page, and the price/year filters — lives in refs created inside useMarketplace() when the page mounts. Nothing is in the URL (the route is a bare /marketplace with no query params) and there is no <KeepAlive> anywhere in the app (grep for KeepAlive/keep-alive across apps/web/src returns zero hits). The card's entire body is a button that navigates to marketplace.detail, so the natural "let me look at this one properly" gesture unmounts the page. Coming back — via useBackNavigation, the sanctioned affordance — remounts it with an empty selection, empty search, no filters and page 1. Why it matters: The select-then-bid flow and the inspect-before-you-buy flow are mutually exclusive. To bid on four machines the user must commit to all four from a 96px thumbnail, a truncated model name and a rounded price, because opening any one of them empties the basket. useMultiSelect's doc comment promises "selection deliberately survives filter or search changes" — it does, but only until the user navigates, which is the case that actually matters. The same round trip also loses a filtered, paginated result set, so a user comparing machines on page 3 of "100 000–500 000" restarts from scratch each time. The filter-panel pattern's testing guidance is explicit here: "confirm that state survives refresh, navigation, or retry in the way users would expect." Fix: Reflect search, page and the active filter buckets in the query string (they are already flat scalars, so this is a useRouteQuery-shaped change in useMarketplace), and hoist the marketplace selection to a module-scoped or Pinia-held set keyed to the marketplace flow, cleared on successful submit and on sign-out. A shareable/refreshable marketplace URL is a bonus this surface should have anyway.

3. A multi-machine bid never says which machines, or whether the amount is per machine — High · MSG-05 ​

Where: apps/web/src/pages/MarketplacePage.vue:127-128 and :266-272; apps/web/src/components/marketplace/BidDialog.vue:79-97; apps/web/src/i18n/messages/en.yml:189, :197What: The dialog's only description of its subject is bidLabel = t("marketplace.bid.selectedCount", { count }) → "Bid on 5 machines" / "Bud på 5 maskiner". The machine list is never rendered; the user cannot see which five. The amount field is labelled "Your bid (NOK)" / "Ditt bud (NOK)" with placeholder "Enter amount". Server-side, CreateBidCommandBody (apps/api/src/modules/bids/bids.dto.ts:57-96) carries one bidAmount plus machineExternalIds[], and toBidResponse maps that to a single TradeOffer covering the group — i.e. the amount is a lot price, not a per-machine price. Nothing in the UI says so. Why it matters: A user who reads "Your bid (NOK)" as per-machine enters 500 000 and commits to 500 000 for the entire group — a fivefold error on a real financial commitment, made against a set of machines they cannot review at the point of confirmation. Compounded by finding 2, the selection may also contain machines added several filter changes ago that the user has forgotten. Bids can be edited afterwards (bidsUpdate), so this is not strictly irreversible, but the confirmation step is where the error should be caught. Fix: Render the selected machines (model · year · asking price) as a scrollable list inside the dialog with a per-row remove control, show the summed asking price, and change the amount label to state the basis explicitly — e.g. "Your bid for all {count} machines (NOK)" / "Ditt bud for alle {count} maskinene (NOK)".

4. Prices are rounded to whole kroner by the display formatter — Medium · CONTENT-05 (proposed) ​

Where: apps/web/src/pages/MarketplacePage.vue:37-46; apps/web/src/pages/MarketplaceListingDetailPage.vue:22-33What: Both pages build new Intl.NumberFormat("nb-NO", { style: "currency", currency: "NOK", maximumFractionDigits: 0 }). Per ECMA-402, an unspecified minimumFractionDigits is clamped down to the supplied maximum, so the formatter rounds rather than truncating. Verified in node against this exact option bag: 12.5 → "13 kr", 499999.6 → "500 000 kr". priceExpected is Float? in Prisma (packages/database/prisma/app/trade_in_machine.prisma:9), so fractional values are representable and arrive from Dataverse unmodified. Why it matters: The asking price a buyer anchors their bid on is not the asking price the system holds. It also collides with the page's own price buckets: a machine at 499 999,60 renders as "500 000 kr" but is returned by the "100 000–500 000" filter and excluded from "Over 500 000", so the grid appears to contradict the filter that produced it. Fix: Drop maximumFractionDigits: 0 and let the NOK currency default (2) stand, or set minimumFractionDigits: 0, maximumFractionDigits: 2 if trailing "00" is unwanted. Do it in one shared money formatter, not per page — see the repo-wide note below. Repo-wide: the same whole-krone pattern is hand-rolled in nine places: apps/web/src/utils/formatters.ts:40, apps/web/src/utils/internal/formatters.ts:40, apps/web/src/components/OrderCard.vue:46, MarketplacePage.vue:40, MarketplaceListingDetailPage.vue:25, MyBidsPage.vue:18, BidDetailPage.vue:27, TradeInMachinesPage.vue:95, TradeInMachineDetailPage.vue:41. This probably deserves promotion to a project-level finding.

5. Error messages show the TanStack query status, and the 403 branch is unreachable — Medium · MSG-02, MSG-03 ​

Where: apps/web/src/pages/MarketplacePage.vue:156; apps/web/src/pages/MarketplaceListingDetailPage.vue:40-47; apps/web/src/domains/marketplace/marketplace.collection.ts:78; packages/domain-helpers/src/create-domain-helpers.ts:135; apps/web/src/i18n/messages/en.yml:147, :175What: Both pages interpolate status into "Could not load the marketplace (status: {status})". The status they pass is queryResult.status.value — TanStack Query's lifecycle string, "pending" | "error" | "success" — not an HTTP status. On failure it is always the literal "error", so users read "Could not load the marketplace (status: error)" / "Kunne ikke laste markedsplassen (status: error)". The same mistake disables the permission-aware branches: useStatusCode(status) (detail page, line 40) and DataTable's own statusCode computed (packages/ui/src/components/DataTable.vue:247-259) both test the string for a numeric 403, which can never match, so t("forbidden.message") and forbiddenMessage are dead code on every list and detail page built this way. Why it matters: Every failure reads identically and tells the user nothing actionable, while the parenthetical looks like a diagnostic and isn't one. A user denied access to the marketplace by CASL sees a generic breakage message instead of the forbidden copy that was written for exactly that case. Fix: Surface the real HTTP status from the thrown error (the generated client's response object) rather than the query lifecycle state — UseListReturn.status should expose the error, or a sibling error ref should. Then drop (status: {status}) from the user-facing string and let the 403 branch do its job.

6. A failed load and a zero-result filter are both dead ends — Medium · MSG-04 ​

Where: apps/web/src/pages/MarketplacePage.vue:149-169; packages/ui/src/components/DataTable.vue:526-541; apps/web/src/i18n/messages/en.yml:146What: On error, DataTable renders EmptyState tone="error" with a title and a description and no action — there is no retry control anywhere in the component, and the page passes nothing to the #empty-action slot. On zero results it renders empty: "No machines match your search." regardless of whether the emptiness came from the search box, the price bucket, the year bucket or a genuinely empty marketplace — again with no action, and on desktop the only way back is to re-open two separate selects and pick "All prices"/"All years" individually. Why it matters: A transient network failure on mobile strands the user on a static error card whose only escape is a manual page reload. A user who narrows to "Under 100 000" + "2020 and newer" and gets nothing is told their search matched nothing, with no reset. The filter-panel pattern names both of these — "apply or reset controls" is part of the anatomy, and "ignoring non-happy states" is its second listed anti-pattern. Fix: Give DataTable's error EmptyState a retry button wired to a refetch prop (a one-line addition to useList's return), and pass a "Clear filters" action into #empty-action that resets the buckets and the search when any filter is active — with a distinct empty message for the filtered case.

7. Neither bid field has a programmatic label — Medium · FORM-01 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:86-109What: <label class="text-sm font-semibold text-text">{{ t("marketplace.bid.amountLabel") }}</label> sits as a sibling of <InputNumber> inside a wrapper div, with no for attribute and no id on the control, and the input is not nested inside the label. Same shape for the comment <Textarea> at line 101-108. There is no aria-label or aria-labelledby fallback on either control. Why it matters: A screen-reader user tabbing into the bid dialog reaches an unnamed numeric field on a form that commits money — the announced text is the placeholder at best ("Enter amount"), which never says this is the bid amount in NOK. The visual label is present, so this is invisible in review and only shows up in assistive tech. Fix: Give each control an input-id/id (PrimeVue InputNumber accepts inputId) and point the label's for at it, or nest the control inside its <label>.

8. The card's add-to-bid checkbox has no accessible name — Medium · A11Y-05 ​

Where: apps/web/src/pages/MarketplacePage.vue:178-189What: The aria-label (marketplace.list.card.selectA11y → "Add {model} to bid") is set on the wrapping <label> element, not on the Checkbox. aria-label on a <label> does not contribute to the accessible name of the input it labels — the implicit association takes the label's text content, and here that content is empty (the label contains only the checkbox). The neighbouring card button at line 191-196 gets this right, putting its aria-label on the interactive element itself. Why it matters: In the grid every one of the 24 checkboxes announces as a bare "checkbox, not checked". A screen-reader user cannot tell which machine they are adding to a bid, and — given finding 3 — cannot tell afterwards either. The i18n string for the correct behaviour already exists and is translated; it is simply attached to the wrong element. Fix: Move :aria-label onto the <Checkbox> (PrimeVue forwards aria-label to the underlying input), or put the model name inside the label as visually-hidden text.

Where: apps/web/src/pages/MarketplaceListingDetailPage.vue:151-164What: Both Galleria slots render <SafeImage alt="" …>. On the detail page the photos are the content — they are the reason the page exists — and the thumbnail strip is a set of controls whose entire visible content is an image with an empty alt, so each thumbnail button has no accessible name. (The alt="" on the small grid-card thumbnail at MarketplacePage.vue:199-205 is defensible: the model name sits right beside it.) Why it matters: A screen-reader user on a machine listing gets the spec table and nothing else — no indication that photos exist, and a thumbnail rail of unnamed buttons if they tab into it. noPhotos copy exists for the no-image case, so the absence of images is announced but their presence is not. Fix: Give the main image a real alt derived from the listing (t('marketplace.detail.photoA11y', { model, index, total })) and name each thumbnail button "Photo {n} of {total}". Both need new i18n keys in en.yml and nb.yml.

10. The mobile filter sheet and the pager render hardcoded English — Medium · CONTENT-01 ​

Where: apps/web/src/pages/MarketplacePage.vue:149-169 (no apply-label/clear-label/search-label passed); packages/ui/src/components/OptionPickerSheet.vue:23-24; packages/ui/src/components/MobileListHeader.vue:22; packages/ui/src/components/DataTable.vue:664-669What: DataTable exposes applyLabel, clearLabel and searchLabel props precisely so pages can translate them, and MarketplacePage passes none of them, so the defaults ship: the mobile filter sheet's buttons read "Apply" and "Clear", and the compact search's accessible label is "Search". The pagination row is worse — Page {{ page }} of {{ totalPages }} ({{ total }} total) is a hardcoded English template with no prop to override it, and at a page size of 24 it is on screen for any marketplace with 25+ listings. Why it matters: An nb user filtering machines on a phone — the app's primary form factor, per apps/web/CLAUDE.md — sees a Norwegian page whose two filter-sheet buttons and pager are in English. Everything else on this feature is translated properly, which makes the gap read as broken rather than untranslated. Fix: Pass the three labels from MarketplacePage (keys exist for "clear" already: marketplace.list.clearSelection is a different string, so add common.apply/common.clear/common.search), and give DataTable a paginationLabel-style slot or prop so the pager can be localised. This affects every list page in the app, not just this one.

11. The load-failure state is a silent DOM swap — Medium · MSG-01 ​

Where: packages/ui/src/components/DataTable.vue:463-541; packages/ui/src/components/EmptyState.vue (whole file — no role, no aria-live) What: The grid transitions skeleton → EmptyState tone="error" with no live region anywhere in the chain. EmptyState renders a decorative medallion (aria-hidden), a <p> title and a <p> description; nothing announces. The same applies to the empty/zero-result swap. Why it matters: A screen-reader user who applies a filter and gets an error or zero results hears nothing at all — the page simply stops having content (WCAG 4.1.3). Because the results region also has no heading, there is no landmark to navigate back to and re-read. Fix: Add role="alert" when tone === "error" and role="status" otherwise to EmptyState's root, or wrap the results region in an aria-live="polite" container in DataTable. Fix belongs in @aadalen/ui.

12. Changing a filter or page leaves the previous results on screen with no pending state — Low · CONTENT-04 ​

Where: packages/domain-helpers/src/create-domain-helpers.ts:126-133; apps/web/src/pages/MarketplacePage.vue:149-169What: useList sets placeholderData: prev, so during a refetch the previous page's rows stay mounted, and isLoading is queryResult.isLoading — false whenever placeholder data exists. isFetching is never returned from the composable, so the page has nothing to render a pending state from. The filter chip/select updates instantly; the grid does not. Why it matters: On a slow connection, tapping "Under 100 000" or page 3 shows an unchanged grid of the old results for the duration of the request, with the filter pill already showing as applied. The user reads that as "the filter matched everything" or "my tap didn't register" and taps again. Fix: Return isFetching from useList, and have DataTable dim the results region or show a thin progress bar while it is true.

13. Price buckets overlap at their boundaries — Low · CONTENT-06 (proposed) ​

Where: apps/web/src/pages/MarketplacePage.vue:58-63; apps/web/src/i18n/messages/en.yml:157-161What: The buckets are inclusive on both ends: 0-100000 sends priceMax=100000 (lte), 100000-500000 sends priceMin=100000 (gte), 500000- sends priceMin=500000. So a machine priced at exactly 100 000 is returned by both "Under 100 000" and "100 000–500 000", and one at exactly 500 000 by both "100 000–500 000" and "Over 500 000". The labels claim strict boundaries ("Under", "Over") that the queries do not implement. Why it matters: Round numbers are exactly where machine asking prices cluster, so the overlap is not an edge case. A buyer who checks "Over 500 000" and finds a machine listed at 500 000 kr — or, per finding 4, at a rounded "500 000 kr" that is really 499 999,60 — cannot trust the filter. Fix: Make the buckets half-open (priceMax=99999 / priceMin=500001, or better, send exclusive bounds the API supports) or relabel to "100 000 and under" / "500 000 and over".

14. Bid form: submit is disabled until valid, with no constraints and no focus — Low · FORM-05, FORM-04, FORM-11 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:34, :88-97, :120-127What: Three small things in one form. :disabled="!canSubmit" leaves the submit button inert until an amount > 0 is entered, with no message explaining the block (FORM-05). No bounds are stated before typing — no minimum, no relation to the asking price, nothing about what happens after submission (FORM-04); canSubmit silently rejects 0 and negatives. And nothing is focused when the dialog opens, so a keyboard user must tab in from the dialog root to reach the amount field (FORM-11). Why it matters: Individually minor, but together they make the primary money action of the feature feel unresponsive: the user opens the dialog, sees a greyed-out "Send bud", and has to discover by experiment what unlocks it. Fix: Keep the submit enabled and validate on activation with an inline message ("Enter a bid amount above 0"); state any bounds under the field; add autofocus to the amount input on open.

Unverified ​

  • A11Y-01 (contrast) — the card's secondary line is text-[11px] text-text-2 and the separator dot text-text-3 on bg-paper (MarketplacePage.vue:219-229), and the price/hours row is text-xs/text-[11px]. These are the smallest text in the feature and the most likely to fail 4.5:1, but contrast needs computed colour values from a rendered page.
  • A11Y-06 (short viewport / mobile) — :card-columns="6" resolves to grid-cols-2 at 320px, so two cards share the width with a 96px image band, a truncated model, a year·location line and a price·hours row. Needs a rendered viewport. Also unverified: SelectionActionBar parking above BottomTabBar via --tab-bar-clearance while the bid bar is up, and PrimeVue Galleria at 320px.
  • PrimeVue internals (node_modules not installed in this checkout, per the standing caveat): whether Message on the detail page carries role="alert" (MarketplaceListingDetailPage.vue:123-136); whether Button :loading also sets disabled (relevant to finding 1's severity, though the Enter-key path bypasses the button either way); Checkbox's internal markup (finding 8 holds regardless — the aria-label is on the wrong element); Galleria's thumbnail button semantics and keyboard model; and Toast politeness for the bid success/error messages.
  • Whether the backend's year column (a string, compared with gte/lte in marketplace.repository.prisma.ts:45-49) ever holds non-4-digit values, which would make the year buckets sort lexicographically wrong. Not reproducible from code alone.

Confirmed non-defects (recorded so they are not re-investigated) ​

  • Filters do reach the API. Bucket → priceMin/priceMax/yearMin/yearMax refs (MarketplacePage.vue:97-107) → useMarketplace filter getter (marketplace.composable.ts:27-36) → normalizeFilters → fetchPage query params (marketplace.collection.ts:28-46) → MarketplaceController.list (apps/api/.../marketplace.controller.ts:37-55) → Prisma gte/lte (marketplace.repository.prisma.ts:45-63). The tt-time-tracker buildApiFilters defect does not reproduce.
  • Changing a filter resets to page 1 (create-domain-helpers.ts:105), and search is debounced at 300ms with the same reset (:91-97).
  • CONTENT-01 for the feature's own copy passes. Every marketplace.* key exists in both en.yml and nb.yml with a real Norwegian translation (lines 141-197 in both files); the two files are the same length. The failure in finding 10 is in shared-component defaults, not in this feature's message catalogue.
  • A11Y-04 <h1>: contrary to the project-level note, both page shells do render one. PageLayout.vue emits <h1> on desktop and MobileListHeader.vue:50 emits <h1> in the sticky mobile bar, with the other branch hidden via mobile:hidden — exactly one <h1> per page in both viewports. The project-level A11Y-04 item may need re-scoping to the pages that use PrimeVue Card titles as headings.
  • The live-tracking-style hidden-nav and localStorage observations from the queue do not touch this feature.

Baseline additions ​

  • CONTENT-05 (proposed, collides — see PROJECT-LEVEL.md): "Money is rendered by one shared formatter that preserves the currency's minor units. A display formatter must never round away precision the backing column can hold." Cited by finding 4; the members audit proposed the same rule from an identical maximumFractionDigits: 0 defect, so this is a two-project confirmation and should be promoted.
  • CONTENT-06 (proposed): "A bucketed filter's label must describe the same interval its query implements — inclusive/exclusive boundaries included. Adjacent buckets must not overlap." Cited by finding 13.
  • FORM-12 (proposed, collides): "A submit handler for a non-idempotent action guards on its own in-flight state, and reuses one idempotency key per user intent, not per call." Finding 1 is currently filed under FORM-06, which covers the focus-loss aspect of disabling a submit but not the duplicate-write aspect. If FORM-06 is left as-is, this needs its own ID. Note PROJECT-LEVEL.md already lists five different proposals under FORM-12.
  • NAV-06 (proposed, collides): "List state a user has deliberately set — search, filters, page, selection — survives navigating to a detail view and back." Cited by finding 2 under FORM-09 for now. PROJECT-LEVEL.md lists variant (e) "view mode/tab reflected in URL", which is the same family; reconcile into one rule about persisting deliberate view state.

Cross-project note ​

  • Currency precision (finding 4) is confirmed in members already and reproduces here in nine places. Worth a targeted grep for maximumFractionDigits in playout and tt-time-tracker; both have money surfaces (invoicing, billing).
  • Duplicate submission on a money action (finding 1) — the pattern to look for is "fresh idempotency key generated inside mutationFn" plus "handler that guards on validity but not on pending". generateRequestId() lives in @aadalen/ui and is used by every mutation in customer-portal, so at minimum every other command dialog in this repo needs the same check; tt-time-tracker invoicing is the likeliest external match.
  • List state lost on detail round-trip (finding 2) — no project in the programme has a <KeepAlive>; any repo whose list state lives in a page-scoped composable will behave the same. Likely in tt-time-tracker (TanStack Table lists) and playout (event list).
  • Hardcoded English in shared-component defaults (finding 10) — applies to playout, which also has an i18n layer and shared components (@playout/ui); worth checking whether its shared components carry English fallback labels. Not applicable to members/tt-time-tracker.
  • EmptyState with no live region (finding 11) — the members and tt-time-tracker audits both found error-state gaps; this is the same rule at the shared-component level.