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
@@ -3,11 +3,14 @@
restyled into the redesigned design language (see components/home/* and
HomeView).
Real, not stubbed: favorites are the local favoritesStore (per-device, no
backend persistence — see favoritesStore.ts from an earlier batch), filtered
against the real product catalog client-side. No pagination — the whole
catalog is fetched once (same "small catalog" approach as everywhere else).
That logic is carried over unchanged; only the chrome and the card change.
Favourites are server-side (FAV-01: GET/POST customer/me/favorites), and the
products behind them are fetched BY ID — the saved ids go straight to
`?ids=…`, one request for the lot.
It used to fetch the whole catalogue and filter it in the browser, which with
~16k products meant downloading all of them to show a handful. It was also
wrong beyond the first page once paging existed: a favourite outside the
loaded slice would simply not appear.
-->
<template>
<div class="favs">
@@ -35,7 +38,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -43,26 +46,41 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCatalogStore } from '../../stores/catalogStore'
import { api, type ProductSummary } from '../../api/client'
const router = useRouter()
const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false)
const allProducts = computed(() => catalog.products)
const fetched = ref<ProductSummary[]>([])
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
/**
* Still filtered against the store, so un-hearting a product removes its card
* at once without waiting for a refetch — the fetched list is the source of the
* product DATA, the store is the source of truth for what is saved.
*/
const favoriteProducts = computed(() => fetched.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = !catalog.loaded
async function load() {
const ids = favorites.ids
if (ids.length === 0) {
fetched.value = []
return
}
loading.value = fetched.value.length === 0
try {
await catalog.load()
fetched.value = await api.getProductsByIds(ids)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
onMounted(load)
// The ids arrive from the server just after sign-in, and change when the person
// hearts something elsewhere in the app — both land here as a new id list.
watch(() => favorites.ids.join(','), load)
</script>
<style scoped lang="less">