Files
tehran_taj/apps/user-app-v2/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

374 lines
12 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/montzar-logo.png" alt="منتزر" class="home__logo" />
<RateStrip :quote="goldPrice" compact class="home__rate" />
</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 }">
<HeroCarousel
v-if="slides.length"
:slides="slides"
class="home__hero home__reveal"
:style="revealDelay(0)"
@select="onSlide"
/>
<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>
</PullRefresh>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, 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 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 } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
// 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 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.
// 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>(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 renders its route without keep-alive, so this view remounts on
// every return to the Home tab. The flag therefore lives outside the component
// — the choreography is a first-run flourish, and replaying it on each tab
// switch 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 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 slides = computed<HeroSlide[]>(() => {
// Prefer what the admin actually configured; the category-derived hero below
// stays as the fallback for when no slide has been uploaded.
if (adminSlides.value?.length) {
return adminSlides.value
.filter((s) => s.image)
.map((s, i) => ({
key: `${i}-${s.image}`,
title: '',
image: s.image,
link: s.link || undefined,
}))
}
return memory.cats.slice(0, 5).map((cat) => ({
key: cat.id,
title: cat.title,
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: HeroSlide) {
// 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. Same rule as user-app.
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 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 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
// 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
})
})
</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, 82%);
backdrop-filter: blur(18px) saturate(140%);
// A hairline rule cut a hard line across the page. A very soft fade under
// the bar separates it from the content that scrolls beneath without
// drawing an edge.
border-bottom: 1px solid alpha(@primary, 5%);
box-shadow: 0 8px 20px -18px alpha(@primary, 55%);
}
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.home__logo {
// Sized to match the pill's height so the two sides of the bar carry equal
// optical weight instead of the logo looking stranded next to it.
height: 25px;
object-fit: contain;
}
.home__body {
// Clears the fixed bar (~50px) plus a little breathing room.
padding-top: 58px;
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>