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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user