feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -150,6 +150,8 @@ import CartItem from '../components/product/CartItem.vue'
|
||||
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalogStore } from '../stores/catalogStore'
|
||||
import { KEYS, useQueryCache } from '../stores/queryCache'
|
||||
|
||||
// Module scope, so it survives this view being unmounted and remounted as the
|
||||
// user moves between tabs, and resets only on a full app reload — the same
|
||||
@@ -231,6 +233,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 {
|
||||
@@ -239,7 +244,7 @@ const submitOrder = async () => {
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await cart.init()
|
||||
await cart.init(true)
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -94,8 +94,10 @@ import ProductCarousel from '../components/home/ProductCarousel.vue'
|
||||
import { useConfigStore } from '../stores/configStore'
|
||||
import type { HomeCategory } from '../stores/configStore'
|
||||
import { useFiltersStore } from '../stores/filtersStore'
|
||||
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'
|
||||
|
||||
// Module scope, so it survives this view being unmounted and remounted as the
|
||||
// user moves between tabs, and resets only on a full app reload.
|
||||
@@ -104,27 +106,29 @@ let hasRevealed = false
|
||||
const router = useRouter()
|
||||
const memory = useConfigStore()
|
||||
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.
|
||||
// Kept in step with user-app, which has had this since the admin screen was
|
||||
// wired up; without it, slides configured in the admin panel never showed here.
|
||||
const adminSlides = ref<AdminSlide[] | null>(null)
|
||||
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
|
||||
|
||||
|
||||
// First-paint entrance. Flipped once, after the first data load, so the
|
||||
// sections settle into place instead of appearing all at once; pull-to-refresh
|
||||
// does not replay it.
|
||||
//
|
||||
// MasterLayout renders its route without keep-alive, so this view remounts on
|
||||
// every return to the Home tab. The flag therefore lives outside the component
|
||||
// — the choreography is a first-run flourish, and replaying it on each tab
|
||||
// switch would fight the page transition rather than add to it.
|
||||
// MasterLayout keeps this view alive between tab switches, but it still
|
||||
// remounts after a visit to a full-page route. The flag therefore lives outside
|
||||
// the component — the choreography is a first-run flourish, and replaying it
|
||||
// would fight the page transition rather than add to it.
|
||||
const revealed = ref(hasRevealed)
|
||||
|
||||
// Each section starts a beat after the one above it. Kept small — the whole
|
||||
@@ -208,31 +212,25 @@ 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 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 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
|
||||
}
|
||||
|
||||
|
||||
@@ -71,12 +71,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
|
||||
}
|
||||
|
||||
@@ -108,13 +108,14 @@ 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()
|
||||
|
||||
const loadingPage = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const allProducts = ref<ProductSummary[]>([])
|
||||
const allProducts = computed(() => catalog.products)
|
||||
const showFilter = ref(false)
|
||||
const filterKeys = ref<string[]>([])
|
||||
|
||||
@@ -187,17 +188,20 @@ watch(sentinel, mountObserver)
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
async function loadProducts() {
|
||||
loadingPage.value = true
|
||||
async function loadProducts(force = false) {
|
||||
// Cached data renders at once; the spinner is only for a cold start.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -211,7 +215,7 @@ function showFilters() {
|
||||
showFilter.value = true
|
||||
}
|
||||
|
||||
onMounted(loadProducts)
|
||||
onMounted(() => loadProducts())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -82,6 +82,7 @@ import Spinner from '../../components/Spinner.vue'
|
||||
import IslandSheet from '../../components/ui/IslandSheet.vue'
|
||||
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
|
||||
import { api } from '../../api/client'
|
||||
import { KEYS, useQueryCache } from '../../stores/queryCache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -112,6 +113,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 = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
@@ -250,12 +250,15 @@ import IslandSheet from '../../components/ui/IslandSheet.vue'
|
||||
import { price } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
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
|
||||
@@ -300,30 +303,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, mirroring HomeView's choreography exactly (module-scope
|
||||
// flag so it only replays on a full app reload, not on every tab switch).
|
||||
let hasRevealed = false
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user