Skip to content

[UX] customer-portal — Catalog ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 22 Patterns: forms/search-field

Summary ​

The Catalog is a well-built feature by this repo's own standards: it uses <PageLayout> + <DataTable card-only> rather than hand-rolled shells, SafeImage on the card thumbnail, useBackNavigation on the detail page, and full en/nb key parity for every catalog.* string. Two things spoil it. First, the grid renders capacity as the raw Dataverse optionset code (100000005) because it skips the resolveProductSpecValue decoder that the detail page — and every other product-spec surface in the app — uses. Second, nothing about the browse state (search text, category, page number) is in the URL and there is no <KeepAlive>, so opening any product and pressing Back drops the user to page 1, unfiltered.

Two checks from the assignment, answered explicitly:

  • Do the search/filter controls reach the API? Yes. Traced end to end: filterValues (CatalogProductsPage.vue:40) → category ref → useCatalogProductsfilters getter (catalog-products.composable.ts:18) → normalizeFilters (create-domain-helpers.ts:101) → fetchPage query.category (catalog-products.collection.ts:17-25) → controller @Query("category") → parseCategory → where.category (catalog-products.repository.prisma.ts:27-29). Search follows the same path to where.name = { contains, mode: "insensitive" } (:23-25). Debounce is 300 ms, filters apply immediately, both reset to page 1. This is not the tt-time-tracker inert-filter defect.
  • Price rounding? Not applicable. Neither CatalogProductResponse nor CatalogProductDetailResponse (catalog-products.dto.ts) carries a price, currency or money field, and neither page imports a formatter. The maximumFractionDigits: 0 class of defect cannot occur here.

Project-level, already filed: NAV-03 — no per-route title mechanism exists; neither catalog route sets one. See PROJECT-LEVEL.md. MSG-02 — getErrorMessage is not on this feature's path (both pages use throwOnError + TanStack error state), so it is not re-reported here.

Correction to PROJECT-LEVEL.md: the note "A11Y-04 — no <h1> on audited pages" does not hold for these two pages. PageLayout.vue:103 renders a real <h1>, and MobileListHeader.vue:50 renders the mobile one; the list page passes :mobile-header="false" and the detail page passes sticky-mobile-title, so exactly one <h1> is present at both breakpoints. Both are in-repo (packages/ui) and readable, so this is verified, not inferred. The separate claim about PrimeVue Card titles rendering as div remains Unverified (no node_modules).

Findings ​

1. The catalog grid shows raw Dataverse optionset codes where the detail page shows real values — High · proposed DATA-RAW-CODE ​

Where: apps/web/src/pages/CatalogProductsPage.vue:75 (table cell) and apps/web/src/pages/CatalogProductsPage.vue:154-159 (card) What: Both render row.capacity directly. capacity is not a human string: it is the new_kapasitet optionset, mapped through this.str("new_kapasitet") (services/worker/src/mapping/profiles/product.profile.ts:30) from NewProductNewKapasitet (packages/dataverse-dtos/src/dataverse-gen/product.ts:168), so the DB column holds the code as a string. catalog-products.dto.ts says so in its own comment: "Every spec field is returned raw; the frontend decodes optionset codes … via its existing product-specs resolver." The detail page does exactly that (CatalogProductDetailPage.vue:67 → resolveProductSpecValue, which maps capacity through CAPACITY_OPTIONS, product-specs.ts:203-217). The list page does not — it is the only product-spec surface in apps/web that renders a spec without the resolver (cf. AssetOverviewCard.vue:151, PartnerServiceOrderDetailPage.vue:88, PartnerBookingDetailMobile.vue:43). Why it matters: Capacity is the only technical spec on the card and one of three columns in the table. A user browsing forklifts sees 100000005 instead of 2500 kg on every tile, then sees 2500 kg after opening the same product. The grid's most decision-relevant field is unreadable, and the two screens contradict each other. Severity call: High, not Blocker — browse and drill-down still function and the correct value is one tap away, so the feature is not unusable; but the primary browse surface displays wrong-looking data to every user, every time. Fix: Route both call sites through the existing decoder: resolveProductSpecValue({ capacity: row.capacity ?? undefined }, "capacity"). It already falls back to String(raw), so it is safe even if a value arrives pre-formatted. Consider decoding in toCatalogProductResponse instead so no list consumer can forget, but the frontend resolver is the established convention.

2. Browse state is not in the URL and is destroyed by Back — Medium · proposed NAV-URL-STATE (collides with existing NAV-06(e)) ​

Where: apps/web/src/domains/catalog-products/catalog-products.composable.ts:14 and create-domain-helpers.ts:85-88; route records at apps/web/src/router/index.ts:163-178What: page, search and category are plain component-scoped refs. Nothing writes them to route.query and nothing reads them back. AppLayout.vue:445-463 renders <RouterView> with no <KeepAlive> (confirmed — the only wrapper is a :key="route.path" animation div), so the list component unmounts on navigation and remounts with page = 1, search = "", category = "". Why it matters: The catalog paginates at 24 per page (catalog-products.composable.ts:5). The core loop of the feature is browse → open a product → go back → open the next one. Today that loop resets to page 1 unfiltered on every return trip, so a user comparing three lifts on page 4 re-filters and re-paginates three times. A filtered view also cannot be bookmarked, shared with a colleague, or survive a refresh — and the mobile Capacitor wrapper makes reload-on-resume more likely. The forms/search-field pattern's own testing guidance calls this out: "Confirm that state survives refresh, navigation, or retry in the way users would expect."Severity call: Medium, not High — every lost state is re-creatable by hand, so it is friction rather than an unrecoverable dead end. Fix: Sync the three values to route.query (?q=&category=&page=) with router.replace while typing/filtering and router.push on page change, and seed the refs from route.query on mount. This belongs in createDomainHelpers (useList) so all ~15 list pages using it get it at once, not in the catalog page.

3. Product category labels are hardcoded English and bypass i18n — Medium · CONTENT-01 ​

Where: apps/web/src/domains/catalog-products/catalog-categories.ts:6-16What: CATALOG_CATEGORY_OPTIONS holds literal English strings — "Truck", "Lift", "Telehandler", "Construction machine", "Miscellaneous", "Equipment", "Parts", "Course", "Services" — and resolveCategoryLabel returns them verbatim. They surface in four places: the desktop filter Select (CatalogProductsPage.vue:34), the mobile filter pill summary (DataTable.vue:373), the card chip (CatalogProductsPage.vue:148) and the detail chip (CatalogProductDetailPage.vue:162). Everything around them is translated — catalog.products.list.filters.allCategories has a proper nb value (nb.yml:88, "Alle kategorier"), and en/nb parity for the whole catalog: block is otherwise complete (verified key by key). Why it matters: A Norwegian user opens the category filter and reads "Alle kategorier, Truck, Lift, Telehandler, Construction machine, Miscellaneous". "Construction machine" and "Miscellaneous" are not Norwegian words, and the same untranslated label is then stamped on every card. The catalog is the one screen where these labels are the primary navigation vocabulary. Fix: Move the labels into catalog.products.categories.<key> in both YAML files, keep the numeric codes in the TS constant, and have resolveCategoryLabel take t (or return a key the callers translate). The same file already models the right split — codes in code, text in locales.

4. Result-set changes are announced to nobody, and a refetch shows no waiting state — Medium · MSG-01, CONTENT-04 ​

Where: packages/ui/src/components/DataTable.vue:542-561 (the results region) and packages/domain-helpers/src/create-domain-helpers.ts:126 + apps/web/src/pages/CatalogProductsPage.vue:87What: Two related gaps. (a) No live region anywhere: the grid is a plain <div> with no role="status"/aria-live="polite", and the result count lives in a bare <p> (PageLayout.vue:106-111, fed by t('catalog.products.list.subtitle', { count: total })). Typing in the search box or picking a category silently swaps the DOM. (b) placeholderData: (prev) => prev keeps the previous page's rows on screen during a refetch, and the page binds :is-loading="isLoading" — TanStack's isLoading, which is false on every refetch after the first. isFetching is never exposed by UseListReturn and never rendered. So between keystroke and response (300 ms debounce + round trip) the user sees stale results, a stale count, and no indication anything is happening. Why it matters: A screen-reader user gets no feedback that their search did anything — WCAG 4.1.3. A sighted user on a slow connection reads a stale "48 produkter" and stale tiles as if they were the answer to the query they just typed. The forms/search-field anatomy names "Search status" as a required part of the component; it is absent here. Fix: Wrap the results region in role="status" aria-live="polite" aria-atomic="false" and announce the count ("48 produkter"). Surface isFetching from useList and have DataTable render a subtle busy state (progress bar or aria-busy="true" on the grid) whenever it is true and rows are already present. Shared-component fix.

5. Desktop search field and category filter have no label — Medium · FORM-01 ​

Where: packages/ui/src/components/DataTable.vue:415-422 (search) and :401-411 (filter Select) What: The desktop InputText has :placeholder="searchPlaceholder ?? 'Search'" and nothing else — no <label>, no aria-label, no aria-labelledby, and not type="search". The Select has only :placeholder="filter.placeholder ?? filter.label". DataTable accepts a searchLabel prop but forwards it only to the mobile MobileListHeader (:336); the desktop branch ignores it, and CatalogProductsPage.vue:90-109 does not pass it in any case. The mobile path is fine — CompactSearch.vue:53,71 sets :aria-label="searchLabel" — but the default is the English literal "Search" (CompactSearch.vue:16), so a Norwegian screen-reader user hears "Search". Why it matters: Placeholder-only labelling disappears the moment the user types and is inconsistently exposed by assistive tech; a screen-reader user tabbing the desktop catalog reaches an unnamed text box and an unnamed combobox. It is also the one baseline rule this shared table breaks on every list page in the app. Fix: In DataTable, bind :aria-label="searchLabel ?? searchPlaceholder" and type="search" on the desktop InputText, and :aria-label="filter.label" on each Select. Have CatalogProductsPage pass :search-label="t('catalog.products.list.searchPlaceholder')" so the mobile default stops falling back to English.

6. A search with no matches is a dead end — no clear control, no search-aware copy — Medium · MSG-03, MSG-04 ​

Where: apps/web/src/pages/CatalogProductsPage.vue:96 and packages/ui/src/components/DataTable.vue:532-541What: When a query matches nothing the grid is replaced by EmptyState with t("catalog.products.list.empty") — "No products found" / "Ingen produkter funnet". The copy is identical whether the catalog is empty, a category filter is active, or a typo was entered; it does not name the query or the active filter and does not say what to do. DataTable exposes an #empty-action slot (:538) and the page supplies nothing for it. On desktop there is no clear-search affordance at all — CompactSearch's ✕ button (CompactSearch.vue:80-90) only exists on mobile — so the user's only escape is to select the text and delete it manually. The active category filter is not visible from the empty state either. Why it matters: A mistyped model number leaves the user staring at "Ingen produkter funnet" with no hint that a category filter they set two minutes ago is still narrowing the results. The pattern's anatomy lists a "Submit or clear control" as a required part of a search field. Fix: Add a clear (✕) button inside the desktop IconField, shown when the query is non-empty. Make the empty copy search-aware — catalog.products.list.emptyForQuery = "Ingen produkter matcher «{query}»" — and fill #empty-action with a "Nullstill søk og filtre" button that resets search and category.

7. Pagination bar is untranslated and its arrows have no accessible name — Medium · CONTENT-01, A11Y-05 ​

Where: packages/ui/src/components/DataTable.vue:664-669 (copy) and :671-678, :688-695 (buttons) What: The pagination strip is hardcoded English — Page {{ page }} of {{ totalPages }} and ({{ total }} total) — with no t() anywhere in the component. The previous/next controls are PrimeVue Buttons with icon="pi pi-angle-left" / pi pi-angle-right, no label, and no aria-label. The numbered buttons are labelled, so only the two arrows are unnamed. Why it matters: This bar renders on the catalog for any result set over 24 items, i.e. the normal case, so a Norwegian user reads "Page 2 of 7 (161 total)" under an otherwise fully Norwegian page. A screen-reader user hears two unnamed buttons flanking the page numbers. Severity call: Medium rather than High — the numbered buttons give keyboard and screen-reader users a working route to every page, so the unnamed arrows are confusing rather than blocking. Fix: Add aria-label to both arrow buttons and wrap the strip in a <nav aria-label="…">. Take the strip's strings as props (pageLabel, totalLabel) or inject t into @aadalen/ui, and pass the localised values from each page. Shared-component fix affecting every paginated list in the app.

8. A failed load on the detail page is reported as "Product not found" — Medium · MSG-03 ​

Where: apps/web/src/pages/CatalogProductDetailPage.vue:128-133What: The branch condition is v-else-if="isError || !product" and the body is t("catalog.products.detail.notFound") — "Fant ikke produktet". Every failure mode collapses into that one sentence: a genuine 404 from CatalogProductsService.getByExternalId, a 403 from the PolicyGuard, a 500, and an offline fetch all read as "this product does not exist". There is no retry control, no status code, and no link into the catalog other than the back affordance. Contrast the list page, which does surface the status code (CatalogProductsPage.vue:97, t('...error', { status })) and gets a 403-specific message from DataTable.vue:271-274. Why it matters: A user on a flaky mobile connection is told the machine they were sent a link to has been removed from the catalog, and gives up instead of retrying. useCatalogProductDetail already computes and returns status (catalog-products.collection.ts:61) — the page imports the composable but never destructures it. Fix: Split the branch. On statusCode === 404 keep the not-found copy and add a "Tilbake til katalogen" link. Otherwise show the load-error copy with the status and a Retry button wired to the query's refetch. Reuse the EmptyState component so it matches the list page's treatment.

9. Product images are inlined as base64 data URIs, so the grid downloads them all — Low · proposed PERF-INLINE-IMAGE ​

Where: services/worker/src/mapping/profiles/product.profile.ts:59 — pictureUrl: s.entityimage ? \data:image/jpeg;base64,${s.entityimage}` : null— consumed atCatalogProductsPage.vue:125-130andCatalogProductDetailPage.vue:144-149**What:**pictureUrlis not a URL; it is the whole JPEG, base64-encoded, stored in Postgres and returned inside the list JSON. Twenty-four of them ship in a singleGET /catalog/productsresponse. Because they are data URIs they cannot be lazily loaded, cached separately, resized, or served by a CDN, and base64 adds ~33%. **Why it matters:** The repo's own guide calls this app "used primarily on mobile phones (PWA + Capacitor wrapper)". The first paint of the catalog blocks on a JSON payload carrying two dozen inline images, and paging forward re-downloads a fresh set every time.SafeImage's degradation story is also moot here — a data URI cannot expire, so the component's whole purpose is unused on this surface. **Fix:** Out of scope for a UX fix, but worth recording: store the image once and return a URL (or a thumbnail endpoint) so SafeImagecan lazy-load and the browser can cache. If that is a larger change, at minimum omitpictureUrl` from the list DTO and fetch thumbnails separately.

10. Copy and markup nits — Low · CONTENT-01, repo convention, proposed A11Y-07(c) ​

Where: three separate spots. What:

  • apps/web/src/i18n/messages/en.yml:85 / nb.yml:85 — subtitle: "{count} products" / "{count} produkter" has no plural form, so a single-result search reads "1 products" / "1 produkter". vue-i18n pluralisation (no products | one product | {count} products) is available and unused.
  • apps/web/src/pages/CatalogProductsPage.vue:56 — the name column cell renders a bare h("img", …) for the thumbnail while the card two hundred lines below uses <SafeImage>. apps/web/CLAUDE.md requires <SafeImage> for data-bound remote images. This is currently unreachable — the page passes card-only, so DataTable never renders the table branch (:565) — but it is a live trap for whoever removes card-only, and it makes the two thumbnail implementations disagree.
  • apps/web/src/pages/CatalogProductDetailPage.vue:146 — the hero product photo has alt="", marking the page's primary illustrative content decorative. On the list card (:127) alt="" is defensible because the wrapping button carries openDetailsA11y with the product name; on the detail page nothing plays that role. PROJECT-LEVEL.md already tracks this as proposed A11Y-07(c). Fix: Add plural forms to both YAML files; swap the column cell to SafeImage (or delete the unused column cell renderer); set :alt="product.name ?? t('catalog.products.detail.title')" on the detail image.

Unverified ​

  • A11Y-01 (contrast). Not assessable from code. Highest-risk spots if someone runs a contrast tool: the card's text-[11px] text-text-2 capacity line (CatalogProductsPage.vue:155) and the text-[11px] category chip (:146) — both are muted-ramp text below 12px, where the 4.5:1 threshold applies with no large-text exemption.
  • A11Y-06 (short viewport / responsive). The list passes :card-columns="6", which resolves to grid-cols-2 md:grid-cols-4 xl:grid-cols-6 (DataTable.vue:165) — two columns at 320px, each holding a truncated 11px name, a chip and a capacity line inside a 96px-tall thumbnail band. Plausible but must be checked at 320/360/390px as apps/web/CLAUDE.md requires. Also unverified: whether the sticky MobileListHeader plus the filter-chip row leave usable height at ~700px.
  • PrimeVue internals. node_modules is not installed, so I could not confirm what element Card's #title slot renders (it carries the "Specifications" and "Documents" section headings on the detail page, which would otherwise be h2s), nor whether Button exposes an accessible name from icon alone. Both bear on findings 7 and the A11Y-04 note.
  • A11Y-02 (24×24 targets). The mobile filter pills are h-[34px] (DataTable.vue:366) and the card buttons fill their tile, so these pass by inspection; the desktop pagination arrows are PrimeVue size="small" text buttons whose rendered box I cannot measure without the library.

Baseline additions ​

Proposed, with one-line definitions. IDs are descriptive because the orchestrator is renumbering — two of these overlap with collisions already logged in PROJECT-LEVEL.md and should be merged there rather than added twice.

  • DATA-RAW-CODE — A value stored as an enum/optionset/status code is decoded to its human label at every surface that displays it, not only the detail view; a shared decoder exists and no view may bypass it. (Finding 1. Same family as the CONTENT-05(a) currency-precision collision — both are "a shared formatter exists and one call site skips it". Recommend folding into one rule about shared value formatters.)
  • NAV-URL-STATE — List state a user has deliberately set (search text, active filters, page number, sort) is reflected in the URL, so it survives refresh, Back, and sharing. (Finding 2. Collides with the existing NAV-06(e) proposal "view mode/tab reflected in URL" — same rule, wider scope. Merge.)
  • PERF-INLINE-IMAGE — Data-bound images are delivered as cacheable URLs, never inlined as base64 in a list payload. (Finding 9. Low confidence this deserves a baseline rule versus a per-repo note — it is checkable from code and library-independent, but it is the only performance rule proposed so far and the baseline is deliberately behavioural.)

No new rule is needed for findings 3–8; they are covered by CONTENT-01, MSG-01, MSG-03, MSG-04, FORM-01, A11Y-05 and CONTENT-04 as written.

Cross-project note ​

  • Findings 4, 5, 6 and 7 are not catalog defects at all — they live in packages/ui/DataTable.vue and packages/domain-helpers/create-domain-helpers.ts, which back roughly fifteen list features in this queue (#9 Marketplace, #14 Customer assets, #18 Work orders, #19 Partner orders, #20 Locations, #21 Contacts, #31 Sync monitoring, #34 User administration, …). Fixing them once in packages/ui fixes every one. Recommend promoting them to PROJECT-LEVEL.md so the remaining customer-portal audits cite them in one line instead of re-deriving them — this draft's findings 4–7 would then collapse to a single reference.
  • Finding 2 (list state not in the URL) is very likely present in all four projects: tt-time-tracker uses TanStack Table with the same refs-in-a-composable shape, playout's event list paginates, and members' widgets are mount-attribute driven. Worth one coordinated check.
  • Finding 1 (raw code leaking to the UI) rhymes exactly with the members currency-precision defect (useFormat.ts:2): a correct shared formatter exists, one surface skips or misconfigures it, and the user sees a wrong value with no indication anything is wrong. Suggest a single cross-project sweep for "displays a value that a sibling view formats".
  • Finding 3 (enum labels hardcoded outside i18n) applies to playout and customer-portal, the two projects that have an i18n layer. customer-portal has at least one other instance already visible: product-specs.ts:143-190 holds Norwegian-only optionset labels ("Bomlift elektrisk", "Ikke definert") that the English locale will render untranslated on the detail page's spec list.