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
+18
View File
@@ -0,0 +1,18 @@
// Cached versions of the reads screens repeat on every visit. Each one goes
// through queryCache under its own key; writes stay on `api` directly.
import { api } from './client'
import { KEYS, useQueryCache, type FetchOptions } from '../stores/queryCache'
export function useCachedReads() {
const cache = useQueryCache()
return {
me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o),
myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o),
accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o),
myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o),
goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o),
homeSlider: (o?: FetchOptions) => cache.fetch(KEYS.homeSlider, api.getHomeSlider, o),
peek: cache.peek,
invalidate: cache.invalidate,
}
}
@@ -1,39 +1,16 @@
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins
// against the already-existing GET /catalog/products list client-side rather
// against the shared catalogue (stores/catalogStore.ts) client-side rather
// than touching apps/api to add an include.
import { ref } from 'vue'
import { api } from '../api/client'
import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
export interface VariantLookup {
productTitle: string
variantTitle: string
imageUrl?: string
}
const cache = ref<Map<string, VariantLookup> | null>(null)
async function ensureLoaded() {
if (cache.value) return cache.value
const products = await api.listProducts()
const map = new Map<string, VariantLookup>()
for (const product of products) {
for (const variant of product.variants) {
map.set(variant.id, {
productTitle: product.title,
variantTitle: variant.title,
imageUrl: product.images[0]?.url,
})
}
}
cache.value = map
return map
}
export type { VariantLookup }
export function useOrderItemProduct() {
const catalog = useCatalogStore()
const lookup = async (productVariantId: string): Promise<VariantLookup | undefined> => {
const map = await ensureLoaded()
return map.get(productVariantId)
if (!catalog.loaded) await catalog.load()
return catalog.variantLookup.get(productVariantId)
}
return { lookup }
}
@@ -1,25 +1,26 @@
// 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) — apps/api is out of scope for this port,
// so this fetches the full list and finds the matching product client-side.
// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent
// behavior change.
// /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.
//
// `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 fetch still runs, silently, and only replaces the product
// if the server's copy actually differs (stock moved since the list loaded).
// 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).
//
// The silent refresh waits until the opening morph (ProductMorph.vue, ≤420ms)
// is over: the response is the whole catalogue, and parsing it — or swapping
// the product and re-rendering — mid-animation drops frames.
// is over: if the catalogue has to come from the server, parsing it — or
// swapping the product and re-rendering — mid-animation drops frames.
import { ref } from 'vue'
import { api, type ProductSummary } from '../api/client'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
const SEEDED_REFRESH_DELAY_MS = 600
export function useProductDetail(id: string, initial?: ProductSummary | null) {
const catalog = useCatalogStore()
const loading = ref(false)
const flag = ref(Boolean(initial))
const notFound = ref(false)
@@ -30,7 +31,7 @@ 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 api.listProducts()
const products = await catalog.load()
const found = products.find((p) => p.id === id) ?? null
if (seeded) {
if (found && JSON.stringify(found) !== JSON.stringify(productRef.value)) productRef.value = found
+21 -10
View File
@@ -1,12 +1,13 @@
// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated
// against POST /get-products (page/limit/count). The new API's GET
// /catalog/products returns the full active catalog in one flat response with
// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger)
// is a no-op after the first successful fetch here, and `totalPages` is
// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact
// (it still calls fetch on scroll) without fabricating fake pages.
import { reactive } from 'vue'
import { api, type ProductSummary } from '../api/client'
// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger)
// is a no-op here, and `totalPages` is always 0. A caller that wants new
// products at the end of the list passes `force` to refresh the cached
// catalogue instead of fabricating fake pages.
import { reactive, watch } from 'vue'
import type { ProductSummary } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
export interface ProductsData {
totalPages: number
@@ -15,17 +16,27 @@ export interface ProductsData {
}
export function useProducts() {
const catalog = useCatalogStore()
const productsData: ProductsData = reactive({
totalPages: 0,
productList: [],
productList: catalog.products,
flag: false,
})
const getProducts = async (loading: { value: boolean }, isPull = false) => {
// A refresh from another screen (pull-to-refresh) lands here too.
watch(
() => catalog.products,
(list) => {
productsData.productList = list
productsData.flag = list.length < 1
},
)
const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => {
if (isPull) return // no further pages exist server-side yet
loading.value = true
loading.value = force || !catalog.loaded
try {
productsData.productList = await api.listProducts()
productsData.productList = await catalog.load(force)
productsData.flag = productsData.productList.length < 1
} catch (e) {
console.log(e)
+5 -5
View File
@@ -6,11 +6,11 @@ import { nextTick, ref } from 'vue'
* after another. The styles are the global `.reveal` / `.is-revealed` pair in
* assets/styles/style.less.
*
* MasterLayout renders its routes without keep-alive, so a screen remounts on
* every return to its tab. Which screens have already played is kept here in
* module scope, so the entrance is a first-run flourish that resets only on a
* full reload — replaying it on each tab switch would fight the page
* transition rather than add to it. (A `let` at the top of <script setup>
* MasterLayout keeps the main tabs alive, but a screen still remounts after a
* full-page route or if it is not in the keep-alive list. Which screens have
* already played is kept here in module scope, so the entrance is a first-run
* flourish that resets only on a full reload — replaying it on each return
* would fight the page transition rather than add to it. (A `let` at the top of <script setup>
* would not do this: that code runs once per mount.)
*/
const played = new Set<string>()
+11 -6
View File
@@ -1,10 +1,9 @@
<!--
Ported from legacy henza_pwa/src/layout/master.vue.
NOTE (existing issue, ported as-is — not fixed here): this layout renders
its own <router-view/> rather than a <slot/>, so the keep-alive/suspense
wrapper App.vue builds around its own <router-view> is never actually used
by this layout — the route component here is resolved fresh, independently.
Preserved exactly; flagged separately.
This layout renders its own <router-view/> rather than a <slot/>, so the
keep-alive/suspense wrapper App.vue builds around its own <router-view> never
reaches these routes. The keep-alive below is the one that applies: the main
tabs stay mounted between switches instead of refetching on every visit.
-->
<template>
<section ref="content">
@@ -16,7 +15,9 @@
@after-leave="pageLeaveEnded"
@leave-cancelled="pageLeaveEnded"
>
<component :is="Component" :key="route.name" />
<keep-alive :include="KEEP_ALIVE">
<component :is="Component" :key="route.name" />
</keep-alive>
</transition>
</router-view>
<Navigation />
@@ -31,6 +32,10 @@ import Navigation from '../components/ui/Navigation.vue'
import { useWindowDataStore } from '../stores/windowDataStore'
import { pageLeaveStarted, pageLeaveEnded } from '../composables/usePageTransition'
// Component names (from the SFC file names) of the bottom-nav tabs. Forms and
// the product page are left out: they should open fresh every time.
const KEEP_ALIVE = ['HomeView', 'SearchView', 'OrdersView', 'DashboardView']
const windowData = useWindowDataStore()
const content = ref<HTMLElement | null>(null)
+4 -3
View File
@@ -3,6 +3,7 @@ import { useSessionStore } from '../stores/session';
import { useNavStore } from '../stores/navStore';
import { useTransitionStore } from '../stores/transitionStore';
import { api } from '../api/client';
import { useCachedReads } from '../api/cachedReads';
import { afterPageLeave } from '../composables/usePageTransition';
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
@@ -131,13 +132,13 @@ router.beforeEach(async (to) => {
// Ported from henza_pwa router/index.ts:266 — once the documents are in,
// /complete_profile is not reachable any more and redirects home. The
// reference read its cached store; this asks the server, so a profile
// completed on another device is respected too. A failed lookup lets the
// reference read its cached store; this asks the server (through the shared
// short-lived cache), so a profile completed on another device is respected too. A failed lookup lets the
// navigation through: the page itself is harmless, and the server is the
// one that decides whether the write is allowed.
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
try {
const me = await api.getMe();
const me = await useCachedReads().me();
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
return { name: 'home' };
}
+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 }
})
+6 -1
View File
@@ -157,6 +157,8 @@ import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore
import { useSessionStore } from '../stores/session'
import { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
const router = useRouter()
const cart = useCartStore()
@@ -222,6 +224,9 @@ const submitOrder = async () => {
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
// The order shows up in the list and the balance, and may have taken stock.
useQueryCache().invalidate(KEYS.orders, KEYS.balance, KEYS.profile, KEYS.ledger)
useCatalogStore().invalidate()
} catch (e) {
console.log(e)
} finally {
@@ -230,7 +235,7 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init()
await cart.init(true)
refreshing.value = false
}
+24 -26
View File
@@ -133,22 +133,26 @@ import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(true)
const loading = ref(!catalog.loaded)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
const adminSlides = ref<AdminSlide[] | null>(null)
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
// First-paint entrance: played once, after the first data load, so the
@@ -240,31 +244,25 @@ function goSearch() {
function goNewest() {
router.push({ name: 'newestProduct' })
}
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
}
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero below covers this silently, same as before this existed.
adminSlides.value = null
}
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero below covers this silently, same as before this existed.
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
}
async function onRefresh() {
await loadData()
await loadData(true)
refreshing.value = false
}
+4 -2
View File
@@ -62,12 +62,14 @@ onBeforeMount(async () => {
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen.
// once the whole fetched list is on screen. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
await getProducts(loadingPull, true)
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
}
</script>
@@ -40,12 +40,13 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
@@ -56,9 +57,11 @@ const collectionProducts = computed(() =>
)
onMounted(async () => {
loadingPage.value = true
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
+14 -11
View File
@@ -148,18 +148,19 @@ import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
const catalog = useCatalogStore()
// Starts true: the first render happens before onMounted kicks off the fetch,
// and with false the "nothing found" box flashed up for a frame before the
// loading state replaced it.
const loadingPage = ref(true)
// Starts true on a cold start: the first render happens before onMounted kicks
// off the fetch, and with false the "nothing found" box flashed up for a frame
// before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
@@ -232,17 +233,19 @@ watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() {
loadingPage.value = true
async function loadProducts(force = false) {
loadingPage.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load(force)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts()
await loadProducts(true)
refreshing.value = false
}
@@ -256,7 +259,7 @@ function showFilters() {
showFilter.value = true
}
onMounted(loadProducts)
onMounted(() => loadProducts())
</script>
<style scoped lang="less">
@@ -87,6 +87,7 @@ import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter()
@@ -117,6 +118,7 @@ async function submit() {
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
useQueryCache().invalidate(KEYS.profile)
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
+28 -21
View File
@@ -154,7 +154,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
@@ -167,12 +167,15 @@ import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter()
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
@@ -254,30 +257,34 @@ async function logOut() {
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.isLoggedIn) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
try {
customer.value = await api.getMe()
} catch (e) {
console.log(e)
}
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
async function loadDashboard(force = false) {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
reads.goldPrice({ force }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
await loadDashboard(true)
refreshing.value = false
}
let activatedOnce = false
onActivated(() => {
// Kept alive by MasterLayout, so every later visit lands here, not in onMounted.
if (activatedOnce) void loadDashboard()
activatedOnce = true
})
// First-paint entrance, the same choreography HomeView uses: played once,
// after the first load; pull-to-refresh and tab returns don't replay it.
const { revealed, revealDelay, reveal } = useReveal('dashboard')
@@ -43,19 +43,22 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
import { useCatalogStore } from '../../stores/catalogStore'
const router = useRouter()
const favorites = useFavoritesStore()
const catalog = useCatalogStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const allProducts = computed(() => catalog.products)
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
loading.value = !catalog.loaded
try {
allProducts.value = await api.listProducts()
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
+16 -8
View File
@@ -164,9 +164,12 @@ import {
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const session = useSessionStore()
const reads = useCachedReads()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -183,7 +186,7 @@ const activeTab = ref('ALL')
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([])
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
@@ -197,11 +200,16 @@ function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.isLoggedIn) return
loading.value = true
async function loadOrders(force = false) {
if (!session.isLoggedIn) {
allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try {
allOrders.value = await api.getMyOrders()
allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
@@ -220,11 +228,11 @@ function openDetail(item: OrderDetail) {
}
async function onRefresh() {
await loadOrders()
await loadOrders(true)
refreshing.value = false
}
onMounted(loadOrders)
onMounted(() => loadOrders())
</script>
<style scoped lang="less">
+12 -8
View File
@@ -104,7 +104,9 @@ import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session'
import { api, type AccountingBalance, type CustomerDetail } from '../../api/client'
import type { AccountingBalance, CustomerDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
@@ -115,7 +117,8 @@ const VERIFICATION_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
@@ -133,8 +136,9 @@ const canCompleteProfile = computed(() => {
const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself.
const balance = ref<AccountingBalance | null>(null)
// accounting server never holds up the profile itself. The last copy fills the
// screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0)
@@ -151,9 +155,9 @@ const balanceSubtitle = computed(() => {
})
async function loadBalance() {
balanceLoading.value = true
balanceLoading.value = !balance.value
try {
balance.value = await api.getAccountingBalance()
balance.value = await reads.accountingBalance({ force: true })
} catch {
balance.value = null
} finally {
@@ -164,9 +168,9 @@ async function loadBalance() {
onMounted(async () => {
if (!session.isLoggedIn) return
void loadBalance()
loading.value = true
loading.value = !customer.value
try {
customer.value = await api.getMe()
customer.value = await reads.me({ force: true })
} finally {
loading.value = false
}
@@ -107,7 +107,9 @@ import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
import type { LedgerEntrySummary } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
@@ -121,9 +123,10 @@ const SOURCE_LABELS: Record<string, string> = {
}
const session = useSessionStore()
const reads = useCachedReads()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
@@ -141,9 +144,9 @@ const openEntry = (item: LedgerEntrySummary) => {
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = true
loading.value = reads.peek(KEYS.ledger) === undefined
try {
entries.value = await api.getMyLedgerEntries()
entries.value = await reads.myLedgerEntries()
} finally {
loading.value = false
}