feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
// 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'
|
||||
import type { ProductSummary } from '../api/client'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
|
||||
export interface CartItem {
|
||||
pid: string
|
||||
@@ -36,9 +37,9 @@ export const useCartStore = defineStore('cart', {
|
||||
// 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().
|
||||
async init() {
|
||||
async init(force = false) {
|
||||
try {
|
||||
const products = await api.listProducts()
|
||||
const products = await useCatalogStore().load(force)
|
||||
const refreshed: CartItem[] = []
|
||||
for (const item of this.items) {
|
||||
const product = products.find((p) => p.id === item.pid)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// The storefront catalogue (GET /catalog/products), shared by every screen.
|
||||
//
|
||||
// The endpoint returns the whole active catalogue in one response, so one copy
|
||||
// serves home, search, collections, favourites, the product page, the cart
|
||||
// re-sync and the order-item lookup. Reads go through queryCache and keep the
|
||||
// copy until pull-to-refresh, the end of the newest-products list, or an order.
|
||||
import { defineStore } from 'pinia'
|
||||
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.
|
||||
const cached = cache.peek<ProductSummary[]>(KEYS.catalog)
|
||||
const products = shallowRef<ProductSummary[]>(cached ?? [])
|
||||
/** True once a response has arrived — an empty catalogue is still "loaded". */
|
||||
const loaded = ref(cached !== undefined)
|
||||
|
||||
const categories = computed(() => {
|
||||
const seen = new Map<string, { id: string; title: string }>()
|
||||
for (const p of products.value) {
|
||||
if (p.category && !seen.has(p.category.id)) seen.set(p.category.id, { id: p.category.id, title: p.category.title })
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
async function load(force = false): Promise<ProductSummary[]> {
|
||||
const list = await cache.fetch(KEYS.catalog, api.listProducts, { force })
|
||||
products.value = list
|
||||
loaded.value = true
|
||||
return list
|
||||
}
|
||||
|
||||
function byId(id: string): ProductSummary | null {
|
||||
return products.value.find((p) => p.id === id) ?? null
|
||||
}
|
||||
|
||||
/** Drops the cached copy; the next load() goes to the server. */
|
||||
function invalidate() {
|
||||
cache.invalidate(KEYS.catalog)
|
||||
}
|
||||
|
||||
return { products, loaded, categories, variantLookup, load, byId, invalidate }
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
//
|
||||
// Not ported (no backend): promotionList, ignoreList, priceList, isLoaded.
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalogStore } from './catalogStore'
|
||||
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'
|
||||
@@ -70,20 +70,13 @@ export const useConfigStore = defineStore('config', {
|
||||
// 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.
|
||||
async init() {
|
||||
// The catalogue comes from catalogStore, so calling this next to another
|
||||
// catalogue read costs no second request.
|
||||
async init(force = false) {
|
||||
try {
|
||||
const products = await api.listProducts()
|
||||
const seen = new Map<string, HomeCategory>()
|
||||
for (const p of products) {
|
||||
if (p.category && !seen.has(p.category.id)) {
|
||||
seen.set(p.category.id, {
|
||||
id: p.category.id,
|
||||
title: p.category.title,
|
||||
icon: { image: brandArt },
|
||||
})
|
||||
}
|
||||
}
|
||||
this.cats = [...seen.values()]
|
||||
const catalog = useCatalogStore()
|
||||
await catalog.load(force)
|
||||
this.cats = catalog.categories.map((c) => ({ ...c, icon: { image: brandArt } }))
|
||||
this.loaded = true
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Shared read cache for API responses.
|
||||
//
|
||||
// Every screen used to fetch on mount, so each tab switch repeated the same
|
||||
// requests — the whole catalogue alone was fetched from ten places. Reads now go
|
||||
// through `fetch(key, …)`:
|
||||
//
|
||||
// - cached entry → returned at once, no request
|
||||
// - request in flight → the same promise is shared, never a second request
|
||||
// - `force` → always asks the server (pull-to-refresh, the
|
||||
// profile tab's balance, reaching the end of a list)
|
||||
//
|
||||
// Entries never expire on a timer: data changes when the user asks for it or
|
||||
// when a write here makes it wrong, and those writes call `invalidate`.
|
||||
//
|
||||
// Entries live in a plain Map, not reactive state: the catalogue is large and
|
||||
// making it deeply reactive would cost more than the request it saves.
|
||||
//
|
||||
// Keys under `me:` hold one customer's data. They are dropped whenever the
|
||||
// session token changes, so the next user never sees the previous one's data.
|
||||
import { defineStore } from 'pinia'
|
||||
import { watch } from 'vue'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
export const KEYS = {
|
||||
catalog: 'catalog',
|
||||
goldPrice: 'goldPrice',
|
||||
homeSlider: 'homeSlider',
|
||||
profile: 'me:profile',
|
||||
orders: 'me:orders',
|
||||
balance: 'me:balance',
|
||||
ledger: 'me:ledger',
|
||||
} as const
|
||||
|
||||
const USER_PREFIX = 'me:'
|
||||
|
||||
interface Entry {
|
||||
data?: unknown
|
||||
/** False until the first successful response. */
|
||||
has: boolean
|
||||
inflight?: Promise<unknown>
|
||||
}
|
||||
|
||||
export interface FetchOptions {
|
||||
/** Skip the cached value. An identical request already in flight is still shared. */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export const useQueryCache = defineStore('queryCache', () => {
|
||||
const entries = new Map<string, Entry>()
|
||||
const session = useSessionStore()
|
||||
|
||||
function request<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const entry = entries.get(key)
|
||||
if (entry?.inflight) return entry.inflight as Promise<T>
|
||||
|
||||
const p: Promise<T> = fetcher()
|
||||
.then((data) => {
|
||||
// Invalidated (or the user changed) while this was in flight: the
|
||||
// response may predate the change, so it is handed back but not kept.
|
||||
if (entries.get(key)?.inflight === p) entries.set(key, { data, has: true })
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
const current = entries.get(key)
|
||||
if (current?.inflight === p) current.inflight = undefined
|
||||
})
|
||||
|
||||
entries.set(key, { ...(entry ?? { has: false }), inflight: p })
|
||||
return p
|
||||
}
|
||||
|
||||
async function fetch<T>(key: string, fetcher: () => Promise<T>, opts: FetchOptions = {}): Promise<T> {
|
||||
const entry = entries.get(key)
|
||||
if (!opts.force && entry?.has) return entry.data as T
|
||||
return request(key, fetcher)
|
||||
}
|
||||
|
||||
/** The cached value, without fetching. */
|
||||
function peek<T>(key: string): T | undefined {
|
||||
const entry = entries.get(key)
|
||||
return entry?.has ? (entry.data as T) : undefined
|
||||
}
|
||||
|
||||
function invalidate(...keys: string[]) {
|
||||
for (const key of keys) entries.delete(key)
|
||||
}
|
||||
|
||||
function clearUserScoped() {
|
||||
for (const key of [...entries.keys()]) {
|
||||
if (key.startsWith(USER_PREFIX)) entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => session.token,
|
||||
() => clearUserScoped(),
|
||||
)
|
||||
|
||||
return { fetch, peek, invalidate, clearUserScoped }
|
||||
})
|
||||
Reference in New Issue
Block a user