feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-11 14:31:32 +03:30
co-authored by Cursor
parent 561addc676
commit 574fa1b4aa
53 changed files with 1241 additions and 401 deletions
+4 -3
View File
@@ -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)
+65
View File
@@ -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 }
})
+7 -14
View File
@@ -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)
+100
View File
@@ -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 }
})