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:
co-authored by
Claude Opus 5
parent
cbb93dec5d
commit
9fc0886a57
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user