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
+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">