Files
tehran_taj/apps/user-app/src/views/HomeView.vue
T

415 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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, 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, priceResult, slidesResult] = await Promise.allSettled([
// memory.init() reads the same cached catalogue, so this is one request.
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
if (catalogResult.status === 'rejected') console.log(catalogResult.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()
})
</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>