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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user