Files
hedena_gold/apps/user-app-v2/src/views/HomeView.vue
T
s.esmaeiliandCursor afb068c643 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>
2026-10-17 13:11:42 +03:30

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>