Skip to content

[UX] customer-portal — Bids ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 18 (12 read in full) Patterns: forms/currency-input

Summary ​

Bids is the app's money path — a bid is a financial commitment to buy a machine — and it is the least defended surface audited so far. The same rounding defect found in members exists here: a shared Intl.NumberFormat with maximumFractionDigits: 0 on both bid screens, so an amount of 12 500,50 is rendered as "kr 12 501" while the create dialog happily accepts the øre; worse, the edit field is capped at 0 fraction digits, so simply re-saving a bid silently rewrites the stored amount to a different number. Beyond currency, a bid is submitted on one click with no confirmation, no statement of what is being committed, and no way to withdraw it (the API exposes no delete endpoint) — and the success toast fires on a queued Dataverse command whose outcome the UI never checks, so "Saved" can be followed immediately by the old amount reappearing in the form.

Known project-level defects (MSG-02 getErrorMessage preferring raw backend strings — used at BidDetailPage.vue:73 and BidDialog.vue:58; NAV-03 single static document title) are not re-filed here. One correction to PROJECT-LEVEL.md: the A11Y-04 "no <h1>" claim does not hold for these pages — @aadalen/ui's PageLayout renders a real <h1> (PageLayout.vue:103) and its mobile sticky variant does too (MobileListHeader.vue:50), so both bid routes have exactly one <h1> on both viewports.

Findings ​

1. Bid amounts are displayed rounded to whole kroner while decimals are accepted and stored — High · CONTENT-05 (proposed — currency precision, variant (a) in PROJECT-LEVEL.md) ​

Where: apps/web/src/pages/MyBidsPage.vue:15-19, apps/web/src/pages/BidDetailPage.vue:27What: Both screens format money with new Intl.NumberFormat("nb-NO", { style: "currency", currency: "NOK", maximumFractionDigits: 0 }). The entry point does not agree: BidDialog.vue:88-97 uses PrimeVue InputNumber with mode="currency" currency="NOK" locale="nb-NO" and no maxFractionDigits override, so it accepts two decimals (the Intl default for NOK), the API takes bidAmount as a plain @IsNumber() (apps/api/src/modules/bids/bids.dto.ts:58-60) and Postgres stores it as Float? (packages/database/prisma/app/trade_offer.prisma:6). A bid of 12 500,50 is therefore committed at 12 500,50 and shown to the bidder as "kr 12 501" in both the list and the detail header. Why it matters: This is the exact class of defect the members audit found (12.5 rendered as "13 €"). On a bid it means the number the customer sees in "My bids" is not the number Aadalen received, and the difference is a real-money discrepancy the user has no way to detect — the rounded value is the only value the UI ever shows. Fix: Replace both ad-hoc formatters with one shared money formatter that uses the currency's own precision (drop maximumFractionDigits: 0, or set minimumFractionDigits: 2, maximumFractionDigits: 2 for NOK), and export it from @aadalen/ui / @aadalen/domain-helpers so no page re-declares it. If the business rule really is "whole kroner only", enforce that at the input and in the API validator, not in the display layer.

2. Re-saving a bid silently rewrites the stored amount — High · MSG-05 ​

Where: apps/web/src/pages/BidDetailPage.vue:217-223 (:max-fraction-digits="0"), with apps/web/src/pages/BidDetailPage.vue:52-62What: The edit field is InputNumber … mode="decimal" :max-fraction-digits="0". The form is seeded from the loaded bid (form.bidAmount = value.bidAmount), so a bid created at 12 500,50 loads into a control that cannot represent it; PrimeVue rounds it to 12 501 on mount. dirty then compares 12501 !== 12500.5 and reports the untouched form as dirty, enabling Save changes. One click on a button the user believes is a no-op mutates the committed amount. Why it matters: An irreversible change to a financial commitment happens without the user asking for it and without being told anything changed — MSG-05's core case. It also means the "no changes" guard is unreliable on exactly the records that matter. Fix: Make the edit control's precision match the create control's and the stored value's (both 2, or both 0 enforced end to end). Until then, compare dirty against a normalised value so an unedited form is never dirty, and show the previous amount next to the field so any change is visible before saving.

3. A bid is submitted with no confirmation, no statement of commitment, and cannot be withdrawn — High · MSG-05 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:44-60 and :120-127; apps/api/src/modules/bids/bids.controller.ts:23-85What: Submit bid fires createMutation.mutateAsync directly — no confirmation step, and no copy anywhere in the dialog stating what a bid is (binding? for how long? what happens if accepted?). marketplace.bid.* in apps/web/src/i18n/messages/en.yml:187-197 contains no such sentence. Afterwards there is no exit: the controller exposes only GET /bids, GET /bids/:id, POST /bids and POST /bids/:id/update — there is no withdraw/cancel endpoint — and BidDetailPage.vue:46 allows editing amount and comment while open but offers no way to retract. A mis-keyed extra zero can be revised down but never taken back. Fix: (a) Add a confirm step that restates the machine(s) and the formatted amount before the request goes out — a second click, not a second dialog field. (b) State the commitment in the dialog ("A submitted bid is sent to Aadalen for review; you can change it while it is pending"). (c) Add a withdraw action for open bids (API + UI) and say so in the confirm copy; if withdrawal is genuinely impossible by business rule, say that instead, before submission.

4. Success is reported for a command that has only been queued, and the UI then contradicts itself — High · MSG-06 (proposed — result-returning calls must be inspected; see collision note in PROJECT-LEVEL.md) ​

Where: apps/web/src/components/marketplace/BidDialog.vue:48-56, apps/web/src/pages/BidDetailPage.vue:23-25 and :53-58, apps/web/src/domains/bids/bids.mutations.ts:24-38 and :57-71What: bidsCreate / bidsUpdate return a CommandStatusResponse ({ commandId, status } — trade-in-machines.dto.ts:126-132); the API only enqueues a durable Dataverse command (bids.service.ts:57-64, :66-88). Both mutations discard status and toast unconditional success ("Your bid has been submitted." / "Saved"), then invalidateQueries(["bids"]) refetches the read model, which is only updated later by the sync pipeline. Two visible consequences: after placing a bid, My bids can still be empty — indistinguishable from a failure; and on the detail page the watch(bid, …) re-seeds the form from the stale refetch, so the amount the user just saw confirmed as "Saved" reverts in front of them to the old value. Why it matters: On a money path the user cannot tell "accepted and pending sync" from "not submitted". If the worker later fails, nothing tells them — the bid simply never appears. Fix: Inspect the returned status and phrase the confirmation for what actually happened ("Bid received — it will appear in My bids shortly"). Render a pending row optimistically (or poll the command) so the list is never silently empty after a submit, and do not re-seed the edit form from a refetch that has not yet observed the write.

5. The bid amount and comment fields have no programmatically associated labels — Medium · FORM-01 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:87-97 and :101-108What: <label class="…">{{ t("marketplace.bid.amountLabel") }}</label> has no for, the InputNumber has no input-id and no aria-label, and the Textarea has no id. (BidDetailPage.vue:212-235 does this correctly with for / input-id — the dialog is the outlier.) The forms/currency-input pattern additionally requires the currency to be in the field's accessible name. Why it matters: A screen-reader user reaches an unnamed numeric field on the one screen where the number is a financial commitment, and no user can tap the label to focus the field. Fix: input-id="bid-amount" + for="bid-amount", id="bid-comment" + for="bid-comment", and make the accessible name include the currency ("Your bid in Norwegian kroner").

6. The save button is explicitly disabled while the save is in flight — Medium · FORM-06 ​

Where: apps/web/src/pages/BidDetailPage.vue:239What: :disabled="!dirty || form.bidAmount === null || updateMutation.isPending.value" — the isPending term disables the button the user has just activated, in addition to :loading. Focus drops to <body>, so a keyboard or screen-reader user loses their place mid-transaction and hears nothing about the outcome. Fix: Drop updateMutation.isPending.value from :disabled, guard re-entry inside save() (it already returns early on missing data), and expose progress via aria-busy on the button.

7. bidAmount has no server-side bounds — Medium · SEC-06 (proposed — value constraints enforced server-side; ID collides, see PROJECT-LEVEL.md) ​

Where: apps/api/src/modules/bids/bids.dto.ts:57-60 and :97-104What: Both CreateBidCommandBody.bidAmount and UpdateBidCommandBody.bidAmount carry only @IsNumber() — no @Min, no @Max, no precision constraint. The only guards are client-side (canSubmit requires > 0 in the dialog; :min="0" on the detail input, which still permits an update to exactly 0). The forms/currency-input security checklist requires that minimum/maximum are enforced server-side and negative amounts rejected where not permitted. Why it matters: A revision to 0 is reachable through the normal UI, and anything else is reachable by a direct call; the resulting record goes on to Dataverse as a real offer. Fix: Add @Min(1) (and a sensible @Max) to both DTOs, and mirror the minimum in the client's validation message rather than in a disabled button.

8. My bids shows at most 50 rows but reports a total it cannot display — Medium · DATA-01 (proposed) ​

Where: apps/web/src/pages/MyBidsPage.vue:13 and :69What: useBidList({ defaultPageSize: 50 }) destructures only rows, total, isLoading, isError, status. createDomainHelpers's useList returns page, totalPages and setPage (packages/domain-helpers/src/create-domain-helpers.ts:130-150) and the page uses none of them, while the subtitle renders t('bids.list.subtitle', { count: total }) — the server-side total. Why it matters: A bidder with 60 bids sees "60 bids" above a list of 50 with no control to reach the other 10. Older bids become permanently invisible, and the page gives no hint that anything is missing. Fix: Wire the returned page / totalPages / setPage to a paginator (or infinite scroll), or, if the list is meant to be short, cap the subtitle to what is actually rendered and say so.

9. Load failures say nothing actionable, and any detail-page failure is reported as "Bid not found" — Medium · MSG-03 ​

Where: apps/web/src/pages/MyBidsPage.vue:87-93, apps/web/src/pages/BidDetailPage.vue:135-140What: The list error renders t("bids.list.error", { status }) where status is TanStack Query's own status string, so the user reads "Could not load your bids (status: error)" — an internal token, no next step, no retry control. The detail page collapses isError || !bid into a single "Bid not found." message, so a 500, an offline device and a genuinely deleted bid all produce the same dead end, with no retry and no link back to My bids in the body (only the header back affordance). Why it matters: On a money screen, "not found" for a transient network failure tells the customer their bid has vanished. Fix: Give both states a Try again button bound to refetch, drop the raw status interpolation (see the project-level MSG-02 finding), and distinguish 404 from other failures on the detail page.

10. A multi-machine bid asks for one amount without saying what it covers — Medium · CONTENT-06 (proposed) ​

Where: apps/web/src/components/marketplace/BidDialog.vue:79-97, with apps/web/src/pages/MarketplacePage.vue:128-133What: From the marketplace grid, selecting N listings opens the same dialog with machineLabel = "Bid on {count} machines" and a single field labelled "Your bid (NOK)" / "Ditt bud (NOK)". Nothing states whether the amount is per machine or for the whole lot, and the selected machines are not listed in the dialog — only their count. Why it matters: The two readings differ by a factor of N on a binding financial offer, and the user cannot check what they selected without closing the dialog and losing the amount they typed. Fix: Label the field for the situation ("Total bid for all 4 machines") and list the selected machines (or a scrollable summary) inside the dialog.

11. Submit is disabled with no explanation of what is missing — Low · FORM-05 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:121, apps/web/src/pages/BidDetailPage.vue:239What: :disabled="!canSubmit" (amount null or ≤ 0) and :disabled="!dirty || form.bidAmount === null || …" both render an inert primary button with no message. Nothing says an amount is required or that a minimum applies. Fix: Keep the button enabled and surface the reason on activation ("Enter a bid amount", "Nothing has changed"), per FORM-05.

12. The save button reads "Saved" while the save is still running — Low · CONTENT-04 ​

Where: apps/web/src/pages/BidDetailPage.vue:240What: :label="updateMutation.isPending.value ? t('bids.detail.saved') : t('bids.detail.save')" — the in-flight label is "Saved" / "Lagret" (past tense), reusing the toast's success key. The waiting state announces completion. Fix: Add a bids.detail.saving key ("Saving…" / "Lagrer…") and use it for the pending label; keep saved for the toast.

13. The bid dialog opens without focusing the amount field — Low · FORM-11 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:36-42, apps/web/src/components/mobile/ResponsiveDialog.vue:41-80What: The watch(visible, …) resets the fields but sets no focus, and neither branch of ResponsiveDialog (BottomSheet on mobile, PrimeVue Dialog on desktop) is given an autofocus target. The dialog is a single-purpose form whose first field is the amount. Fix: Focus the amount input when the dialog opens (autofocus on the InputNumber, or a nextTick focus in the watcher).

14. The dialog gives no price context or bounds before the user types — Low · FORM-04 ​

Where: apps/web/src/components/marketplace/BidDialog.vue:86-98What: No minimum, no maximum and no reference price are shown. On the detail route the asking price is on the page (MarketplaceListingDetailPage.vue:71, labels.price) but the modal covers it; on the multi-select route the user never saw a price at all. marketplace.bid.amountPlaceholder is just "Enter amount". Fix: Show the asking price (or the per-machine prices for a multi-bid) as helper text inside the dialog, linked via aria-describedby, together with any minimum the API will enforce (see finding 7).

Not-applicable / passing ​

  • CONTENT-01 — passes. Every key used by these screens exists in both en.yml and nb.yml (bids.* at en.yml:199-226 / nb.yml:199-226, marketplace.bid.* at :187-197) with genuine Norwegian translations, no English fallbacks.
  • A11Y-04 — passes here, contradicting the project-level note: PageLayout renders <h1> (packages/ui/src/components/PageLayout.vue:103), the mobile sticky bar renders <h1> (MobileListHeader.vue:50), and the detail page's card titles are <h2> (BidDetailPage.vue:171, :206) with no level skips. Note the two are mutually exclusive by viewport (v-if/mobile:hidden), so there is exactly one.
  • A11Y-03 — passes: list rows are real <button type="button"> (MyBidsPage.vue:108-114) and machine rows likewise (BidDetailPage.vue:175-181); no click-only divs in this feature.
  • A11Y-05 — the chevron and truck icons are decorative inside buttons that already carry a text accessible name; SafeImage uses alt="" deliberately.
  • NAV-02 — no timers, intervals or subscriptions in this feature.
  • MSG-02, NAV-03 — real failures, but architectural; already recorded in PROJECT-LEVEL.md for customer-portal and deliberately not re-filed.

Unverified ​

  • A11Y-01 (contrast) — the status pills use the ÅDALEN token pairs (bg-status-warn-bg text-status-warn-fg, etc.) and several labels are text-text-3 / text-[11px]; needs computed colour values against packages/ui/src/styles/theme.css in a rendered page.
  • A11Y-06 (short viewport / mobile keyboard) — the bid dialog becomes a BottomSheet at ≤760px with max-height: 86% and the amount field near the top; whether the footer buttons stay reachable with the numeric keypad open cannot be settled by reading code.
  • A11Y-02 (target size) — the status pill is non-interactive, but the dialog's text-style Cancel button and the -ml-0.5 back link need measuring.
  • MSG-01 — whether PrimeVue's Message (MyBidsPage.vue:87) and Toast carry role="alert" / role="status" cannot be confirmed: node_modules is not installed in this checkout (standing caveat in PROJECT-LEVEL.md). The hand-rolled empty state (MyBidsPage.vue:96-101) and the detail page's not-found block have no live region of their own.
  • FORM-06 in the dialog — BidDialog.vue:124 uses only :loading; whether PrimeVue's Button also sets disabled from loading needs the installed package. (Finding 6 does not depend on this — the detail page disables explicitly.)
  • PrimeVue InputNumber default precision — finding 1 assumes the Intl default of 2 fraction digits for NOK in mode="currency" with no maxFractionDigits. Worth confirming in a rendered dialog; the mismatch between the create control (unset) and the edit control (0) is a defect either way.

Baseline additions ​

  • DATA-01 (proposed) — A truncated list must expose the control that reaches the rest of the rows, and must not report a total larger than it can display. (finding 8; likely recurs on every useList page in this repo and on the admin tables in tt-time-tracker.)
  • CONTENT-06 (proposed) — A single input that applies to multiple selected items must state its scope in the label (per item vs total), and the dialog must show what was selected, not only how many. (finding 10.)
  • CONTENT-05 (a) — reuse, do not re-mint. The currency-precision rule already proposed by the members audit covers finding 1 exactly. Recommend it be worded as: money is formatted by one shared formatter using the currency's own precision; display precision never differs from the precision the input accepts or the API stores. The "input" clause is the part this audit adds — members only showed the display half.
  • SEC-06 (server-side value constraints) — finding 7 is a fifth meaning for the already-colliding SEC-06; it belongs with variant (b) "capacity limits enforced server-side" as one rule about any client-side numeric bound needing a server-side twin.
  • MSG-05 extension — the current wording covers "destructive or irreversible actions confirm first". Findings 2 and 3 suggest adding: …and an action that creates a financial commitment states the commitment and whether it can be undone, before it is sent.

Cross-project note ​

  • Currency precision (finding 1) is confirmed in members already (12.5 → "13 €") and now in customer-portal. Both playout (invoicing/pricing surfaces) and tt-time-tracker (@tt/accounting, billed hours × rate) are strong candidates — every project audited so far has hand-rolled Intl.NumberFormat per page rather than sharing one formatter, so grep for maximumFractionDigits and for duplicated NumberFormat declarations in both.
  • Queued-command success reporting (finding 4) is specific to this repo's Dataverse command+worker architecture, so it should recur on trade-in machines, book service, and every other POST that returns CommandStatusResponse in customer-portal — worth one architectural sweep rather than per-feature findings if a second audit confirms it.
  • Truncated lists (finding 8) — createDomainHelpers is shared across this repo's list pages, so any page destructuring only rows/total has the same defect; tt-time-tracker's TanStack tables are the closest analogue elsewhere.
  • Unlabelled inputs inside dialogs (finding 5) — the pattern of labelling correctly on full pages but not inside modals is worth checking in all four projects.