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
@@ -67,7 +67,34 @@ export class CustomersService {
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },
include: { items: true },
include: {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } },
},
},
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
}
@@ -0,0 +1,62 @@
import { CustomersService } from './customers.service';
/**
* `GET customer/me/orders` must carry each item's product and variant.
*
* The apps used to label order rows by downloading the ENTIRE catalogue and
* joining it in the browser (composables/useOrderItemProduct.ts). With the real
* catalogue at ~16k products that is the wrong trade for a few row labels, and
* it was also broken for the case order history exists for: a product that has
* since left the storefront is absent from the catalogue, so the row rendered
* blank.
*
* Both apps now read these fields straight off the item, so the include is part
* of the contract — hence a test, which this method did not have.
*/
describe('CustomersService.getOrders — items carry their product', () => {
function setup() {
const findMany = jest.fn().mockResolvedValue([]);
const prisma = { order: { findMany } };
// getOrders touches neither the accounting sync nor anything on it.
const service = new CustomersService(prisma as never, {} as never);
return { service, findMany };
}
it('includes the variant, its product and the first image', async () => {
const { service, findMany } = setup();
await service.getOrders('c1');
const include = findMany.mock.calls[0][0].include;
const variant = include.items.include.productVariant;
expect(variant.select.title).toBe(true);
expect(variant.select.product.select.title).toBe(true);
// images[0] is the product's main photo, so it must be ordered and limited
// to one — the apps read `images[0].url` and nothing else.
expect(variant.select.product.select.images).toMatchObject({
orderBy: { sortOrder: 'asc' },
take: 1,
});
});
it('is scoped to the caller and newest first', async () => {
// Guards the two properties that were already here: one customer must never
// receive another's orders, and the list is reverse-chronological.
const { service, findMany } = setup();
await service.getOrders('c1');
expect(findMany.mock.calls[0][0]).toMatchObject({
where: { customerId: 'c1' },
orderBy: { createdAt: 'desc' },
});
});
it('does not filter the product by storefront visibility', async () => {
// Deliberate: an order for a product that has since been deactivated, or
// had its photo removed, must still show what was bought. The storefront
// rules apply to the catalogue, not to history.
const { service, findMany } = setup();
await service.getOrders('c1');
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
expect(product.where).toBeUndefined();
});
});
+112
View File
@@ -4,6 +4,8 @@ const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/** Must match stores/session.ts — the act-as target, if any. */
const ACTING_AS_KEY = 'gold-bank:actingAs';
/** Must match MAX_IDS on the server's ListProductsDto. */
const ID_CHUNK = 100;
/**
* Called when the API rejects our token. Wired up in main.ts rather than
@@ -130,6 +132,54 @@ export interface ProductSummary {
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
}
/** Query for the paged storefront list; every field is optional. */
export interface ProductQuery {
page?: number;
limit?: number;
search?: string;
categoryId?: string;
brandId?: string;
collectionId?: string;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
}
/**
* One page of products. `total` is the size of the FILTERED set, not of the
* page, so it both drives the "N محصول" count and tells the list when to stop
* asking for more.
*/
export interface ProductPage {
rows: ProductSummary[];
total: number;
page: number;
limit: number;
}
/** Filter options, built from the catalogue server-side. */
export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
/**
* Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request
* rather than `?search=undefined` — which would be a filter for the literal
* text "undefined".
*/
function toQueryString(query: object): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const s = params.toString();
return s ? `?${s}` : '';
}
export interface LedgerEntrySummary {
id: string;
currency: string;
@@ -158,6 +208,22 @@ export interface OrderItemDetail {
discountPercentSnapshot: string;
computedWeightGrams: string;
flatRialCharge: string;
/**
* The product and variant this line is for, included by the server.
*
* Optional because an order fetched by an older client — or a cached response
* from before the server included it — has no such field. The apps fall back
* to a plain label rather than showing nothing.
*
* It used to be looked up in the browser against the whole catalogue, which
* both downloaded ~16k products for a few row labels and rendered blank for a
* product that had since left the storefront.
*/
productVariant?: {
id: string;
title: string;
product: { id: string; title: string; images: Array<{ url: string }> };
} | null;
}
export interface OrderDetail {
@@ -331,7 +397,53 @@ function iamAppUrl(path: string) {
}
export const api = {
/**
* The WHOLE active catalogue in one response.
*
* The real catalogue reaches ~16k products, so prefer the paged/targeted
* calls below; this stays for the screens that genuinely work over the full
* list and is what the route answers when asked for nothing in particular.
*/
listProducts: () => request<ProductSummary[]>('/catalog/products'),
/** One page of the storefront, filtered and counted server-side. */
listProductsPaged: (query: ProductQuery = {}) =>
request<ProductPage>(`/catalog/products${toQueryString(query)}`),
/**
* One product, instead of downloading the catalogue to `find()` it.
* 404s for a product that is not on the storefront (inactive, or no photo).
*/
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
/**
* Specific products by id — the cart, favourites, order items.
*
* Chunked because the route caps `ids` at 100 and drops the rest: a caller
* that asked for more would silently get a partial answer, and callers treat
* "missing from the answer" as "no longer on the storefront". Favourites
* already run to the high hundreds in the imported data (874 rows, FAV-01),
* so this is a live case, not a theoretical one.
*/
getProductsByIds: async (ids: string[]): Promise<ProductSummary[]> => {
const unique = [...new Set(ids)].filter(Boolean);
if (unique.length === 0) return [];
const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) =>
unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
);
const pages = await Promise.all(
chunks.map((chunk) =>
request<ProductPage>(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`),
),
);
return pages.flatMap((p) => p.rows);
},
/**
* The filter sheet's options (categories, brands, collections, gram range),
* which used to be derived by scanning the whole catalogue in the browser.
*/
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the
@@ -3,9 +3,10 @@
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries
productVariantId.
The product/variant title and photo come WITH the order (the server includes
each item's variant and product). They used to be looked up in the browser
against the whole catalogue, which downloaded ~16k products to label a few
rows and left the row blank for a product that had since left the storefront.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
@@ -46,18 +47,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>()
onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId)
})
// The server sends these with the order, so there is nothing to fetch and
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
</script>
<style scoped lang="less">
@@ -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
}
+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
@@ -22,7 +22,7 @@
// router guard against a bootstrap request this app does not have; see the same
// note in user-app's configStore for the full reasoning (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'
@@ -72,15 +72,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)
+11 -2
View File
@@ -215,14 +215,23 @@ function goNewest() {
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
if (catsResult.status === 'rejected') console.log(catsResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero covers this silently, same as before this existed.
@@ -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">
+112
View File
@@ -4,6 +4,8 @@ const BASE = '/api';
const TOKEN_KEY = 'gold-bank:token';
/** Must match stores/session.ts — the act-as target, if any. */
const ACTING_AS_KEY = 'gold-bank:actingAs';
/** Must match MAX_IDS on the server's ListProductsDto. */
const ID_CHUNK = 100;
/**
* Called when the API rejects our token. Wired up in main.ts rather than
@@ -145,6 +147,54 @@ export interface ProductSummary {
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
}
/** Query for the paged storefront list; every field is optional. */
export interface ProductQuery {
page?: number;
limit?: number;
search?: string;
categoryId?: string;
brandId?: string;
collectionId?: string;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
}
/**
* One page of products. `total` is the size of the FILTERED set, not of the
* page, so it both drives the "N محصول" count and tells the list when to stop
* asking for more.
*/
export interface ProductPage {
rows: ProductSummary[];
total: number;
page: number;
limit: number;
}
/** Filter options, built from the catalogue server-side. */
export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
/**
* Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request
* rather than `?search=undefined` — which would be a filter for the literal
* text "undefined".
*/
function toQueryString(query: object): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const s = params.toString();
return s ? `?${s}` : '';
}
export interface LedgerEntrySummary {
id: string;
currency: string;
@@ -173,6 +223,22 @@ export interface OrderItemDetail {
discountPercentSnapshot: string;
computedWeightGrams: string;
flatRialCharge: string;
/**
* The product and variant this line is for, included by the server.
*
* Optional because an order fetched by an older client — or a cached response
* from before the server included it — has no such field. The apps fall back
* to a plain label rather than showing nothing.
*
* It used to be looked up in the browser against the whole catalogue, which
* both downloaded ~16k products for a few row labels and rendered blank for a
* product that had since left the storefront.
*/
productVariant?: {
id: string;
title: string;
product: { id: string; title: string; images: Array<{ url: string }> };
} | null;
}
export interface OrderDetail {
@@ -346,8 +412,54 @@ function iamAppUrl(path: string) {
}
export const api = {
/**
* The WHOLE active catalogue in one response.
*
* The real catalogue reaches ~16k products, so prefer the paged/targeted
* calls below; this stays for the screens that genuinely work over the full
* list and is what the route answers when asked for nothing in particular.
*/
listProducts: () => request<ProductSummary[]>('/catalog/products'),
/** One page of the storefront, filtered and counted server-side. */
listProductsPaged: (query: ProductQuery = {}) =>
request<ProductPage>(`/catalog/products${toQueryString(query)}`),
/**
* One product, instead of downloading the catalogue to `find()` it.
* 404s for a product that is not on the storefront (inactive, or no photo).
*/
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
/**
* Specific products by id — the cart, favourites, order items.
*
* Chunked because the route caps `ids` at 100 and drops the rest: a caller
* that asked for more would silently get a partial answer, and callers treat
* "missing from the answer" as "no longer on the storefront". Favourites
* already run to the high hundreds in the imported data (874 rows, FAV-01),
* so this is a live case, not a theoretical one.
*/
getProductsByIds: async (ids: string[]): Promise<ProductSummary[]> => {
const unique = [...new Set(ids)].filter(Boolean);
if (unique.length === 0) return [];
const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) =>
unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
);
const pages = await Promise.all(
chunks.map((chunk) =>
request<ProductPage>(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`),
),
);
return pages.flatMap((p) => p.rows);
},
/**
* The filter sheet's options (categories, brands, collections, gram range),
* which used to be derived by scanning the whole catalogue in the browser.
*/
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the
@@ -3,9 +3,10 @@
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries
productVariantId.
The product/variant title and photo come WITH the order (the server includes
each item's variant and product). They used to be looked up in the browser
against the whole catalogue, which downloaded ~16k products to label a few
rows and left the row blank for a product that had since left the storefront.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
@@ -46,18 +47,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>()
onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId)
})
// The server sends these with the order, so there is nothing to fetch and
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
</script>
<style scoped lang="less">
@@ -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
}
+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)
+11 -2
View File
@@ -247,14 +247,23 @@ function goNewest() {
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
if (catsResult.status === 'rejected') console.log(catsResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero below covers this silently, same as before this existed.
+32 -14
View File
@@ -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">
+20 -6
View File
@@ -113,13 +113,27 @@ ProductImage: @@index([productId])
## ۴. ترتیب اجرا (هر مرحله قابل تست و بی‌شکست)
| مرحله | کار | چرا این ترتیب |
| مرحله | کار | وضعیت |
|---|---|---|
| ۱ | مهاجرت ایندکس‌ها | بدون این، صفحه‌بندی هم کند است. مستقل و بی‌ریسک. |
| ۲ | چهار مسیر سرور + کش کلیدی + تست | سازگار عقب‌رو؛ اپ‌ها هنوز دست‌نخورده کار می‌کنند |
| ۳ | `useProductDetail`، `cartStore`، `FavoritesView`، `configStore` → مسیرهای نقطه‌ای | بزرگ‌ترین برد با کمترین ریسک؛ رفتار دیداری عوض نمی‌شود |
| ۴ | `SearchView` / `NewestView` → صفحه‌بندی واقعی سرور | پرریسک‌ترین (فیلتر + اسکرول)؛ آخر، وقتی بقیه پایدار است |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
| ۱ | مهاجرت ایندکس‌ها | ✅ **انجام شد** (`cbb93de`) — `Product` هیچ ایندکسی نداشت |
| ۲ | مسیرهای سرور + تست | ✅ **انجام شد** (`cbb93de`) — صفحه‌بندی opt-in، فیلتر سمت سرور، ۱۷ تست |
| ۳ | صفحه محصول، سبد، علاقه‌مندی، دسته‌ها، برچسب آیتم سفارش | ✅ **انجام شد** — پنج مصرف‌کننده منتقل شد |
| ۴ | `SearchView` / `NewestView` / rails خانه → صفحه‌بندی واقعی | ⏳ **باقی** — پرریسک‌ترین (فیلتر + اسکرول) |
| ۵ | حذف خوانش کل کاتالوگ و هشدار لاگ | ⏳ فقط وقتی هیچ مصرف‌کننده‌ای نمانده |
### یادداشت مرحله ۳ (انجام‌شده)
«برچسب آیتم سفارش» در طرح اولیه نبود و سر راه پیدا شد: `OrderItem` فقط
`productVariantId` دارد نه `productId`، پس `?ids=` جوابش نمی‌داد. سرور همان را
با یک `include` برمی‌گرداند — که برای محصول حذف‌شده هم کار می‌کند، برعکس روش
قبلی که ردیف را خالی می‌گذاشت.
یک باگ هم در حین کار ساخته و گرفته شد: انتقال دسته‌ها به `/facets` باعث شد
`memory.init()` دیگر کاتالوگ را بار نزند و rails خانه خالی شود؛ `HomeView` حالا
صریحاً کاتالوگ را بار می‌کند.
**مرحله ۴ هنوز باقی است** — یعنی rails خانه، جستجو و «جدیدترین» همچنان کل
کاتالوگ را می‌گیرند. پس تا آن مرحله، سود واقعی ۱۶ هزار رکوردی کامل نشده.
## ۵. دو چیز که باید بدانی
+85
View File
@@ -1258,3 +1258,88 @@ confirm the suite fails (it did, 1 failed/16 passed) and then restored.
Worth one `EXPLAIN ANALYZE` on a full dataset before step 4.
_No data was deleted in this entry._
---
## 2026-10-12 — Catalogue paging, step 3: the apps stop fetching everything
- **What:** Five places that loaded the whole catalogue to find a few rows now
read exactly what they need — the product screen, the cart re-sync,
favourites, the home categories, and order-row labels.
- **Why:** step 3 of `docs/CATALOG-PAGINATION-PLAN.md`. Step 2 added the
targeted routes; these are the callers. At ~16k products each of these was
downloading the entire catalogue, and three of them would have been **wrong**
as well as slow once the list is paged.
- **Target:** both apps' `api/client.ts`, `composables/useProductDetail.ts`,
`composables/useOrderItemProduct.ts`, `stores/cartStore.ts`,
`stores/configStore.ts`, `stores/catalogStore.ts`,
`views/user/FavoritesView.vue`, `views/HomeView.vue`,
`components/product/OrderItemCard.vue`; api
`modules/customers/customers.service.ts` + `order-items-include.spec.ts`.
- **Matrix row:** STUB-03 in `ISSUES.md`.
- **Commit:** see below.
| Caller | Was | Now |
|---|---|---|
| product screen | whole catalogue, then `find(id)` | `GET /catalog/products/:id` |
| cart re-sync | whole catalogue for 2–3 lines | `?ids=` for the cart's products |
| favourites | whole catalogue, filtered in the browser | `?ids=` for the saved ids |
| home categories | whole catalogue, categories derived from it | `GET /catalog/products/facets` |
| order row labels | whole catalogue, joined on variantId | included with the order |
**Three of these were not just 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. The 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
that history exists for — and rendered a blank row.
**Order labels moved 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: one join instead of 16k rows, and it answers for
de-listed products too, because order history is deliberately NOT filtered by
storefront visibility (asserted in the new spec). `OrderItemCard` is now
synchronous — no fetch, no `onMounted`, the row renders complete on its first
frame.
**`variantLookup` was deleted from `catalogStore`**, not 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 would be read by 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), so this is a
live case.
**A failed cart re-sync now leaves the cart alone.** It is the person's own
data in localStorage; dropping lines because the network blinked is worse than
showing a slightly stale price.
**The product screen tells 404 from "request failed".** Before, a failed
catalogue fetch left `notFound` false, which was right — the product probably
exists. Only a real 404 renders the not-found page. If the page was already
seeded from the tapped card and the product has since been de-listed, the card's
copy stays on screen rather than blanking mid-read; the cart refuses it at
checkout.
**One bug introduced and caught here:** 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`, so the home screen would have rendered empty
rails. `HomeView` now loads the catalogue explicitly. Found by grepping every
caller of `configStore.init` rather than by running the app, which is also how I
know `HomeView` is the only caller.
**Still on the unpaged list, by design:** the home rails and the search/newest
lists. They slice locally today; moving them onto paged reads with server-side
filtering is step 4, kept separate because it is the risky part (debounce,
loading state, telling "no results" from "still loading").
**Verification.** api 334/334 across 30 suites; both apps `vue-tsc` clean. The
`getOrders` include has a new spec (3 cases) because it is now 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.
_No data was deleted in this entry._