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
@@ -1,16 +1,37 @@
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins
// against the shared catalogue (stores/catalogStore.ts) client-side rather
// than touching apps/api to add an include.
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
// The product and variant titles shown on one line of an order.
//
// These now arrive WITH the order: GET customer/me/orders includes each item's
// variant, its product and the product's first image.
//
// Before that, this joined against the shared catalogue in the browser. Two
// problems, both fixed by the include: it fetched the whole catalogue (~16k
// products in the real data) to label a few rows, and a product that had since
// left the storefront was absent from the catalogue, so the row rendered blank —
// precisely for old orders, which is where order history matters most.
import type { OrderItemDetail } from '../api/client'
export type { VariantLookup }
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
/** Reads the labels off the item itself; no request, no catalogue. */
export function orderItemLookup(item: OrderItemDetail): VariantLookup | undefined {
const variant = item.productVariant
if (!variant) return undefined
return {
productTitle: variant.product.title,
variantTitle: variant.title,
imageUrl: variant.product.images[0]?.url,
}
}
/**
* Kept as a composable so the call sites read unchanged. Synchronous now —
* `lookup` is resolved from the item, so it returns a value rather than a
* promise, and callers no longer need to await anything on mount.
*/
export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
if (!catalog.loaded) await catalog.load()
return catalog.variantLookup.get(productVariantId)
}
return { lookup }
return { lookup: orderItemLookup }
}
@@ -1,26 +1,31 @@
// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id).
// The new API has no single-product-detail endpoint (only GET
// /catalog/products, the flat list), so the product is found in the shared
// catalogue (stores/catalogStore.ts). When the catalogue is already cached,
// opening a product costs no request at all.
//
// Reads ONE product from GET /catalog/products/:id.
//
// It used to fetch the shared catalogue and `find()` the row in it, because the
// API had no single-product route. With the real catalogue reaching ~16k
// products that meant opening any product could download all of them, so the
// route now exists (plan step 2) and this is the first caller moved onto it.
//
// `initial` seeds the page with the ProductSummary the tapped card already
// holds — the same shape, from the same endpoint — so the detail view renders
// complete on its first frame instead of mounting blank and jumping when the
// fetch lands. The lookup still runs, silently, and only replaces the product
// if the catalogue's copy actually differs (stock moved since the list loaded).
// fetch lands. The read still runs, silently, and only replaces the product if
// the server's copy actually differs (stock moved since the list loaded).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: if the catalogue has to come from the server, parsing it — or
// swapping the product and re-rendering — mid-animation drops frames.
// is over: swapping the product and re-rendering mid-animation drops frames.
import { ref } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { api, UnauthorizedError, type ProductSummary } from '../api/client'
const SEEDED_REFRESH_DELAY_MS = 600
/** A 404 from the API client, which throws `API <path> failed (404): …`. */
function isNotFound(e: unknown): boolean {
return e instanceof Error && !(e instanceof UnauthorizedError) && /\(404\)/.test(e.message)
}
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
@@ -31,17 +36,30 @@ export function useProductDetail(id: string, initial?: ProductSummary | null) {
if (seeded) await new Promise((resolve) => setTimeout(resolve, SEEDED_REFRESH_DELAY_MS))
else loading.value = true
try {
const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null
const found = await api.getProduct(id)
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
if (JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
} else {
productRef.value = found
notFound.value = !found
}
flag.value = true
} catch (e) {
console.log(e)
// Only a real 404 means "no such product". Anything else — offline, a
// 500 — must not render the "not found" page: before this route existed
// a failed catalogue fetch left `notFound` false, and that is still the
// right answer, because the product may well exist.
if (isNotFound(e)) {
if (!seeded) {
productRef.value = null
notFound.value = true
}
// Seeded and now 404: the product was pulled from the storefront while
// the page was open. The card's copy stays on screen rather than
// blanking under the person mid-read; the cart refuses it at checkout.
} else {
console.log(e)
}
flag.value = true
} finally {
loading.value = false
}