SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروشهای این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
<!--
|
||||
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>
|
||||
|
||||
<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, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
import SingleProduct from '../components/product/SingleProduct.vue'
|
||||
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 { 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)
|
||||
|
||||
const productDrawer = ref(false)
|
||||
const activeProductId = ref<string | 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[]>(() =>
|
||||
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
|
||||
}
|
||||
|
||||
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: HeroSlide) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.home__bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: rgba(255, 255, 255, 0.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 fade(@primary, 5%);
|
||||
box-shadow: 0 8px 20px -18px fade(@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>
|
||||
Reference in New Issue
Block a user