feat(apps): read products by id instead of loading the whole catalogue

Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted
routes; these are the five callers that were fetching all ~16k products
to find a handful of rows:

  product screen    whole catalogue, then find(id)  -> GET /products/:id
  cart re-sync      whole catalogue for 2-3 lines   -> ?ids=
  favourites        whole catalogue, filtered here  -> ?ids=
  home categories   categories derived from it      -> /products/facets
  order row labels  joined on variantId             -> included with the order

Three of these were not merely slow, they were going to be wrong.
Favourites filtered the loaded catalogue, so once the list pages a
favourite outside the loaded slice silently disappears. Home categories
were derived the same way, so a category whose products all sit further
down would vanish from the home screen. Order rows looked their title up
in the catalogue, which ALREADY failed for a product that had since left
the storefront — exactly the old orders history exists for — and
rendered a blank row.

Order labels went to the server rather than to another id lookup: the
item carries productVariantId, not a product id, so ?ids= could not
answer it. One nested include in getOrders returns the variant, its
product and the product's first image. Deliberately NOT filtered by
storefront visibility, so a de-listed product still shows what was
bought; asserted in the new spec. OrderItemCard is synchronous now — no
fetch, no onMounted, the row renders complete on its first frame.

variantLookup is deleted from catalogStore rather than left behind: it
existed only to label order rows, and keeping it would have kept a
reason to load the full catalogue.

getProductsByIds chunks at 100 because the route caps ids there and
drops the rest. A partial answer reads to callers as "these products are
no longer on the storefront" — the cart would silently drop valid lines.
Favourites already run to 874 rows in the imported data (FAV-01).

A failed cart re-sync now leaves the cart alone: it is the person's own
data in localStorage, and dropping lines because the network blinked is
worse than a slightly stale price. The product screen tells a real 404
from a failed request, so only the former renders the not-found page.

One bug introduced and caught while doing this: moving the home
categories to /facets meant memory.init() no longer loaded the catalogue
as a side effect, and the home rails read catalog.products — the home
screen would have rendered empty rails. HomeView now loads it
explicitly. Found by grepping every caller of configStore.init, which is
also how I know HomeView is the only one.

Still on the unpaged list by design: the home rails and the
search/newest lists. Moving those onto paged reads is step 4, kept
separate because it is the risky part — debounce, loading state, and
telling "no results" from "still loading".

api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a
spec now (3 cases) because its include is a contract both apps depend on
and the method had no test at all. Nothing click-tested — the cart
re-sync and the favourites screen are the two worth a manual pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-12 15:19:50 +03:30
co-authored by Claude Opus 5
parent cbb93dec5d
commit 9fc0886a57
22 changed files with 743 additions and 191 deletions
+25 -10
View File
@@ -1,10 +1,8 @@
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
// ids and flat (non-paginated) product list. Persisted the same way (a single
// localStorage key), just via Pinia's own state instead of @vueuse's
// useStorage wrapping a hand-rolled class.
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
import { defineStore } from 'pinia'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from './catalogStore'
import { api, type ProductSummary } from '../api/client'
export interface CartItem {
pid: string
@@ -34,16 +32,33 @@ export const useCartStore = defineStore('cart', {
items: loadFromStorage() as CartItem[],
}),
actions: {
// Re-syncs cart entries against fresh product data (e.g. price/weight
// changes since the item was added), dropping entries whose
// product/variant no longer exists — same intent as legacy's init().
/**
* Re-syncs cart entries against fresh product data (e.g. price/weight
* changes since the item was added), dropping entries whose
* product/variant no longer exists — same intent as legacy's init().
*
* Asks for exactly the products in the cart. It used to load the entire
* catalogue to look up two or three of them, which with ~16k products meant
* downloading all of them every time the app opened.
*
* A failed request leaves the cart alone: it is the person's own data, held
* in localStorage, and dropping items because the network blinked would be
* worse than showing a slightly stale price.
*/
async init(force = false) {
void force // kept for callers; every call now goes to the server anyway
if (this.items.length === 0) return
try {
const products = await useCatalogStore().load(force)
const ids = [...new Set(this.items.map((i) => i.pid))]
const products = await api.getProductsByIds(ids)
const byId = new Map(products.map((p) => [p.id, p]))
const refreshed: CartItem[] = []
for (const item of this.items) {
const product = products.find((p) => p.id === item.pid)
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
// A product the server no longer lists (deactivated, or its photo was
// removed) comes back missing, which is the same signal the catalogue
// lookup gave before: drop the line.
if (product && variant) {
refreshed.push({ ...item, product })
}
+6 -20
View File
@@ -9,12 +9,6 @@ import { computed, ref, shallowRef } from 'vue'
import { api, type ProductSummary } from '../api/client'
import { KEYS, useQueryCache } from './queryCache'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
export const useCatalogStore = defineStore('catalog', () => {
const cache = useQueryCache()
// shallowRef: the list is replaced wholesale, never mutated in place.
@@ -31,19 +25,11 @@ export const useCatalogStore = defineStore('catalog', () => {
return [...seen.values()]
})
const variantLookup = computed(() => {
const map = new Map<string, VariantLookup>()
for (const product of products.value) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
return map
})
// `variantLookup` lived here: a variantId -> {productTitle, variantTitle,
// imageUrl} map built over the whole catalogue, used only to label order
// rows. The server now includes those fields with the order itself
// (customers.service.ts getOrders), so the map is gone rather than left as a
// reason to keep loading the full catalogue.
async function load(force = false): Promise<ProductSummary[]> {
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
@@ -61,5 +47,5 @@ export const useCatalogStore = defineStore('catalog', () => {
cache.invalidate(KEYS.catalog)
}
return { products, loaded, categories, variantLookup, load, byId, invalidate }
return { products, loaded, categories, load, byId, invalidate }
})
+11 -8
View File
@@ -26,7 +26,7 @@
// add a bounce: opening a shared product link would land on home instead of the
// product. Replicating it would port the breakage, not the behaviour (D4).
import { defineStore } from 'pinia'
import { useCatalogStore } from './catalogStore'
import { api } from '../api/client'
import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.png'
@@ -76,15 +76,18 @@ export const useConfigStore = defineStore('config', {
this.cats = cats
},
// Legacy's store.init() fanned out to /config, /promotion, /ignore, /price.
// Here the only live source is the product catalogue, from which the real
// category list is derived.
// The catalogue comes from catalogStore, so calling this next to another
// catalogue read costs no second request.
// Here the live source is the catalogue's own category list.
//
// Reads GET /catalog/products/facets, a small aggregate. It used to load the
// whole catalogue and derive the categories from it in the browser — up to
// ~16k products fetched to populate one row of chips, and wrong beyond the
// first page once the list is paged, because a category whose products all
// sit further down would vanish from the home screen.
async init(force = false) {
void force // the facets are a small read; there is no cached copy to bypass
try {
const catalog = useCatalogStore()
await catalog.load(force)
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
const { categories } = await api.getCatalogFacets()
this.cats = categories.map((c) => ({ ...c, icon: { image: brandArt } }))
this.loaded = true
} catch (e) {
console.log(e)