Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted routes; these are the five callers that were fetching all ~16k products to find a handful of rows: product screen whole catalogue, then find(id) -> GET /products/:id cart re-sync whole catalogue for 2-3 lines -> ?ids= favourites whole catalogue, filtered here -> ?ids= home categories categories derived from it -> /products/facets order row labels joined on variantId -> included with the order Three of these were not merely slow, they were going to be wrong. Favourites filtered the loaded catalogue, so once the list pages a favourite outside the loaded slice silently disappears. Home categories were derived the same way, so a category whose products all sit further down would vanish from the home screen. Order rows looked their title up in the catalogue, which ALREADY failed for a product that had since left the storefront — exactly the old orders history exists for — and rendered a blank row. Order labels went to the server rather than to another id lookup: the item carries productVariantId, not a product id, so ?ids= could not answer it. One nested include in getOrders returns the variant, its product and the product's first image. Deliberately NOT filtered by storefront visibility, so a de-listed product still shows what was bought; asserted in the new spec. OrderItemCard is synchronous now — no fetch, no onMounted, the row renders complete on its first frame. variantLookup is deleted from catalogStore rather than left behind: it existed only to label order rows, and keeping it would have kept a reason to load the full catalogue. getProductsByIds chunks at 100 because the route caps ids there and drops the rest. A partial answer reads to callers as "these products are no longer on the storefront" — the cart would silently drop valid lines. Favourites already run to 874 rows in the imported data (FAV-01). A failed cart re-sync now leaves the cart alone: it is the person's own data in localStorage, and dropping lines because the network blinked is worse than a slightly stale price. The product screen tells a real 404 from a failed request, so only the former renders the not-found page. One bug introduced and caught while doing this: moving the home categories to /facets meant memory.init() no longer loaded the catalogue as a side effect, and the home rails read catalog.products — the home screen would have rendered empty rails. HomeView now loads it explicitly. Found by grepping every caller of configStore.init, which is also how I know HomeView is the only one. Still on the unpaged list by design: the home rails and the search/newest lists. Moving those onto paged reads is step 4, kept separate because it is the risky part — debounce, loading state, and telling "no results" from "still loading". api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a spec now (3 cases) because its include is a contract both apps depend on and the method had no test at all. Nothing click-tested — the cart re-sync and the favourites screen are the two worth a manual pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
14 KiB
Vue
450 lines
14 KiB
Vue
<!--
|
||
Redesigned home screen (replaces the previous port of legacy home/index.vue).
|
||
|
||
Layout intent — shop first, account second:
|
||
1. a slim brand bar with search + cart
|
||
2. the live 18k rate strip (real: GET /pricing/latest)
|
||
3. an editorial showcase hero built from real categories
|
||
4. the category rail (real: derived from GET /catalog/products)
|
||
5. "برگزیده" — a wide feature card + a two-column grid
|
||
6. "تازهها" — a horizontal rail
|
||
7. the account block (real: GET /customers/:id when signed in)
|
||
|
||
Data honesty, carried over from the previous version deliberately:
|
||
- the API exposes no curated home payload, no promotions and no per-product
|
||
price, so nothing on this page prints a rial price for a piece. Weight and
|
||
making-charge percent are real per-variant fields; everything else shown
|
||
is a label, not invented data.
|
||
- categories come from the catalogue itself, so an empty catalogue renders
|
||
an empty state instead of placeholder tiles.
|
||
-->
|
||
<template>
|
||
<div class="home">
|
||
<header class="home__bar">
|
||
<div class="home__barInner">
|
||
<router-link :to="{ name: 'cart' }" class="home__iconBtn" aria-label="سبد خرید">
|
||
<Icon icon="vuesax-linear:bag-2" size="21" />
|
||
<span v-if="cart.items.length" class="home__badge">{{ cart.items.length }}</span>
|
||
</router-link>
|
||
|
||
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
|
||
|
||
<router-link :to="{ name: 'search' }" class="home__iconBtn" aria-label="جستجو">
|
||
<Icon icon="vuesax-linear:search-normal-1" size="21" />
|
||
</router-link>
|
||
</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>
|
||
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
||
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
|
||
|
||
<ShowcaseSlider
|
||
v-if="slides.length"
|
||
:slides="slides"
|
||
class="home__hero reveal"
|
||
:style="revealDelay(1)"
|
||
@select="onSlide"
|
||
/>
|
||
|
||
<CategoryRail
|
||
v-if="memory.cats.length"
|
||
:categories="memory.cats"
|
||
:active-id="null"
|
||
class="home__rail reveal"
|
||
:style="revealDelay(2)"
|
||
@select="onCategory"
|
||
/>
|
||
|
||
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
|
||
<SectionHead
|
||
title="برگزیده منتزر"
|
||
subtitle="انتخابشده از میان مجموعههای موجود"
|
||
action-label="همه محصولات"
|
||
@action="goSearch"
|
||
/>
|
||
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" />
|
||
|
||
<div v-if="gridProducts.length" class="home__grid">
|
||
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" />
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="newestProducts.length" class="home__section reveal" :style="revealDelay(4)">
|
||
<SectionHead
|
||
title="تازهها"
|
||
subtitle="آخرین افزودههای مجموعه"
|
||
action-label="مشاهده همه"
|
||
@action="goNewest"
|
||
/>
|
||
<div class="home__reel no-scrollbar">
|
||
<ShowcaseCard
|
||
v-for="item in newestProducts"
|
||
:key="item.id"
|
||
:value="item"
|
||
class="home__reelItem"
|
||
@open="openProduct"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="!loading && !products.length" class="home__empty reveal" :style="revealDelay(3)">
|
||
<Icon icon="vuesax-linear:box" size="26" />
|
||
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
|
||
</section>
|
||
|
||
<div class="home__trust reveal" :style="revealDelay(5)">
|
||
<div class="home__trustItem">
|
||
<Icon icon="vuesax-linear:shield-tick" size="18" />
|
||
<span>خرید امن</span>
|
||
</div>
|
||
<div class="home__trustItem">
|
||
<Icon icon="vuesax-linear:verify" size="18" />
|
||
<span>اصالت کالا</span>
|
||
</div>
|
||
<div class="home__trustItem">
|
||
<Icon icon="vuesax-linear:box-tick" size="18" />
|
||
<span>ارسال بیمهشده</span>
|
||
</div>
|
||
</div>
|
||
</container>
|
||
</PullRefresh>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { Loading, PullRefresh } from 'vant'
|
||
import Icon from '../components/icon/Icon.vue'
|
||
import { openProduct as openProductMorph } from '../composables/useProductMorph'
|
||
import RateStrip from '../components/home/RateStrip.vue'
|
||
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
|
||
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
||
import CategoryRail from '../components/home/CategoryRail.vue'
|
||
import ShowcaseCard from '../components/home/ShowcaseCard.vue'
|
||
import SectionHead from '../components/home/SectionHead.vue'
|
||
import { useConfigStore } from '../stores/configStore'
|
||
import type { HomeCategory } from '../stores/configStore'
|
||
import { useCartStore } from '../stores/cartStore'
|
||
import { useFiltersStore } from '../stores/filtersStore'
|
||
import { useReveal } from '../composables/useReveal'
|
||
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
|
||
import { useCachedReads } from '../api/cachedReads'
|
||
import { useCatalogStore } from '../stores/catalogStore'
|
||
import { KEYS } from '../stores/queryCache'
|
||
|
||
const router = useRouter()
|
||
const memory = useConfigStore()
|
||
const cart = useCartStore()
|
||
const filters = useFiltersStore()
|
||
const catalog = useCatalogStore()
|
||
const reads = useCachedReads()
|
||
|
||
const loading = ref(!catalog.loaded)
|
||
const refreshing = ref(false)
|
||
const products = computed(() => catalog.products)
|
||
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
|
||
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
|
||
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
|
||
// knows whether to wait or fall back to the category-derived hero.
|
||
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
|
||
|
||
|
||
// First-paint entrance: played once, after the first data load, so the
|
||
// sections settle into place instead of popping in piecemeal. Pull-to-refresh
|
||
// and later returns to this tab do not replay it.
|
||
const { revealed, revealDelay, reveal } = useReveal('home')
|
||
|
||
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
|
||
const gridProducts = computed(() => products.value.slice(1, 5))
|
||
const newestProducts = computed(() => products.value.slice(5, 13))
|
||
|
||
// The hero prefers the admin-uploaded banner (manage/Const.vue "اسلایدر
|
||
// موبایل") when one has been configured; otherwise it falls back to a hero
|
||
// built from REAL categories, since no curated banner payload existed before
|
||
// that admin screen was wired up. Each fallback slide deep-links into search
|
||
// filtered by that category, so the artwork is decorative but the
|
||
// destination is genuine.
|
||
const SLIDE_COPY = [
|
||
{ eyebrow: 'مجموعه منتزر', cta: 'دیدن مجموعه' },
|
||
{ eyebrow: 'انتخاب سردبیر', cta: 'مشاهده' },
|
||
{ eyebrow: 'پیشنهاد این هفته', cta: 'بررسی کنید' },
|
||
]
|
||
|
||
const slides = computed<ShowcaseSlide[]>(() => {
|
||
if (adminSlides.value?.length) {
|
||
return adminSlides.value
|
||
.filter((s) => s.image)
|
||
.map((s, i) => ({
|
||
key: `${i}-${s.image}`,
|
||
eyebrow: 'مجموعه منتزر',
|
||
title: '',
|
||
subtitle: '',
|
||
cta: '',
|
||
image: s.image,
|
||
link: s.link || undefined,
|
||
}))
|
||
}
|
||
|
||
const cats = memory.cats.slice(0, 3)
|
||
if (!cats.length) return []
|
||
return cats.map((cat, i) => ({
|
||
key: cat.id,
|
||
eyebrow: SLIDE_COPY[i % SLIDE_COPY.length].eyebrow,
|
||
title: cat.title,
|
||
subtitle: 'ساختهشده از طلای ۱۸ عیار، با اجرت شفاف و اصالت تضمینشده.',
|
||
cta: SLIDE_COPY[i % SLIDE_COPY.length].cta,
|
||
image: productImageForCategory(cat.id),
|
||
categoryId: cat.id,
|
||
}))
|
||
})
|
||
|
||
function productImageForCategory(categoryId: string): string | undefined {
|
||
const match = products.value.find((p) => p.category?.id === categoryId)
|
||
return match?.images?.[0]?.url
|
||
}
|
||
|
||
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
|
||
// App.vue) — the card is found from the tap, so the emit chain stays as is.
|
||
function openProduct(product: ProductSummary) {
|
||
openProductMorph(product)
|
||
}
|
||
|
||
function onCategory(cat: HomeCategory) {
|
||
filters.query.cats = [cat.id]
|
||
router.push({ name: 'search' })
|
||
}
|
||
|
||
function onSlide(slide: ShowcaseSlide) {
|
||
// Admin-uploaded slides (manage/Const.vue) carry their own destination
|
||
// instead of a categoryId — an absolute URL opens in a new tab, anything
|
||
// else is treated as an in-app path.
|
||
if (slide.link) {
|
||
if (/^https?:\/\//i.test(slide.link)) {
|
||
window.open(slide.link, '_blank', 'noopener')
|
||
} else {
|
||
router.push(slide.link)
|
||
}
|
||
return
|
||
}
|
||
if (slide.categoryId) {
|
||
filters.query.cats = [slide.categoryId]
|
||
}
|
||
router.push({ name: 'search' })
|
||
}
|
||
|
||
function goSearch() {
|
||
router.push({ name: 'search' })
|
||
}
|
||
function goNewest() {
|
||
router.push({ name: 'newestProduct' })
|
||
}
|
||
// 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, 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),
|
||
memory.init(force),
|
||
reads.goldPrice({ force }),
|
||
reads.homeSlider({ force }),
|
||
])
|
||
|
||
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
|
||
if (catsResult.status === 'rejected') console.log(catsResult.reason)
|
||
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
|
||
// No admin slides configured, or the endpoint failed — the category-derived
|
||
// hero below covers this silently, same as before this existed.
|
||
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
|
||
}
|
||
|
||
async function onRefresh() {
|
||
await loadData(true)
|
||
refreshing.value = false
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadData()
|
||
loading.value = false
|
||
reveal()
|
||
})
|
||
|
||
/**
|
||
* Owner ruling 2026-10-12: re-read on entering a screen rather than caching
|
||
* with a lifetime. This view is kept alive, so without this the gold price was
|
||
* fetched once per app launch and then never again until pull-to-refresh — on
|
||
* the screen that shows it most prominently.
|
||
*
|
||
* Only the PRICE is re-read, not the catalogue or the slides: the catalogue is
|
||
* the whole active product list in one response, and re-fetching it on every
|
||
* tab switch is exactly the needless load the ruling is meant to avoid. It
|
||
* changes when an admin edits it, not minute to minute, and pull-to-refresh and
|
||
* placing an order both already invalidate it.
|
||
*/
|
||
let activatedOnce = false
|
||
onActivated(async () => {
|
||
if (!activatedOnce) {
|
||
activatedOnce = true
|
||
return
|
||
}
|
||
try {
|
||
goldPrice.value = await reads.goldPrice({ force: true })
|
||
} catch (e) {
|
||
// Keep the figure already on screen rather than blanking it.
|
||
console.log(e)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.home {
|
||
background: @page;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.home__bar {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 20;
|
||
background: alpha(@canvas, 88%);
|
||
backdrop-filter: blur(14px);
|
||
border-bottom: 1px solid @hairline;
|
||
}
|
||
|
||
.home__barInner {
|
||
max-width: 768px;
|
||
margin: 0 auto;
|
||
padding: 11px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.home__logo {
|
||
height: 21px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.home__iconBtn {
|
||
position: relative;
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 12px;
|
||
display: grid;
|
||
place-items: center;
|
||
color: @ink;
|
||
background: @hairlineSoft;
|
||
transition: background 0.18s ease;
|
||
}
|
||
|
||
.home__badge {
|
||
position: absolute;
|
||
top: -3px;
|
||
left: -3px;
|
||
min-width: 17px;
|
||
height: 17px;
|
||
padding: 0 4px;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
background: @wine;
|
||
border: 2px solid @canvas;
|
||
}
|
||
|
||
.home__body {
|
||
padding-top: 62px;
|
||
padding-bottom: 90px;
|
||
}
|
||
|
||
.home__rate {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.home__hero {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.home__rail {
|
||
margin-top: 18px;
|
||
}
|
||
|
||
.home__section {
|
||
margin-top: 28px;
|
||
}
|
||
|
||
.home__grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 11px;
|
||
margin-top: 11px;
|
||
}
|
||
|
||
.home__reel {
|
||
display: flex;
|
||
gap: 11px;
|
||
overflow-x: auto;
|
||
margin: 0 -16px;
|
||
padding: 0 16px;
|
||
scroll-snap-type: x proximity;
|
||
}
|
||
|
||
.home__reelItem {
|
||
flex: 0 0 154px;
|
||
scroll-snap-align: start;
|
||
}
|
||
|
||
.home__empty {
|
||
margin-top: 28px;
|
||
padding: 34px 20px;
|
||
border: 1px dashed @hairline;
|
||
border-radius: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: @inkFaint;
|
||
}
|
||
.home__empty p {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
.home__trust {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 9px;
|
||
margin-top: 26px;
|
||
}
|
||
|
||
.home__trustItem {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 14px 6px;
|
||
border-radius: 14px;
|
||
background: @hairlineSoft;
|
||
color: @inkMuted;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|