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

417 lines
12 KiB
Vue
Raw Normal View History

2026-09-09 06:46:27 +03:30
<!--
Redesigned home screen (replaces the previous port of legacy home/index.vue).
2026-09-09 06:46:27 +03:30
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.
2026-09-09 06:46:27 +03:30
-->
<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>
2026-09-09 06:46:27 +03:30
</router-link>
2026-09-16 11:59:35 +03:30
<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
2026-09-16 11:59:35 +03:30
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" />
2026-09-09 06:46:27 +03:30
</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>
2026-09-09 06:46:27 +03:30
</container>
</PullRefresh>
</div>
2026-09-09 06:46:27 +03:30
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
2026-09-09 06:46:27 +03:30
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'
2026-09-09 06:46:27 +03:30
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 { api } from '../api/client'
2026-09-21 08:32:31 +03:30
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
2026-09-09 06:46:27 +03:30
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
2026-09-09 06:46:27 +03:30
const loading = ref(true)
const refreshing = ref(false)
2026-09-09 06:46:27 +03:30
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
2026-09-21 08:32:31 +03:30
// 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>(null)
2026-09-09 06:46:27 +03:30
// 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))
2026-09-09 06:46:27 +03:30
2026-09-21 08:32:31 +03:30
// 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 = [
2026-09-16 11:59:35 +03:30
{ eyebrow: 'مجموعه منتزر', cta: 'دیدن مجموعه' },
{ eyebrow: 'انتخاب سردبیر', cta: 'مشاهده' },
{ eyebrow: 'پیشنهاد این هفته', cta: 'بررسی کنید' },
]
const slides = computed<ShowcaseSlide[]>(() => {
2026-09-21 08:32:31 +03:30
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) {
2026-09-21 08:32:31 +03:30
// 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' })
}
async function loadData() {
2026-09-09 06:46:27 +03:30
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
2026-09-09 06:46:27 +03:30
} catch (e) {
console.log(e)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
2026-09-21 08:32:31 +03:30
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero below covers this silently, same as before this existed.
adminSlides.value = null
}
}
async function onRefresh() {
await loadData()
refreshing.value = false
}
onMounted(async () => {
await loadData()
loading.value = false
reveal()
2026-09-09 06:46:27 +03:30
})
</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>