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>
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
|
|
// 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 { api, type ProductSummary } from '../api/client'
|
|
|
|
export interface CartItem {
|
|
pid: string
|
|
vid: string
|
|
product: ProductSummary
|
|
qty: number
|
|
note?: string | null
|
|
}
|
|
|
|
const STORAGE_KEY = 'appCart'
|
|
|
|
function loadFromStorage(): CartItem[] {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
return raw ? JSON.parse(raw) : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function persist(items: CartItem[]) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
|
}
|
|
|
|
export const useCartStore = defineStore('cart', {
|
|
state: () => ({
|
|
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().
|
|
*
|
|
* 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 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 = 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 })
|
|
}
|
|
}
|
|
this.items = refreshed
|
|
persist(this.items)
|
|
} catch (e) {
|
|
console.log(e)
|
|
}
|
|
},
|
|
addToCart(data: CartItem) {
|
|
// Refuse a variant that cannot be ordered — out of stock and not flagged
|
|
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
|
|
// here so it holds even if a caller forgets to disable its button.
|
|
const target = data.product?.variants?.find((v) => v.id === data.vid)
|
|
if (!target) return false
|
|
if (!((target.qty ?? 0) > 0 || target.isPreOrder)) return false
|
|
|
|
const index = this.find_index(data)
|
|
if (index === -1) this.items.push({ ...data, qty: this.capQty(data.qty, target) })
|
|
else {
|
|
this.items[index].product = data.product
|
|
this.items[index].qty = this.capQty(data.qty, target)
|
|
}
|
|
persist(this.items)
|
|
return true
|
|
},
|
|
|
|
/**
|
|
* Stock ceiling for a cart line. A pre-order variant is orderable beyond
|
|
* what is on hand (that is the point of pre-ordering), so it is uncapped;
|
|
* anything else stops at its on-hand quantity.
|
|
*/
|
|
capQty(want: number, variant: { qty?: number; isPreOrder?: boolean }) {
|
|
const n = Math.max(1, Math.floor(want) || 1)
|
|
if (variant.isPreOrder) return n
|
|
const max = variant.qty ?? 0
|
|
return max > 0 ? Math.min(n, max) : n
|
|
},
|
|
|
|
/** The ceiling for an item already in the cart; null when uncapped. */
|
|
maxQty(item: Pick<CartItem, 'pid' | 'vid'>) {
|
|
const i = this.find_index(item)
|
|
if (i === -1) return null
|
|
const v = this.variant(this.items[i])
|
|
if (!v || v.isPreOrder) return null
|
|
return (v.qty ?? 0) > 0 ? (v.qty as number) : null
|
|
},
|
|
remove(item: CartItem) {
|
|
const index = this.find_index(item)
|
|
if (index > -1) this.items.splice(index, 1)
|
|
persist(this.items)
|
|
},
|
|
// Legacy's exact name (typo'd "Feild"), kept for consistency with the rest
|
|
// of this faithfully-ported store's method names.
|
|
updateFeild(value: unknown, index: number | null, item: Pick<CartItem, 'pid' | 'vid'>, field: keyof CartItem) {
|
|
if (!this.isInCart(item)) return false
|
|
const i = index ?? this.find_index(item)
|
|
// A quantity change is clamped to what is actually in stock (unless the
|
|
// variant is pre-orderable), so the ceiling holds no matter which screen
|
|
// drives the stepper.
|
|
if (field === 'qty') {
|
|
const v = this.variant(this.items[i])
|
|
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
|
|
}
|
|
;(this.items[i] as any)[field] = value
|
|
persist(this.items)
|
|
return true
|
|
},
|
|
find_index(item: Pick<CartItem, 'pid' | 'vid'>) {
|
|
return this.items.findIndex((cartItem) => cartItem.pid === item.pid && cartItem.vid === item.vid)
|
|
},
|
|
isInCart(item: Pick<CartItem, 'pid' | 'vid'>) {
|
|
return this.find_index(item) > -1
|
|
},
|
|
variant(item: CartItem) {
|
|
return item.product.variants.find((v) => v.id === item.vid)
|
|
},
|
|
total() {
|
|
return this.items
|
|
.reduce((sum, item) => {
|
|
const v = this.variant(item)
|
|
return sum + (v ? parseFloat(v.rashWeightGrams) * item.qty : 0)
|
|
}, 0)
|
|
.toFixed(2)
|
|
},
|
|
clear() {
|
|
this.items = []
|
|
persist(this.items)
|
|
},
|
|
},
|
|
})
|