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.
374 lines
12 KiB
Vue
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>
|