user-app: redesigned home screen and product detail

Design language introduced here and reused across both screens:
  - LESS colour tokens (@ink/@inkSoft/@inkMuted/@inkFaint, @wine/@wineSoft,
    @canvas/@canvasSoft, @hairline/@hairlineSoft) added to
    themeConfig/colors.json and injected globally via vite's modifyVars, so
    scoped <style lang="less"> blocks resolve them with no import
  - BEM-ish scoped class names, hairline-bordered cards at 14-18px radii,
    ink surfaces with a wine accent, dashed-border empty states
  - components/home/: SectionHead, ShowcaseCard, ShowcaseSlider, CategoryRail,
    RateStrip, AccountStrip

Data honesty is deliberate throughout: no rial price is printed for a piece,
because a finished item's price is the live gold rate x weight plus wage,
computed at order time and never stored on the product. Only genuinely
present fields are shown (weight, making-charge percent, stock, category,
brand). Categories and the hero are derived from the real catalogue, so an
empty catalogue renders an empty state rather than placeholder tiles.

Also fixes a real bug in the product detail view: it ran, at module scope,
history.pushState + window.onpopstate = () => history.go(1). Because the
drawer mounts its slot eagerly, that fired for every product card on the
home screen and permanently hijacked the back button for the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-14 23:05:29 +03:30
co-authored by Claude Opus 5
parent a12fb0450e
commit f2d83c53df
41 changed files with 2041 additions and 241 deletions
+349 -94
View File
@@ -1,126 +1,381 @@
<!--
Ported from legacy henza_pwa/src/views/home/index.vue.
Redesigned home screen (replaces the previous port of legacy home/index.vue).
Honest adaptations:
- legacy fetched a single curated payload from POST /front (categories
with icons, a hand-picked "newest" list, a hand-picked "shelf" list, a
home banner slider config). The new API only exposes GET
/catalog/products (flat, uncurated), so `configStore` supplies the
banner slides (placeholder artwork, legacy's exact slide shape) and
derives the real category list from the product catalogue. The two
product sections both draw from the same flat product list (first 4 as
"newest", the rest as the shelf) rather than two independently curated
lists.
- the "please login" nudge (DialogVant, 5s after load) is ported and will
fire, since there is genuinely no login yet — matching legacy's real
behavior for an unauthenticated visitor.
- the notification bell (`NotificationList`) was already commented out of
legacy's own template — not ported, matching legacy's actual current state.
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>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<AppHeader>
<nav class="flex justify-between items-center">
<span></span>
<img src="/img/henza1.svg" alt="هنزاگلد" />
</nav>
</AppHeader>
<container class="px-[16px] pb-[66px]" v-if="flag">
<HomeSlider class="z-0 mx-[-16px]" :slides="memory.config.main_slider" :height="sliderHeight" />
<section>
<h5 class="font-bold">دسته بندی ها</h5>
<div class="no-scrollbar grid grid-cols-3 gap-4 mt-[16px] items-stretch">
<Category class="h-full" v-for="item in memory.cats" :key="item.id" :value="item" />
</div>
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
<h4 class="font-bold">محصولات جدید</h4>
<ProductHorizontal class="mt-[10px]" v-for="item in newestProducts" :key="item.id" :value="item" />
<router-link :to="{ name: 'newestProduct' }">
<Button :type="('light' as any)" class="mt-[16px]" block size="large">
<template #icon>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="25" />
</template>
مشاهده همه
</Button>
<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>
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
<div class="no-scrollbar flex overflow-x-auto mt-[16px] mx-[-16px] px-[16px]">
<ProductVertical class="flex-[0_0_280px] ml-[16px]" v-for="item in shelfProducts" :key="item.id" :value="item" />
<ViewAll class="last:ml-0 flex-[0_0_280px]" to="/search" />
<img src="/img/henza1.svg" 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>
<container class="home__body">
<RateStrip :quote="goldPrice" class="home__rate" />
<ShowcaseSlider v-if="slides.length" :slides="slides" class="home__hero" @select="onSlide" />
<CategoryRail
v-if="memory.cats.length"
:categories="memory.cats"
:active-id="null"
class="home__rail"
@select="onCategory"
/>
<section v-if="featured" class="home__section">
<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">
<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">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
<section class="home__section">
<SectionHead title="حساب من" subtitle="موجودی و دسترسی‌های شما" />
<AccountStrip :customer="customer" @go="onAccountNav" @login="goLogin" />
</section>
<div class="home__trust">
<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>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</Spinner>
<Drawer
v-model:visible="productDrawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="productDrawer = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Divider } from 'ant-design-vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import Spinner from '../components/Spinner.vue'
import DialogVant from '../components/DialogVant.vue'
import Category from '../components/product/Category.vue'
import ProductHorizontal from '../components/product/ProductHorizontal.vue'
import ProductVertical from '../components/product/ProductVertical.vue'
import ViewAll from '../components/ViewAll.vue'
import HomeSlider from '../components/slider/HomeSlider.vue'
import vectorIcon from '../assets/img/Vector.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import { useWindowDataStore } from '../stores/windowDataStore'
import SingleProduct from '../components/product/SingleProduct.vue'
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 AccountStrip from '../components/home/AccountStrip.vue'
import { useConfigStore } from '../stores/configStore'
import { isUserLoggedIn } from '../utils/checkAccess'
import { api, type ProductSummary } from '../api/client'
import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
import type { CustomerDetail, GoldPriceQuote, ProductSummary } from '../api/client'
const router = useRouter()
const windowData = useWindowDataStore()
// legacy's `memory` — the global config store (banner slider + categories)
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const session = useSessionStore()
const loading = ref(false)
const flag = ref(false)
const loginNotify = ref(false)
const loading = ref(true)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(null)
const newestProducts = computed(() => products.value.slice(0, 4))
const shelfProducts = computed(() => products.value.slice(4))
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
const sliderHeight = computed(() => Math.min(windowData.width, 768) / 2.2125 + 'px')
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))
const getFront = async () => {
// The hero is built from REAL categories, not a curated banner payload (none
// exists). Each 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[]>(() => {
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
}
function openProduct(product: ProductSummary) {
activeProductId.value = product.id
productDrawer.value = true
}
function onCategory(cat: HomeCategory) {
filters.query.cats = [cat.id]
router.push({ name: 'search' })
}
function onSlide(slide: ShowcaseSlide) {
if (slide.categoryId) {
filters.query.cats = [slide.categoryId]
}
router.push({ name: 'search' })
}
function onAccountNav(routeName: string) {
router.push({ name: routeName })
}
function goSearch() {
router.push({ name: 'search' })
}
function goNewest() {
router.push({ name: 'newestProduct' })
}
function goLogin() {
router.push({ name: 'login' })
}
onMounted(async () => {
try {
loading.value = true
products.value = await api.listProducts()
flag.value = true
setTimeout(() => {
if (!isUserLoggedIn()) {
loginNotify.value = true
}
}, 5000)
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
// legacy home/index.vue ran getFront() alongside the global store init, which
// is what populated the slider + category grid.
await Promise.all([getFront(), memory.init()])
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
if (session.customerId) {
try {
customer.value = await api.getCustomer(session.customerId)
} catch {
customer.value = null
}
}
})
</script>
<style scoped lang="less">
.home {
background: @canvas;
min-height: 100vh;
}
.home__bar {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.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 #fff;
}
.home__body {
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>