feat: paginate customer orders and polish the dashboard hero

Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-17 13:11:42 +03:30
co-authored by Cursor
parent f550172567
commit afb068c643
89 changed files with 4518 additions and 1325 deletions
+9 -10
View File
@@ -27,16 +27,12 @@
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<PageBar title="سبد خرید" :back="false" />
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
@@ -62,7 +58,7 @@
دیدن محصولات
</button>
</section>
</PullRefresh>
</AppPullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
@@ -141,7 +137,7 @@
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
@@ -244,8 +240,11 @@ const submitOrder = async () => {
}
async function onRefresh() {
await cart.init(true)
refreshing.value = false
try {
await cart.init(true)
} finally {
refreshing.value = false
}
}
onMounted(async () => {
+33 -33
View File
@@ -27,12 +27,7 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<HeroCarousel
v-if="slides.length"
@@ -74,7 +69,7 @@
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
</template>
@@ -82,7 +77,7 @@
import { brand } from '../brand'
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
@@ -94,7 +89,7 @@ import ProductCarousel from '../components/home/ProductCarousel.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -178,15 +173,18 @@ function goNewest() {
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
(async () => {
if (force) catalog.invalidate()
const page = await api.listProductsPaged({
page: 1,
limit: Math.max(REEL_LIMIT, LIST_LIMIT),
sort: 'newest',
})
catalog.replace(page.rows)
})(),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
@@ -201,8 +199,13 @@ async function loadData(force = false) {
}
async function onRefresh() {
await loadData(true)
refreshing.value = false
try {
await loadData(true)
} finally {
// Always release Vant's PullRefresh, even when a request fails — otherwise
// the spinner sticks until a full reload (same bug Dashboard already fixed).
refreshing.value = false
}
}
onMounted(async () => {
@@ -252,25 +255,22 @@ onActivated(async () => {
min-height: 100vh;
}
// Same chrome as components/ui/PageBar.vue — one look for every top bar.
.home__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
// A hairline rule cut a hard line across the page. A very soft fade under
// the bar separates it from the content that scrolls beneath without
// drawing an edge.
border-bottom: 1px solid alpha(@wine, 5%);
box-shadow: 0 8px 20px -18px alpha(@wine, 55%);
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -278,15 +278,15 @@ onActivated(async () => {
}
.home__logo {
// Sized to match the pill's height so the two sides of the bar carry equal
// optical weight instead of the logo looking stranded next to it.
height: 25px;
// Tall enough to read at a glance; still fits the bar next to RateStrip.
height: 36px;
width: auto;
object-fit: contain;
}
.home__body {
// Clears the fixed bar (~50px) plus a little breathing room.
padding-top: 58px;
// Clears the fixed bar (same offset as PageBar / CartView).
padding-top: 76px;
padding-bottom: 90px;
}
+7 -32
View File
@@ -1,12 +1,8 @@
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
redesigned design language.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
STUB-03 step 4: real server paging via useProducts / listProductsPaged.
-->
<template>
<div class="newest">
@@ -24,12 +20,9 @@
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
<ProductCard v-for="item in productsData.productList" :key="item.id" :value="item" />
</div>
<!-- Previously this view rendered its heading over blank space when
the catalogue came back empty. Same empty state the other
catalogue screens use, chained to the results above it. -->
<section v-else-if="!loadingPage" class="newest__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
@@ -41,7 +34,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import { onBeforeMount, ref } from 'vue'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
@@ -50,35 +43,18 @@ import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
const { productsData, getProducts, hasMore } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
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. 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
}
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
if (loadingPull.value || !hasMore.value) return
await getProducts(loadingPull, true)
}
</script>
@@ -103,7 +79,6 @@ const pull = async () => {
gap: 14px;
}
// Same empty state the other catalogue screens draw.
.newest__empty {
margin-top: 8px;
padding: 34px 20px;
@@ -36,11 +36,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cho__body">
<template v-if="rows.length">
<div class="cho__list">
@@ -99,7 +95,7 @@
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
<div ref="sentinel" class="cho__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -146,7 +142,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -209,8 +205,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertChildOrder) {
@@ -40,11 +40,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cch__body">
<div v-if="rows.length" class="cch__list">
<article
@@ -88,7 +84,7 @@
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -143,7 +139,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -196,8 +192,11 @@ async function readSearch(value: string | undefined) {
}
async function onRefresh() {
await load()
refreshing.value = false
try {
await load()
} finally {
refreshing.value = false
}
}
function openDetail(item: ExpertCustomer) {
@@ -52,11 +52,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="rep__body">
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
<template v-if="activeKey === '1'">
@@ -124,7 +120,7 @@
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
<div ref="sentinel" class="rep__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -151,7 +147,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -243,8 +239,11 @@ async function changeTab(key: '1' | '2') {
}
async function onRefresh() {
await reload()
refreshing.value = false
try {
await reload()
} finally {
refreshing.value = false
}
}
/** The «جزئیات» link — the reference opened the same card in a modal. */
@@ -1,15 +1,9 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language (see components/home/* and HomeView).
into the redesigned design language.
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
STUB-03 step 4: products for this collection come from
GET /catalog/products?collectionId=&page=&limit= — not a full-catalogue scan.
-->
<template>
<div class="coll">
@@ -19,11 +13,15 @@
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
<div v-if="rows.length" class="coll__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<section v-else-if="!loadingPage" class="coll__empty">
<div v-if="hasMore || loadingMore" ref="sentinel" class="coll__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage && !rows.length" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
@@ -33,37 +31,51 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
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 { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const route = useRoute()
const catalog = useCatalogStore()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const loadingPage = ref(false)
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.
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionId = computed(() => String(route.params.id ?? ''))
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function query() {
return { collectionId: collectionId.value, sort: 'newest' as const }
}
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(query())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
onMounted(async () => {
loadingPage.value = !catalog.loaded
try {
await catalog.load()
await resetAndLoad(query())
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
})
</script>
@@ -85,6 +97,12 @@ onMounted(async () => {
gap: 14px;
}
.coll__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
+84 -110
View File
@@ -14,7 +14,7 @@
-->
<template>
<div class="search">
<PageBar title="جستجو">
<PageBar title="جستجو" :back="false">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
@@ -54,155 +54,92 @@
</PageBar>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="search__body">
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="search__body">
<div v-if="rows.length" class="search__grid">
<ProductCard v-for="item in rows" :key="item.id" :value="item" />
</div>
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<div v-if="hasMore || loadingMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<!-- Keyed off the result count, NOT chained to the sentinel above it.
As a `v-else-if` on `hasMore` this rendered whenever the window had
caught up with the list — i.e. at the end of every fully-rendered
result set — so "nothing found" sat under a screen full of
products. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
<section v-if="!rows.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</AppPullRefresh>
</Spinner>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
:facets="facets"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type CatalogFacets, type ProductQuery } from '../../api/client'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCatalogStore } from '../../stores/catalogStore'
import { usePagedProducts } from '../../composables/usePagedProducts'
const filters = useFiltersStore()
const catalog = useCatalogStore()
const { rows, loading, loadingMore, hasMore, resetAndLoad, loadMore } = usePagedProducts()
const loadingPage = ref(false)
const loadingPage = computed(() => loading.value && rows.value.length === 0)
const refreshing = ref(false)
const allProducts = computed(() => catalog.products)
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const facets = ref<CatalogFacets | null>(null)
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
const availableCategories = computed(() => facets.value?.categories ?? [])
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
function toServerQuery(): ProductQuery {
const q = filters.query
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
const out: ProductQuery = { sort: 'newest' }
if (q.text?.trim()) out.search = q.text.trim()
if (q.cats?.[0]) out.categoryId = q.cats[0]
if (q.brands.length) out.brandId = q.brands.join(',')
if (q.limit_gram.length === 2) {
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
out.minGram = q.limit_gram[0]
out.maxGram = q.limit_gram[1]
}
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
return out
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts(force = false) {
// Cached data renders at once; the spinner is only for a cold start.
loadingPage.value = !catalog.loaded
async function loadFacets() {
try {
await catalog.load(force)
facets.value = await api.getCatalogFacets()
} catch (e) {
console.log(e)
}
}
async function reload(opts: { preserveRows?: boolean } = {}) {
try {
await resetAndLoad(toServerQuery(), opts)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts(true)
refreshing.value = false
try {
await Promise.all([loadFacets(), reload({ preserveRows: true })])
} finally {
refreshing.value = false
}
}
function search(value: string | undefined) {
@@ -215,7 +152,44 @@ function showFilters() {
showFilter.value = true
}
onMounted(() => loadProducts())
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
void loadMore(toServerQuery())
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
let textTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => filters.query.text,
() => {
clearTimeout(textTimer)
textTimer = setTimeout(() => void reload(), 300)
},
)
watch(
() => [filters.query.cats, filters.query.brands, filters.query.limit_gram] as const,
() => void reload(),
{ deep: true },
)
onMounted(async () => {
await loadFacets()
await reload()
})
</script>
<style scoped lang="less">
+273 -175
View File
@@ -46,9 +46,8 @@
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__brand">میز کار</span>
<PageBar title="میز کار" :back="false">
<template #trailing>
<button
v-if="session.isLoggedIn"
type="button"
@@ -58,20 +57,16 @@
>
<Icon icon="vuesax-linear:logout" size="18" />
</button>
</div>
</header>
<span v-else class="dash__exitSpacer" aria-hidden="true" />
</template>
</PageBar>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id, quick actions
built into the same dark surface as the balance itself. -->
<section v-if="customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<div class="dash__heroTop">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customer/me. Auth is the token —
never treat a slow/failed profile fetch as a guest session. -->
<section v-if="session.isLoggedIn && customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<header class="dash__heroHead">
<div class="dash__heroIdentity">
<span class="dash__heroName">{{ customer.fullName }}</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
@@ -79,27 +74,49 @@
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
</header>
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ grams }}</span>
<span class="dash__heroUnit">گرم {{ remainText }}</span>
<div class="dash__heroBalance">
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ gramsLabel }}</span>
<span class="dash__heroUnit">گرم</span>
</div>
<span class="dash__heroEyebrow">موجودی طلا</span>
</div>
<div class="dash__heroDebt">
<span>بدهی ریالی</span>
<span class="dash__heroDebtLabel">بدهی ریالی</span>
<b>{{ price(customer.fiatDebtRial) }} <i>ریال</i></b>
</div>
</section>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite dash__reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="22" color="#fff" />
</div>
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
<section
v-else-if="session.isLoggedIn"
class="dash__hero dash__hero--pending dash__reveal"
:style="revealDelay(0)"
>
<header class="dash__heroHead">
<div class="dash__heroIdentity">
<span class="dash__heroName">حساب شما</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
</div>
</header>
<p class="dash__heroPending">
{{ profileError ? 'موجودی الان در دسترس نیست. دوباره تلاش کنید.' : 'در حال دریافت موجودی…' }}
</p>
<button
v-if="profileError"
type="button"
class="dash__heroRetry"
@click="loadDashboard"
>
تلاش دوباره
</button>
</section>
<!-- Signed out: same card shell as the logged-in hero; logo + CTA only. -->
<section v-else class="dash__hero dash__invite dash__reveal" :style="revealDelay(0)">
<img class="dash__inviteLogo" src="/img/brand-logo.png" :alt="brand.name" />
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
@@ -114,9 +131,10 @@
</span>
<span class="dash__rowText">
<b>سفارشات</b>
<small>پیگیری سفارش‌های شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(1)">
@@ -125,9 +143,10 @@
</span>
<span class="dash__rowText">
<b>اسناد</b>
<small>تراکنش‌ها و رسیدهای حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
@@ -136,9 +155,10 @@
</span>
<span class="dash__rowText">
<b>پروفایل</b>
<small>اطلاعات و تنظیمات حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(3)">
@@ -147,9 +167,10 @@
</span>
<span class="dash__rowText">
<b>علاقمندی</b>
<small>محصولات ذخیره‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!--
D24 — the account statement. The reference put it on the dashboard
@@ -163,9 +184,10 @@
</span>
<span class="dash__rowText">
<b>گزارش</b>
<small>گردش حساب طلایی و ریالی شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!-- Appearance (theme + reduced motion). Device-level preferences, not
@@ -176,9 +198,10 @@
</span>
<span class="dash__rowText">
<b>تنظیمات ظاهری</b>
<small>پوسته روشن و تیره، انیمیشن‌ها</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!--
@@ -195,9 +218,10 @@
</span>
<span class="dash__rowText">
<b>سفارش مشتریان</b>
<small>سفارش‌های مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(5)">
@@ -206,9 +230,10 @@
</span>
<span class="dash__rowText">
<b>مشتریان من</b>
<small>فهرست مشتریان معرفی‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
</template>
</section>
@@ -216,7 +241,7 @@
<!-- Live 18k rate — a single inline row, not a titled section. -->
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
</container>
</PullRefresh>
</AppPullRefresh>
<IslandSheet
v-model:show="data.notify"
@@ -244,8 +269,9 @@
import { brand } from '../../brand'
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
@@ -261,15 +287,20 @@ const reads = useCachedReads()
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)
const profileError = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
// screen so the two never disagree.
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
const remainText = computed(() => (isDebtor.value ? 'بدهکار' : 'بستانکار'))
const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
const gramsLabel = computed(() => {
const n = grams.value
if (!Number.isFinite(n)) return '۰'
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
})
const data = reactive({ notify: false, loginNotify: false })
@@ -314,6 +345,7 @@ async function loadDashboard() {
// 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.
profileError.value = false
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.
@@ -325,14 +357,22 @@ async function loadDashboard() {
// to control, so it is gone.
reads.goldPrice({ force: true }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
if (me.status === 'fulfilled') {
customer.value = me.value
profileError.value = false
} else {
profileError.value = session.isLoggedIn && !customer.value
console.log(me.reason)
}
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
try {
await loadDashboard()
} finally {
refreshing.value = false
}
}
let activatedOnce = false
@@ -369,43 +409,15 @@ onMounted(async () => {
min-height: 100vh;
}
// A bare bar, sized like Home's, but with no border/title-bar chrome — the
// hero card below carries the page's identity instead of a header label.
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 82%);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid alpha(@wine, 5%);
box-shadow: 0 8px 20px -18px alpha(@wine, 55%);
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__brand {
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
// Same footprint as PageBar's `.bar__btn` (cart / search / …) so the trailing
// logout doesn't shrink the bar relative to the rest of the app.
.dash__exit {
flex: 0 0 auto;
width: 34px;
height: 34px;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 11px;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
@@ -413,11 +425,18 @@ onMounted(async () => {
transition: transform 0.18s ease;
}
.dash__exit:active {
transform: scale(0.92);
transform: scale(0.94);
}
.dash__exitSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 58px;
// Clears the shared PageBar (same offset as CartView).
padding-top: 76px;
padding-bottom: 90px;
}
@@ -446,22 +465,55 @@ onMounted(async () => {
}
// ── Hero ────────────────────────────────────────────────────────────────
// One full surface: identity, balance and quick actions all on the same
// ink → wine gradient, instead of a dark card handing off to a separate
// white grid underneath it.
// Same cool neutrals as the rest of the workbench (canvasSoft / gray-*), so
// labels and nested panels don't sit on a warm cream that fights the page.
.dash__hero {
margin-top: 16px;
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5A0027 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(124, 0, 53, 0.32);
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
margin-top: 12px;
margin-bottom: 14px;
padding: 16px 16px 14px;
/* Fixed footprint so guest / pending / signed-in cards stay the same size. */
height: 194px;
box-sizing: border-box;
border-radius: 22px;
color: @ink;
/* Tailwind gray-50 */
background: #f9fafb;
border: 1px solid @hairline;
box-shadow: none;
overflow: hidden;
}
.dash__heroTop {
.dash__hero::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
opacity: 1;
/* Soft lines on gray-50 — barely-there gray-200 */
background-image: repeating-linear-gradient(
-28deg,
transparent 0,
transparent 10px,
rgba(229, 231, 235, 0.22) 10px,
rgba(229, 231, 235, 0.22) 11px
);
}
.dash__hero > * {
position: relative;
z-index: 1;
}
.dash__heroHead {
display: flex;
align-items: flex-start;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@@ -469,154 +521,198 @@ onMounted(async () => {
.dash__heroIdentity {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
min-width: 0;
}
.dash__heroName {
font-size: 13.5px;
font-weight: 800;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__heroMobile {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
color: @inkFaint;
direction: ltr;
text-align: right;
}
.dash__heroSeal {
flex: 0 0 auto;
padding: 5px 12px;
padding: 6px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
.dash__heroSeal.is-debtor {
color: #fff;
background: @wineSoft;
color: @wine;
background: @wineWash;
}
.dash__heroSeal.is-creditor {
color: #14161A;
background: rgba(255, 255, 255, 0.9);
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
}
.dash__heroBalance {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 0;
text-align: center;
}
.dash__heroEyebrow {
display: block;
margin-top: 6px;
font-size: 11px;
font-weight: 500;
color: @inkFaint;
}
.dash__heroFigure {
margin-top: 20px;
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
}
.dash__heroNum {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.5px;
font-size: 38px;
font-weight: 500;
letter-spacing: -0.6px;
line-height: 1.05;
color: @ink;
font-variant-numeric: tabular-nums;
}
.dash__heroUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
font-size: 12.5px;
font-weight: 500;
color: @inkFaint;
}
.dash__heroDebt {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
margin-top: 0;
padding: 11px 13px;
border-radius: 14px;
background: @canvas;
border: 1px solid @hairline;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebtLabel {
color: @inkFaint;
font-weight: 500;
}
.dash__heroDebt b {
font-size: 13px;
font-weight: 700;
color: #fff;
font-weight: 500;
color: @ink;
font-variant-numeric: tabular-nums;
}
.dash__heroDebt i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
font-weight: 400;
color: @inkFaint;
padding-inline-start: 3px;
}
// ── Signed-out invite ──────────────────────────────────────────────────
.dash__invite {
margin-top: 16px;
padding: 26px 20px;
border-radius: 26px;
background: @heroSurface;
color: #fff;
.dash__heroPending {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: @inkFaint;
text-align: center;
gap: 10px;
}
.dash__inviteIcon {
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.1);
margin-bottom: 2px;
}
.dash__invite h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.dash__invite p {
margin: 0;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.6);
max-width: 240px;
}
.dash__inviteCta {
margin-top: 6px;
padding: 10px 22px;
.dash__heroRetry {
flex: 0 0 auto;
align-self: center;
margin-top: 0;
padding: 9px 16px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: #14161A;
background: #fff;
font-weight: 500;
color: #fff;
background: @wine;
}
// ── Signed-out invite (same footprint as logged-in .dash__hero) ────────
.dash__inviteLogo {
display: block;
flex: 1 1 auto;
height: 88px;
max-height: 88px;
width: auto;
object-fit: contain;
margin: auto;
}
.dash__inviteCta {
display: block;
flex: 0 0 auto;
width: 100%;
/* Mirror .dash__heroDebt so the bottom band matches after login. */
margin-top: 0;
padding: 11px 13px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
color: #fff;
background: @wine;
transition: transform 0.18s ease;
}
.dash__inviteCta:active {
transform: scale(0.96);
transform: scale(0.98);
}
// ── Settings-style rows ────────────────────────────────────────────────
// Same 16px gutter as <container>, equal inline padding so the leading icon
// tile and the trailing chevron sit the same distance from the card edges.
.dash__rows {
margin-top: 0;
display: flex;
flex-direction: column;
gap: 9px;
gap: 8px;
}
.dash__row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
width: 100%;
padding: 12px 14px;
border: 1px solid @hairline;
border-radius: 17px;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
text-align: right;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease, border-color 0.18s ease;
}
.dash__row:active {
@@ -626,9 +722,9 @@ onMounted(async () => {
.dash__rowIcon {
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 12px;
width: 36px;
height: 36px;
border-radius: 11px;
display: grid;
place-items: center;
color: @wine;
@@ -638,22 +734,24 @@ onMounted(async () => {
.dash__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dash__rowText b {
font-size: 12.5px;
font-weight: 700;
font-weight: 600;
color: @ink;
}
.dash__rowText small {
font-size: 10.5px;
.dash__rowChevron {
flex: 0 0 auto;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: @inkFaint;
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 14px;
margin-top: 12px;
}
</style>
+31 -17
View File
@@ -23,9 +23,7 @@
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<img class="auth__logo" src="/img/brand-logo.png" :alt="brand.name" />
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
@@ -58,7 +56,15 @@
label="تلفن همراه"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
>
<Input type="number" v-model:value="formModel.username" />
<Input
:value="formModel.username"
type="tel"
inputmode="numeric"
:maxlength="11"
placeholder="09123456789"
autocomplete="tel"
@update:value="(v: string) => (formModel.username = digits(v))"
/>
</FormItem>
<button
@@ -85,7 +91,6 @@ import { brand } from '../../brand'
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
@@ -105,6 +110,15 @@ const body = reactive({
defaultFocus: false,
})
/** Digits only, Persian/Arabic numerals normalised, capped at 11 (09xxxxxxxxx). */
function digits(v: string): string {
return (v ?? '')
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/\D/g, '')
.slice(0, 11)
}
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
@@ -137,9 +151,12 @@ const sendPhone = async () => {
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
body.defaultFocus = true
// After the OTP boxes mount (and after the submit button releases focus),
// put the caret in the first digit field.
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('.auth__otp .otp-input')?.focus()
} catch (e: unknown) {
// Both of these carry a Persian, user-facing `message` from the server, so
// show it instead of a generic one:
@@ -236,18 +253,15 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
padding-bottom: 24px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
.auth__logo {
display: block;
height: 72px;
width: auto;
object-fit: contain;
}
.auth__title {
margin: 14px 0 0;
margin: 16px 0 0;
font-size: 16px;
font-weight: 800;
color: @ink;
+505 -126
View File
@@ -8,10 +8,9 @@
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
They are chips now instead of ant Tabs, which lets them scroll
horizontally like every other filter rail in the redesign.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by chip (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Fetches paginated order history via GET /customer/me/orders (page /
status / q). Infinite-scroll like ReportView; filters run server-side
so a status chip never misses rows on later pages.
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
@@ -26,7 +25,7 @@
-->
<template>
<div class="ord">
<PageBar title="سفارشات" back-to="home">
<PageBar title="سفارشات" :back="false">
<template #below>
<div class="ord__barExtra">
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
@@ -47,25 +46,30 @@
</template>
</PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="ord__body">
<template v-if="filteredOrders.length">
<Spinner :spinning="loading && !orders.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="orders.length">
<div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
<OrderCard v-for="item in orders" :key="item.id" :value="item" @view="openDetail" />
</div>
</template>
<section v-else-if="!loading" class="ord__empty">
<section v-else-if="!loading && !failed" class="ord__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
<section v-if="failed" class="ord__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت سفارش‌ها با خطا روبه‌رو شد.</p>
<button type="button" class="ord__retry" @click="reload">تلاش دوباره</button>
</section>
<div v-if="loading && orders.length" class="ord__more">در حال دریافت…</div>
<div ref="sentinel" class="ord__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -79,39 +83,31 @@
>
<div class="detail">
<header class="detail__bar">
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
<button type="button" class="detail__close" aria-label="بستن" @click="closeDetail">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<h2 class="detail__title">جزئیات سفارش</h2>
<span class="detail__spacer" aria-hidden="true" />
<button
type="button"
class="detail__trail"
aria-label="تاریخچه وضعیت"
@click="timelineOpen = true"
>
<Icon icon="vuesax-linear:clock-1" size="18" />
</button>
</header>
<div class="detail__scroll">
<!-- Progress -->
<section class="detail__block">
<div class="detail__steps">
<div
v-for="(step, index) in ORDER_STEPS"
:key="step"
class="detail__step"
:class="{ 'is-done': index <= currentStep, 'is-active': index === currentStep }"
>
<span class="detail__stepDot">{{ index + 1 }}</span>
<span class="detail__stepTitle">{{ step }}</span>
</div>
</div>
</section>
<!-- Headline facts -->
<!-- Headline facts — order path lives in the timeline sheet (header clock). -->
<section class="detail__block">
<div class="detail__headline">
<div>
<div class="detail__idBlock">
<div class="detail__eyebrow">شماره سفارش</div>
<div class="detail__number">{{ viewData.item.orderNumber }}</div>
</div>
<span
class="detail__status"
:style="{ background: modalColor?.background, color: modalColor?.font }"
:style="{ background: modalColor.background, color: modalColor.font }"
>
{{ modalStatusLabel }}
</span>
@@ -143,13 +139,82 @@
</div>
</div>
</Drawer>
<!-- Status history as an island action sheet (same pattern as cart note). -->
<Drawer
v-model:visible="timelineOpen"
placement="bottom"
:closable="false"
:zIndex="1100"
height="70%"
class="rtl island-sheet"
:bodyStyle="{ padding: 0 }"
>
<div class="tlSheet">
<header class="tlSheet__banner">
<span class="tlSheet__chip">وضعیت</span>
<span class="tlSheet__dash" aria-hidden="true" />
<span
v-if="viewData.item"
class="tlSheet__badge"
:style="{
background: modalColor.background,
color: modalColor.font,
}"
>
<Icon icon="vuesax-linear:tick-circle" size="14" />
{{ modalStatusLabel }}
</span>
</header>
<ol v-if="timelinePath.length" class="tlSheet__rail">
<li
v-for="(node, index) in timelinePath"
:key="node.status"
class="tlSheet__node"
:class="[`is-${node.state}`]"
>
<div class="tlSheet__axis" aria-hidden="true">
<span
v-if="node.state === 'current'"
class="tlSheet__halo"
:style="{ color: orderStatusColor(node.status).font }"
/>
<span class="tlSheet__marker" :style="markerStyle(node)">
<Icon :icon="orderStatusIcon(node.status)" size="16" />
</span>
<span
v-if="index < timelinePath.length - 1"
class="tlSheet__stem"
:style="node.state === 'pending' ? undefined : stemStyle(node.status)"
/>
</div>
<div class="tlSheet__main">
<div class="tlSheet__row">
<div class="tlSheet__copy">
<div class="tlSheet__status">{{ orderStatusLabel(node.status) }}</div>
<p class="tlSheet__blurb">{{ orderStatusBlurb(node.status) }}</p>
</div>
<time v-if="node.at" class="tlSheet__when" :datetime="node.at">
{{ jtimelineWhen(node.at) }}
</time>
<span v-else class="tlSheet__when is-wait">در انتظار</span>
</div>
</div>
</li>
</ol>
<p v-else class="tlSheet__empty">مسیری برای این سفارش ثبت نشده است.</p>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -157,19 +222,26 @@ import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import {
ORDER_STATUS_COLORS,
ORDER_PIPELINE,
ORDER_STATUS_LABELS,
ORDER_STEPS,
orderStepIndex,
orderStatusBlurb,
orderStatusColor,
orderStatusIcon,
orderStatusLabel,
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { jdate, jtimelineWhen } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
import { api, type OrderDetail } from '../../api/client'
import { useQueryCache, KEYS } from '../../stores/queryCache'
type TimelineNode = {
status: string
at: string | null
state: 'done' | 'current' | 'pending'
}
const session = useSessionStore()
const reads = useCachedReads()
const cache = useQueryCache()
const tabs = [
{ title: 'همه', key: 'ALL' },
@@ -184,81 +256,179 @@ const tabs = [
]
const activeTab = ref('ALL')
const orders = ref<OrderDetail[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
const failed = ref(false)
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
/** Bumped on every filter/reload so an in-flight page response is dropped. */
let fetchSeq = 0
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders(force = false) {
async function fetchPage() {
if (!session.isLoggedIn) {
allOrders.value = []
orders.value = []
hasMore.value = false
return
}
loading.value = reads.peek(KEYS.orders) === undefined
if (loading.value || !hasMore.value) return
const my = fetchSeq
loading.value = true
failed.value = false
try {
allOrders.value = await reads.myOrders({ force })
const data = await api.getMyOrders({
page: page.value,
status: activeTab.value,
q: searchTerm.value,
})
if (my !== fetchSeq) return
const batch = data.result ?? []
orders.value = page.value === 1 ? batch : [...orders.value, ...batch]
if (batch.length < (data.limit ?? 20)) hasMore.value = false
else page.value += 1
} catch (e) {
if (my !== fetchSeq) return
failed.value = true
hasMore.value = false
console.log(e)
} finally {
loading.value = false
if (my === fetchSeq) loading.value = false
}
}
function resetList() {
orders.value = []
page.value = 1
hasMore.value = true
failed.value = false
}
async function reload() {
fetchSeq += 1
loading.value = false
resetList()
await fetchPage()
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const timelineOpen = ref(false)
const modalStatusLabel = computed(() =>
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : '',
viewData.item ? orderStatusLabel(viewData.item.status) : '',
)
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
const modalColor = computed(() =>
viewData.item ? orderStatusColor(viewData.item.status) : orderStatusColor('PLACED'),
)
/** Pending stays grey. Reached steps colour the icon/border only — fill stays opaque so the halo never shows through. */
function markerStyle(node: TimelineNode) {
if (node.state === 'pending') return undefined
const c = orderStatusColor(node.status)
return { color: c.font, borderColor: c.font }
}
function stemStyle(status: string) {
return { background: orderStatusColor(status).font }
}
/**
* Full order path for the timeline sheet (replaces the old detail stepper).
* Past steps are dated from statusEvents; future ones stay «در انتظار».
*/
const timelinePath = computed((): TimelineNode[] => {
const item = viewData.item
if (!item) return []
const times = new Map<string, string>()
for (const ev of item.statusEvents ?? []) {
if (!times.has(ev.toStatus)) times.set(ev.toStatus, ev.createdAt)
}
if (!times.has(item.status)) times.set(item.status, item.createdAt)
if (item.status === 'SUSPENDED') {
return [
{ status: 'SUSPENDED', at: times.get('SUSPENDED') ?? item.createdAt, state: 'current' },
...ORDER_PIPELINE.map((status) => ({ status, at: null, state: 'pending' as const })),
]
}
if (item.status === 'CANCELLED') {
const prior = ORDER_PIPELINE.filter((status) => times.has(status)).map((status) => ({
status,
at: times.get(status) ?? null,
state: 'done' as const,
}))
return [
...prior,
{ status: 'CANCELLED', at: times.get('CANCELLED') ?? item.createdAt, state: 'current' },
]
}
const idx = (ORDER_PIPELINE as readonly string[]).indexOf(item.status)
const currentIdx = idx >= 0 ? idx : 0
return ORDER_PIPELINE.map((status, i) => ({
status,
at: times.get(status) ?? (i === currentIdx ? item.createdAt : null),
state: i < currentIdx ? 'done' : i === currentIdx ? 'current' : 'pending',
}))
})
function openDetail(item: OrderDetail) {
viewData.item = item
viewData.model = true
timelineOpen.value = false
}
function closeDetail() {
timelineOpen.value = false
viewData.model = false
}
async function onRefresh() {
await loadOrders(true)
refreshing.value = false
try {
cache.invalidate(KEYS.orders)
await reload()
} finally {
refreshing.value = false
}
}
watch([activeTab, searchTerm], () => {
void reload()
})
/**
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
* نمیخوره، هم دیتاها به‌روز میمونه».
*
* `force` matters because an order's status changes on the ADMIN side — nothing
* in this app invalidates the cache when that happens, so without it a list
* fetched once was shown unchanged for the rest of the session.
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened.
* Status changes on the ADMIN side, so each visit reloads from page 1.
*
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
* keep-alive list, so it is mounted once and every later visit only activates
* it. With `onMounted` alone the second visit made no request at all.
*
* The cached copy still renders instantly (`peek` above) and `loading` only
* shows a spinner when there is nothing to show, so the refresh is invisible.
*
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
* shape DashboardView uses.
* keep-alive list. `activatedOnce` guards the first visit where Vue runs both.
*/
onMounted(() => loadOrders(true))
onMounted(async () => {
await reload()
await nextTick()
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) void fetchPage()
})
if (sentinel.value) observer.observe(sentinel.value)
}
})
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void loadOrders(true)
if (activatedOnce) void reload()
activatedOnce = true
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped lang="less">
@@ -289,7 +459,7 @@ onActivated(() => {
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
@@ -335,6 +505,25 @@ onActivated(() => {
font-size: 12.5px;
}
.ord__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.ord__more,
.ord__sentinel {
padding: 12px 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
/* ── Detail drawer ─────────────────────────────────────────────────── */
.detail {
height: 100vh;
@@ -357,8 +546,8 @@ onActivated(() => {
.detail__title {
margin: 0;
font-size: 14px;
font-weight: 800;
font-size: 13px;
font-weight: 500;
color: @ink;
}
@@ -374,10 +563,20 @@ onActivated(() => {
background: @hairlineSoft;
}
.detail__spacer {
display: block;
.detail__trail {
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
}
.detail__trail:active {
transform: scale(0.96);
}
.detail__scroll {
@@ -393,63 +592,235 @@ onActivated(() => {
.detail__blockTitle {
margin: 0 0 11px;
font-size: 13px;
font-weight: 800;
font-size: 12.5px;
font-weight: 500;
color: @ink;
}
.detail__steps {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 4px;
.tlSheet {
padding: 4px 18px 22px;
max-height: 100%;
overflow-y: auto;
}
.detail__step {
flex: 1 0 74px;
.tlSheet__banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.tlSheet__chip {
flex: 0 0 auto;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
font-size: 11px;
font-weight: 500;
color: @inkSoft;
}
.tlSheet__dash {
flex: 1 1 auto;
height: 0;
border-top: 1.5px dashed @hairline;
min-width: 16px;
}
.tlSheet__badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.tlSheet__rail {
list-style: none;
margin: 0;
padding: 0 2px;
}
.tlSheet__node {
display: flex;
gap: 12px;
align-items: stretch;
min-height: 64px;
}
.tlSheet__axis {
flex: 0 0 40px;
width: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
position: relative;
}
.detail__stepDot {
width: 28px;
height: 28px;
/* Soft shadow-like halo — sits behind the opaque marker disc. */
.tlSheet__halo {
position: absolute;
top: 0;
left: 50%;
width: 40px;
height: 40px;
margin-left: -20px;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.tlSheet__halo::before {
content: '';
position: absolute;
inset: -14px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, currentColor 32%, transparent) 0%,
color-mix(in srgb, currentColor 12%, transparent) 38%,
transparent 70%
);
filter: blur(1.5px);
opacity: 0;
animation: tlSheetHalo 4.8s ease-in-out infinite;
}
@keyframes tlSheetHalo {
0%,
100% {
opacity: 0;
}
50% {
opacity: 0.7;
}
}
@media (prefers-reduced-motion: reduce) {
.tlSheet__halo::before {
animation: none;
opacity: 0.35;
}
}
.tlSheet__marker {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid currentColor;
background: @canvas;
display: grid;
place-items: center;
font-size: 11.5px;
font-weight: 700;
color: @inkFaint;
background: @hairlineSoft;
border: 1px solid @hairline;
position: relative;
z-index: 1;
}
.detail__step.is-done .detail__stepDot {
color: #fff;
background: @wine;
border-color: @wine;
.tlSheet__node.is-pending .tlSheet__marker {
/* Tailwind gray-400 — clearer than hairline for inactive dashed rings */
color: #9ca3af;
background: @canvas;
border-color: #9ca3af;
border-style: dashed;
}
.detail__step.is-active .detail__stepDot {
color: #fff;
background: @wineDeep;
border-color: @wineDeep;
.tlSheet__node.is-done .tlSheet__marker,
.tlSheet__node.is-current .tlSheet__marker {
background: @canvas;
}
.detail__stepTitle {
font-size: 10px;
line-height: 1.5;
.tlSheet__node.is-pending .tlSheet__status,
.tlSheet__node.is-pending .tlSheet__blurb,
.tlSheet__node.is-pending .tlSheet__when {
color: @inkFaint;
}
.detail__step.is-done .detail__stepTitle {
color: @inkSoft;
font-weight: 600;
.tlSheet__stem {
flex: 1 1 auto;
width: 1.5px;
margin: 4px 0;
background: @hairline;
min-height: 16px;
}
.tlSheet__node.is-pending .tlSheet__stem {
background: @hairlineSoft;
border-inline-start: 0;
background-image: repeating-linear-gradient(
180deg,
@hairline 0 4px,
transparent 4px 8px
);
width: 2px;
}
.tlSheet__main {
flex: 1;
min-width: 0;
padding-top: 4px;
padding-bottom: 18px;
}
.tlSheet__node:last-child .tlSheet__main {
padding-bottom: 4px;
}
.tlSheet__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.tlSheet__copy {
min-width: 0;
flex: 1;
}
.tlSheet__status {
font-size: 13px;
font-weight: 500;
color: @ink;
line-height: 1.35;
}
.tlSheet__blurb {
margin: 3px 0 0;
font-size: 11.5px;
font-weight: 500;
line-height: 1.45;
color: @inkFaint;
}
.tlSheet__when {
flex: 0 0 auto;
padding-top: 2px;
font-size: 11px;
font-weight: 500;
color: @inkFaint;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tlSheet__when.is-wait {
font-weight: 500;
opacity: 0.85;
}
.tlSheet__empty {
margin: 4px 0 0;
padding: 20px 12px;
font-size: 12px;
color: @inkFaint;
text-align: center;
border: 1px dashed @hairline;
border-radius: 14px;
}
.detail__headline {
@@ -460,24 +831,32 @@ onActivated(() => {
padding-bottom: 14px;
}
.detail__idBlock {
min-width: 0;
flex: 1;
}
.detail__eyebrow {
font-size: 11px;
color: @inkFaint;
}
.detail__number {
padding-top: 4px;
font-size: 18px;
font-weight: 800;
color: @ink;
padding-top: 3px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: @inkSoft;
word-break: break-all;
}
.detail__status {
flex: 0 0 auto;
align-self: flex-start;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-weight: 500;
white-space: nowrap;
}
@@ -510,7 +889,7 @@ onActivated(() => {
.detail__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
font-weight: 500;
color: @ink;
}