Files
tehran_taj/apps/user-app/src/views/HomeView.vue
T
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30

417 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 { api } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(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>(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' })
}
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
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()
})
</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>