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
+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