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>
369 lines
13 KiB
Vue
369 lines
13 KiB
Vue
<!--
|
|
Home screen, built to the supplied design.
|
|
|
|
Layout, top to bottom:
|
|
1. a slim brand bar: the logo, and the live 18k rate strip beside it
|
|
(real: GET /pricing/latest). Search and cart are reachable from the
|
|
bottom navigation, so the bar carries the rate instead.
|
|
2. the coverflow hero (real categories, decorative artwork)
|
|
3. "دسته بندی ها" — the category rail
|
|
4. "محصولات جدید" — a peeking horizontal rail, then the vertical list
|
|
|
|
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. In particular the design's struck-through
|
|
"اجرت ۱۵٪" is not rendered — see ProductPanel's header.
|
|
- 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">
|
|
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
|
|
<RateStrip :quote="goldPrice" compact class="home__rate" />
|
|
</div>
|
|
</header>
|
|
|
|
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
|
<container class="home__body" :class="{ 'is-revealed': revealed }">
|
|
<HeroCarousel
|
|
v-if="slides.length"
|
|
:slides="slides"
|
|
class="home__hero home__reveal"
|
|
:style="revealDelay(0)"
|
|
/>
|
|
|
|
<section v-if="memory.cats.length" class="home__section home__reveal" :style="revealDelay(1)">
|
|
<h2 class="home__heading">دسته بندی ها</h2>
|
|
<CategoryGrid :categories="memory.cats" :active-id="null" @select="onCategory" />
|
|
</section>
|
|
|
|
<section v-if="newestProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
|
|
<h2 class="home__heading">محصولات جدید</h2>
|
|
|
|
<!--
|
|
The design's top row: a looping carousel whose centre card is
|
|
larger than the two peeking neighbours, ending in a "see all" card.
|
|
-->
|
|
<ProductCarousel :products="reelProducts" @open="openProduct" @all="goNewest" />
|
|
|
|
<!-- The vertical list below the rail, as in the design. "See all"
|
|
lives on the carousel's last card, so no trailing button here. -->
|
|
<div class="home__list">
|
|
<ProductPanel
|
|
v-for="(item, i) in listProducts"
|
|
:key="item.id"
|
|
:value="item"
|
|
class="home__reveal"
|
|
:style="revealDelay(3 + i)"
|
|
@open="openProduct"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="!loading && !products.length" class="home__empty">
|
|
<Icon icon="vuesax-linear:box" size="26" />
|
|
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
|
|
</section>
|
|
</container>
|
|
</AppPullRefresh>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { brand } from '../brand'
|
|
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
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'
|
|
import HeroCarousel from '../components/home/HeroCarousel.vue'
|
|
import type { HeroSlide } from '../components/home/HeroCarousel.vue'
|
|
import CategoryGrid from '../components/home/CategoryGrid.vue'
|
|
import ProductPanel from '../components/home/ProductPanel.vue'
|
|
import ProductCarousel from '../components/home/ProductCarousel.vue'
|
|
import { useConfigStore } from '../stores/configStore'
|
|
import type { HomeCategory } from '../stores/configStore'
|
|
import { useFiltersStore } from '../stores/filtersStore'
|
|
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
|
|
import { useCachedReads } from '../api/cachedReads'
|
|
import { useCatalogStore } from '../stores/catalogStore'
|
|
import { KEYS } from '../stores/queryCache'
|
|
|
|
// Module scope, so it survives this view being unmounted and remounted as the
|
|
// user moves between tabs, and resets only on a full app reload.
|
|
let hasRevealed = false
|
|
|
|
const router = useRouter()
|
|
const memory = useConfigStore()
|
|
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.
|
|
// Kept in step with user-app, which has had this since the admin screen was
|
|
// wired up; without it, slides configured in the admin panel never showed here.
|
|
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
|
|
|
|
|
|
// First-paint entrance. Flipped once, after the first data load, so the
|
|
// sections settle into place instead of appearing all at once; pull-to-refresh
|
|
// does not replay it.
|
|
//
|
|
// MasterLayout keeps this view alive between tab switches, but it still
|
|
// remounts after a visit to a full-page route. The flag therefore lives outside
|
|
// the component — the choreography is a first-run flourish, and replaying it
|
|
// would fight the page transition rather than add to it.
|
|
const revealed = ref(hasRevealed)
|
|
|
|
// Each section starts a beat after the one above it. Kept small — the whole
|
|
// sequence finishes well under a second, so it reads as the page arriving
|
|
// rather than as something the user has to wait through.
|
|
const REVEAL_STEP_MS = 70
|
|
|
|
function revealDelay(i: number) {
|
|
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
|
|
}
|
|
|
|
// How many of the newest pieces the home page shows before handing off to the
|
|
// dedicated "محصولات جدید" route.
|
|
const REEL_LIMIT = 6
|
|
const LIST_LIMIT = 4
|
|
|
|
const newestProducts = computed(() => products.value)
|
|
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
|
|
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
|
|
|
|
// The hero shows ONLY what the admin uploaded in the slider section
|
|
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
|
|
// from categories and filled with product photos, so a newly added product
|
|
// could land in the hero. Slides are display-only: tapping one does nothing.
|
|
const slides = computed<HeroSlide[]>(() =>
|
|
(adminSlides.value ?? [])
|
|
.filter((s) => s.image)
|
|
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
|
|
)
|
|
|
|
// 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 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([
|
|
// 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 }),
|
|
])
|
|
|
|
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 covers this silently, same as before this existed.
|
|
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
|
|
}
|
|
|
|
async function onRefresh() {
|
|
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 () => {
|
|
await loadData()
|
|
loading.value = false
|
|
// Wait for the sections the load just populated to be in the DOM, then let
|
|
// one frame paint them in their "before" state — otherwise the browser
|
|
// coalesces both states into one and there is nothing to animate.
|
|
if (revealed.value) return
|
|
await nextTick()
|
|
requestAnimationFrame(() => {
|
|
revealed.value = true
|
|
hasRevealed = true
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// 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, 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 {
|
|
// 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 (same offset as PageBar / CartView).
|
|
padding-top: 76px;
|
|
padding-bottom: 90px;
|
|
}
|
|
|
|
// First-paint entrance: each section rises a little, scales up from just under
|
|
// full size and fades in, one beat after the one above it.
|
|
//
|
|
// The curve is a long decelerate with no overshoot — the thing that makes this
|
|
// read as "Apple" rather than as a web page animating is that it starts fast,
|
|
// settles slowly, and never bounces back. The travel is deliberately small
|
|
// (14px, 2% of scale): at first paint the content should look like it was
|
|
// always there and simply came into focus, not like it flew in.
|
|
.home__reveal {
|
|
opacity: 0;
|
|
transform: translate3d(0, 14px, 0) scale(0.98);
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.is-revealed .home__reveal {
|
|
opacity: 1;
|
|
transform: translate3d(0, 0, 0) scale(1);
|
|
transition:
|
|
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
|
|
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
|
|
}
|
|
|
|
// Respect a reduced-motion preference: show the page, skip the choreography.
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.home__reveal {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
.is-revealed .home__reveal {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
.home__hero {
|
|
margin-top: 18px;
|
|
// Bleed past the container's 16px gutter so the side cards reach the screen
|
|
// edge and run off it, rather than stopping inside the text column.
|
|
margin-inline: -16px;
|
|
}
|
|
|
|
.home__section {
|
|
margin-top: 30px;
|
|
}
|
|
|
|
// Section titles in the design sit right-aligned with no "see all" affordance.
|
|
.home__heading {
|
|
margin: 0 0 14px;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: @ink;
|
|
text-align: right;
|
|
}
|
|
|
|
.home__list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.home__empty {
|
|
margin-top: 28px;
|
|
padding: 34px 20px;
|
|
border: 1px dashed @hairline;
|
|
border-radius: 32px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
color: @inkFaint;
|
|
}
|
|
.home__empty p {
|
|
margin: 0;
|
|
font-size: 12.5px;
|
|
}
|
|
</style>
|