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:
s.esmaeili
2026-09-18 12:54:45 +03:30
parent 05fe780c89
commit 53a304b5d5
355 changed files with 22865 additions and 14 deletions
@@ -0,0 +1,229 @@
<!--
The "حساب" block that sits BELOW the shop content on the home screen.
Two genuinely different states:
- signed out: a quiet invitation, no fabricated numbers.
- signed in: the customer's real gram position from GET /customers/:id
(goldDebtGrams / fiatDebtRial), labelled بدهکار or بستانکار the same way
DashboardView does, since the ledger stores debt as a signed figure.
-->
<template>
<section class="acct">
<template v-if="customer">
<div class="acct__head">
<div>
<div class="acct__eyebrow">{{ positionLabel }}</div>
<div class="acct__figure">
{{ grams }}
<span class="acct__unit">گرم</span>
</div>
</div>
<span class="acct__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="acct__row">
<span class="acct__rowLabel">بدهی ریالی</span>
<span class="acct__rowValue">{{ price(fiat) }} <i>ریال</i></span>
</div>
<div class="acct__actions">
<button type="button" class="acct__action" @click="emit('go', 'ordersList')">
<Icon icon="vuesax-linear:box" size="18" />
سفارش‌ها
</button>
<button type="button" class="acct__action" @click="emit('go', 'transactionsList')">
<Icon icon="vuesax-linear:receipt-text" size="18" />
اسناد
</button>
<button type="button" class="acct__action" @click="emit('go', 'profileData')">
<Icon icon="vuesax-linear:user" size="18" />
پروفایل
</button>
</div>
</template>
<template v-else>
<div class="acct__invite">
<div class="acct__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
<div class="acct__inviteCopy">
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
</div>
<button type="button" class="acct__inviteCta" @click="emit('login')">ورود / ثبت‌نام</button>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { price } from '../../utils/filters'
import type { CustomerDetail } from '../../api/client'
const props = defineProps<{ customer: CustomerDetail | null }>()
const emit = defineEmits<{ (e: 'go', route: string): void; (e: 'login'): void }>()
const isDebtor = computed(() => (props.customer ? parseFloat(props.customer.goldDebtGrams) > 0 : false))
const positionLabel = computed(() => (isDebtor.value ? 'مانده بدهی گرمی' : 'مانده بستانکاری گرمی'))
const grams = computed(() => (props.customer ? Math.abs(parseFloat(props.customer.goldDebtGrams)) : 0))
const fiat = computed(() => (props.customer ? props.customer.fiatDebtRial.split('.')[0] : '0'))
</script>
<style scoped lang="less">
.acct {
border-radius: 20px;
padding: 18px;
background: @ink;
color: #fff;
box-shadow: 0 22px 44px -32px rgba(20, 22, 26, 0.9);
}
.acct__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.acct__eyebrow {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
padding-bottom: 7px;
}
.acct__figure {
font-size: 26px;
font-weight: 800;
letter-spacing: 0.3px;
}
.acct__unit {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.55);
padding-inline-start: 3px;
}
.acct__seal {
font-size: 10.5px;
font-weight: 700;
padding: 5px 11px;
border-radius: 999px;
}
.acct__seal.is-debtor {
color: #fff;
background: @wine;
}
.acct__seal.is-creditor {
color: #fff;
background: rgba(255, 255, 255, 0.13);
}
.acct__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 12.5px;
}
.acct__rowLabel {
color: rgba(255, 255, 255, 0.55);
}
.acct__rowValue {
font-weight: 700;
}
.acct__rowValue i {
font-style: normal;
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
}
.acct__actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 16px;
}
.acct__action {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 11px 4px;
border: 0;
cursor: pointer;
border-radius: 13px;
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.07);
transition: background 0.18s ease, transform 0.18s ease;
}
.acct__action:active {
transform: scale(0.97);
}
.acct__invite {
display: flex;
align-items: center;
gap: 13px;
flex-wrap: wrap;
}
.acct__inviteIcon {
width: 40px;
height: 40px;
border-radius: 13px;
display: grid;
place-items: center;
color: #fff;
background: @wine;
flex: 0 0 auto;
}
.acct__inviteCopy {
flex: 1 1 150px;
}
.acct__inviteCopy h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #fff;
}
.acct__inviteCopy p {
margin: 5px 0 0;
font-size: 11.5px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.58);
}
.acct__inviteCta {
flex: 1 1 100%;
margin-top: 4px;
padding: 12px;
border: 0;
cursor: pointer;
border-radius: 13px;
background: #fff;
color: @ink;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.18s ease;
}
.acct__inviteCta:active {
transform: scale(0.98);
}
</style>
@@ -0,0 +1,95 @@
<!--
Category row from the design: a scrollable rail of square tiles, each holding
a circular outlined mark with the category title beneath it.
Categories are REAL — configStore derives them from GET /catalog/products.
The design shows an outlined necklace glyph per tile; no per-category artwork
exists in the API yet, so a shared line mark stands in and the category's own
icon is used as soon as one is provided (`cat.icon.image`).
-->
<template>
<div class="cats no-scrollbar">
<button
v-for="cat in categories"
:key="cat.id"
type="button"
class="cats__tile"
:class="{ 'is-active': cat.id === activeId }"
@click="emit('select', cat)"
>
<span class="cats__mark" aria-hidden="true">
<img v-if="cat.icon?.image" :src="cat.icon.image" alt="" />
<Icon v-else icon="vuesax-linear:3d-cube-scan" size="27" />
</span>
<span class="cats__title">{{ cat.title }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import Icon from '../icon/Icon.vue'
import type { HomeCategory } from '../../stores/configStore'
defineProps<{ categories: HomeCategory[]; activeId?: string | null }>()
const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
</script>
<style scoped lang="less">
.cats {
display: flex;
gap: 15px;
overflow-x: auto;
// Bleed to the screen edge so tiles can scroll past the container gutter.
margin: 0 -16px;
padding: 2px 16px;
scroll-snap-type: x proximity;
}
.cats__tile {
scroll-snap-align: start;
flex: 0 0 auto;
width: 79px;
height: 65px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
cursor: pointer;
font-family: inherit;
border-radius: 22px;
border: 0;
background: @hairlineSoft;
color: @primary;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.18s ease;
}
.cats__tile:active {
transform: scale(0.96);
}
.cats__tile.is-active {
background: @hairline;
}
.cats__mark {
display: grid;
place-items: center;
color: @primary;
}
.cats__mark img {
width: 27px;
height: 27px;
object-fit: contain;
}
.cats__title {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
max-width: 71px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,94 @@
<!--
Horizontal category rail. Categories are real, derived from the product
catalogue by configStore (GET /catalog/products), so this renders nothing
rather than placeholder tiles when the catalogue is empty.
-->
<template>
<div class="rail no-scrollbar">
<button
v-for="cat in categories"
:key="cat.id"
type="button"
class="rail__chip"
:class="{ 'is-active': cat.id === activeId }"
@click="emit('select', cat)"
>
<span class="rail__mark" aria-hidden="true">
<img v-if="cat.icon?.image" :src="cat.icon.image" alt="" />
</span>
<span class="rail__title">{{ cat.title }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { HomeCategory } from '../../stores/configStore'
defineProps<{ categories: HomeCategory[]; activeId?: string | null }>()
const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
</script>
<style scoped lang="less">
.rail {
display: flex;
gap: 9px;
overflow-x: auto;
padding: 2px 16px 2px;
margin: 0 -16px;
scroll-snap-type: x proximity;
}
.rail__chip {
scroll-snap-align: start;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px 8px 10px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.rail__chip:active {
transform: scale(0.97);
}
.rail__chip.is-active {
background: @ink;
border-color: @ink;
color: #fff;
}
.rail__mark {
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
background: @hairlineSoft;
flex: 0 0 auto;
}
.rail__chip.is-active .rail__mark {
background: rgba(255, 255, 255, 0.14);
}
.rail__mark img {
width: 15px;
height: 15px;
object-fit: contain;
opacity: 0.85;
}
.rail__title {
white-space: nowrap;
}
</style>
@@ -0,0 +1,275 @@
<!--
Coverflow hero for the home screen (design: centred card raised above two
peeking neighbours, drag/swipe to advance).
Dependency-free on purpose: the app ships swiper, but swiper's coverflow
module drags in its own CSS that fights the app's RTL/less theming, and the
effect here is three transforms — cheaper to own than to configure.
Slides are built from REAL categories (see HomeView), so each one deep-links
into a genuine filtered search. The artwork is the first catalogue image of
that category; a slide with no image renders its tile blank rather than
substituting stock art.
-->
<template>
<section class="hero" v-if="slides.length">
<div
class="hero__stage"
ref="stage"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
>
<article
v-for="(slide, i) in slides"
:key="slide.key"
class="hero__card"
:class="{ 'is-active': i === index }"
:style="cardStyle(i)"
@click="onCardClick(i, slide)"
>
<img v-if="slide.image" :src="slide.image" :alt="slide.title" class="hero__art" draggable="false" />
<div v-else class="hero__artFallback" aria-hidden="true"></div>
</article>
</div>
<div class="hero__dots" v-if="slides.length > 1">
<button
v-for="(slide, i) in slides"
:key="slide.key"
type="button"
class="hero__dot"
:class="{ 'is-active': i === index }"
:aria-label="slide.title"
@click="go(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
export interface HeroSlide {
key: string
title: string
image?: string
categoryId?: string
}
const props = withDefaults(defineProps<{ slides: HeroSlide[]; autoplay?: boolean }>(), {
autoplay: true,
})
const emit = defineEmits<{ (e: 'select', slide: HeroSlide): void }>()
const index = ref(0)
const stage = ref<HTMLElement | null>(null)
// Drag state. `drag` is the live finger offset in px, folded into the active
// card's transform so the stack tracks the finger instead of snapping on
// release only.
const dragging = ref(false)
const drag = ref(0)
let startX = 0
let pointerId: number | null = null
let timer: ReturnType<typeof setInterval> | undefined
const SWIPE_THRESHOLD = 46
// Geometry of the stack — see the derivation in cardStyle().
const NEIGHBOUR_SCALE = 0.76
const NEIGHBOUR_SHIFT = 36.6
// Side cards stay desaturated at all times and sit dimmed behind the centre
// card; only the front card shows its artwork in colour.
const NEIGHBOUR_OPACITY = 0.75
const count = computed(() => props.slides.length)
function cardStyle(i: number) {
// Signed distance from the active card, wrapped so the rail behaves as a
// loop: with 3 slides, index 2 sits at -1 from index 0, not +2.
let offset = i - index.value
if (count.value > 2) {
if (offset > count.value / 2) offset -= count.value
if (offset < -count.value / 2) offset += count.value
}
const dragShift = dragging.value ? drag.value : 0
const abs = Math.abs(offset)
// Unlike the product rail, the hero's side cards tuck *behind* the centre
// card rather than standing beside it — the centre card is wider, taller and
// fully opaque, and its shadow draws the seam over the two cards under it.
//
// NEIGHBOUR_SHIFT is derived, not eyeballed. The visible sliver of a
// neighbour is measured from its OUTER edge past the centre card's edge:
// shift = (centreRight + sliver - 50 - W*scale/2) / W
// where W = cell width (% of stage) and centreRight = 50 + W/2. With
// W = 73.1% (the 13.5% insets below), scale 0.76 and an 18% sliver → 36.6%.
// That sliver deliberately puts each neighbour's outer edge past 100% of the
// stage, so the side cards run off the screen rather than stopping at it.
const translate = offset * NEIGHBOUR_SHIFT + dragShift
const scale = offset === 0 ? 1 : NEIGHBOUR_SCALE
const opacity = abs > 1 ? 0 : abs === 0 ? 1 : NEIGHBOUR_OPACITY
return {
transform: `translate3d(${translate}%, 0, 0) scale(${scale})`,
opacity: String(opacity),
zIndex: String(10 - abs),
pointerEvents: abs > 1 ? 'none' : 'auto',
transition: dragging.value ? 'none' : 'transform .42s cubic-bezier(.22,.61,.36,1), opacity .42s ease',
} as Record<string, string>
}
function go(i: number) {
index.value = ((i % count.value) + count.value) % count.value
restart()
}
function next() {
go(index.value + 1)
}
function prev() {
go(index.value - 1)
}
function onDown(e: PointerEvent) {
if (count.value < 2) return
dragging.value = true
startX = e.clientX
drag.value = 0
pointerId = e.pointerId
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
stop()
}
function onMove(e: PointerEvent) {
if (!dragging.value || e.pointerId !== pointerId) return
const width = stage.value?.clientWidth || 1
// Expressed as a % of stage width so it composes with the % translate above.
drag.value = ((e.clientX - startX) / width) * 100
}
function onUp(e: PointerEvent) {
if (!dragging.value) return
const moved = e.clientX - startX
dragging.value = false
drag.value = 0
pointerId = null
// RTL note: dragging right (positive) moves toward the *previous* card,
// matching the direction the finger pushes the stack.
if (moved > SWIPE_THRESHOLD) prev()
else if (moved < -SWIPE_THRESHOLD) next()
else restart()
}
function onCardClick(i: number, slide: HeroSlide) {
// A click that ended a drag must not also open the category.
if (Math.abs(drag.value) > 1) return
if (i !== index.value) {
go(i)
return
}
emit('select', slide)
}
function stop() {
if (timer) clearInterval(timer)
timer = undefined
}
function restart() {
stop()
if (props.autoplay && count.value > 1) {
timer = setInterval(next, 6000)
}
}
onMounted(restart)
onBeforeUnmount(stop)
</script>
<style scoped lang="less">
.hero__stage {
position: relative;
// The centre card is a portrait of aspect 0.777 (measured off the design:
// 351x452). Driving the stage from an aspect ratio rather than a fixed pixel
// height keeps that proportion on every screen width — a fixed height made
// the card too tall and narrow on a phone.
//
// Cards span the stage's full height, so the card's height IS the stage's.
// With the centre card at 73.1% of the stage width, the stage's own
// width:height ratio is 0.777 / 0.731 ≈ 1.063.
aspect-ratio: 1.063;
touch-action: pan-y;
user-select: none;
}
.hero__card {
position: absolute;
top: 0;
bottom: 0;
// The active card occupies the middle 73.1% of the stage; the side cards sit
// *behind* it, showing only a sliver past each edge.
// NEIGHBOUR_SHIFT in the script is derived from this width — changing one
// without the other breaks the sliver it was chosen to produce.
left: 13.45%;
right: 13.45%;
border-radius: 32px;
overflow: hidden;
cursor: pointer;
background: @hairlineSoft;
will-change: transform, opacity;
}
// The seam: a shadow cast by the front card onto the two tucked behind it, so
// the centre card reads as lifted off the stack rather than pasted onto it.
.hero__card.is-active {
box-shadow: 0 18px 44px -12px rgba(20, 22, 26, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.9);
}
.hero__art,
.hero__artFallback {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
// Desaturation animates with the same curve the card settles on, so a card
// regains its colour as it comes to the front.
transition: filter 0.42s cubic-bezier(0.22, 0.61, 0.36, 1);
}
// Only the front card shows its artwork in colour; the ones behind it stay
// black and white at all times. Dimming lives on the cell (NEIGHBOUR_OPACITY),
// not here, so the two do not compound.
.hero__card:not(.is-active) .hero__art {
filter: grayscale(1);
}
.hero__artFallback {
background: linear-gradient(140deg, @hairlineSoft 0%, @hairline 100%);
}
.hero__dots {
display: flex;
justify-content: center;
gap: 6px;
padding-top: 14px;
}
.hero__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition: all 0.25s ease;
}
.hero__dot.is-active {
width: 20px;
background: @wine;
}
</style>
@@ -0,0 +1,298 @@
<!--
"محصولات جدید" rail, per the design: the centred card sits larger than its two
neighbours, and the rail loops endlessly — after the last product the next
card is a "مشاهده همه محصولات جدید" tile that routes to the newest-products
page, and past that the rail wraps back to the first product.
Shares its geometry with HeroCarousel (same wrapped-offset maths, same drag
handling) but is a separate component rather than a prop-flag on that one:
this rail carries cards with their own markup and a trailing CTA tile, while
the hero carries edge-to-edge artwork. Merging them would mean a component
that is two layouts behind a flag.
-->
<template>
<section class="reel">
<div
class="reel__stage"
ref="stage"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
>
<div
v-for="(slot, i) in slots"
:key="slot.key"
class="reel__cell"
:class="{ 'is-active': i === index }"
:style="cellStyle(i)"
>
<ProductPanel
v-if="slot.kind === 'product'"
:value="slot.product!"
@open="onOpen(i, $event)"
/>
<button v-else type="button" class="reel__all" @click="onAll(i)">
<span class="reel__allMark" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-2" size="26" />
</span>
<span class="reel__allText">مشاهده همه محصولات جدید</span>
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import ProductPanel from './ProductPanel.vue'
import type { ProductSummary } from '../../api/client'
interface Cell {
key: string
kind: 'product' | 'all'
product?: ProductSummary
}
const props = defineProps<{ products: ProductSummary[] }>()
const emit = defineEmits<{
(e: 'open', value: ProductSummary): void
(e: 'all'): void
}>()
const index = ref(0)
const stage = ref<HTMLElement | null>(null)
const dragging = ref(false)
const drag = ref(0)
let startX = 0
let pointerId: number | null = null
const SWIPE_THRESHOLD = 46
// Geometry of the rail — see the derivation in cellStyle().
const NEIGHBOUR_SCALE = 0.78
const NEIGHBOUR_SHIFT = 92.5
// Side cards also recede rather than just shrinking: a little transparency
// sells the depth without a perspective transform. Their vertical alignment is
// not done here — `transform-origin: bottom` on the cell pins every card's
// bottom edge to the same baseline, so the side cards shrink upward and stay
// flush with the centre card no matter what NEIGHBOUR_SCALE becomes.
const NEIGHBOUR_OPACITY = 0.62
// The CTA is the last cell, so the loop reads: products → "see all" → back to
// the first product.
const slots = computed<Cell[]>(() => [
...props.products.map((p) => ({ key: p.id, kind: 'product' as const, product: p })),
{ key: '__all__', kind: 'all' as const },
])
const count = computed(() => slots.value.length)
function cellStyle(i: number) {
// Signed distance from the centre, wrapped so the rail loops in both
// directions rather than running out at either end.
let offset = i - index.value
if (count.value > 2) {
if (offset > count.value / 2) offset -= count.value
if (offset < -count.value / 2) offset += count.value
}
const dragShift = dragging.value ? drag.value : 0
// Design: the centre card is full size, the flanking ones noticeably
// smaller, with clear air between them. Cells beyond the immediate
// neighbours are parked out of sight.
//
// NEIGHBOUR_SHIFT is not a free-hand number. `scale` shrinks a cell about
// its own centre, pulling its inner edge back toward the middle by half the
// shrinkage, so a translate chosen by eye ends up overlapping the centre
// card. Solving for a real gap, in units of cell width:
// shift = (gap + W/2 + W*scale/2) / W where W = cell width (% of stage)
// With W = 74% (the 13% insets below), scale 0.78 and a 2.6% gap, that is
// 92.5% — see the CSS note on .reel__cell.
const translate = offset * NEIGHBOUR_SHIFT + dragShift
// `progress` is how far this cell sits from the centre *including the live
// drag*, so scale/opacity/drop interpolate continuously under the finger
// instead of snapping between two states on release. This is what makes the
// rail feel physical: the card you are pulling in grows as it arrives.
const progress = Math.min(Math.abs(offset + dragShift / NEIGHBOUR_SHIFT), 1)
const scale = 1 - (1 - NEIGHBOUR_SCALE) * progress
const near = 1 - (1 - NEIGHBOUR_OPACITY) * progress
// Only the centre card and its two neighbours are painted; the rest are
// parked invisible so a long catalogue does not stack 20 layers.
const visible = Math.abs(offset) <= 1
const opacity = visible ? near : 0
return {
transform: `translate3d(${translate}%, 0, 0) scale(${scale})`,
opacity: String(opacity),
zIndex: String(10 - Math.abs(offset)),
pointerEvents: visible ? 'auto' : 'none',
// A gentle overshoot on settle reads as "snapping into place" rather than
// sliding to a stop. Nothing animates while the finger is down — the
// transform already tracks it frame by frame.
transition: dragging.value
? 'none'
: 'transform .55s cubic-bezier(.21,1.02,.36,1), opacity .45s ease',
} as Record<string, string>
}
function go(i: number) {
index.value = ((i % count.value) + count.value) % count.value
}
function next() {
go(index.value + 1)
}
function prev() {
go(index.value - 1)
}
function onDown(e: PointerEvent) {
if (count.value < 2) return
dragging.value = true
startX = e.clientX
drag.value = 0
pointerId = e.pointerId
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
}
function onMove(e: PointerEvent) {
if (!dragging.value || e.pointerId !== pointerId) return
const width = stage.value?.clientWidth || 1
drag.value = ((e.clientX - startX) / width) * 100
}
function onUp(e: PointerEvent) {
if (!dragging.value) return
const moved = e.clientX - startX
dragging.value = false
drag.value = 0
pointerId = null
// RTL: dragging right pushes the stack toward the previous card.
if (moved > SWIPE_THRESHOLD) prev()
else if (moved < -SWIPE_THRESHOLD) next()
}
// A tap on a side card centres it instead of opening it, and a tap that ended
// a drag does neither.
function onOpen(i: number, product: ProductSummary) {
if (Math.abs(drag.value) > 1) return
if (i !== index.value) {
go(i)
return
}
emit('open', product)
}
function onAll(i: number) {
if (Math.abs(drag.value) > 1) return
if (i !== index.value) {
go(i)
return
}
emit('all')
}
</script>
<style scoped lang="less">
.reel__stage {
position: relative;
// ProductPanel's own content height at full scale: 18 padding + 148 art
// + 14 + title + 9 + meta + 16 padding. The cells are absolutely positioned
// against this box, so the room the active card's shadow needs below is
// margin, not padding — padding would stretch them.
height: 252px;
touch-action: pan-y;
user-select: none;
// No overflow clip here: it would cut the active card's shadow and the
// dropped side cards. Cells past the immediate neighbours are already
// opacity 0, so nothing spills into the gutter anyway.
margin: 0 -16px 26px;
}
.reel__cell {
position: absolute;
top: 0;
bottom: 0;
// 13% insets put the centre card at 74% of the stage, matching the design.
// NEIGHBOUR_SHIFT in the script is derived from this width — changing one
// without the other reintroduces the overlap it was chosen to remove.
left: 13%;
right: 13%;
// Pins the bottom edge: side cards shrink upward instead of about their
// middle, so every card in the rail sits on the same baseline. Horizontal
// scaling still happens about the centre, so the gap maths above is
// unaffected.
transform-origin: bottom center;
will-change: transform, opacity;
}
// The centre card lifts off the page; the shadow fades in with the same curve
// the transform settles on, so growing and lifting read as one motion.
.reel__cell > :deep(.panel),
.reel__all {
transition: box-shadow 0.55s cubic-bezier(0.21, 1.02, 0.36, 1);
}
.reel__cell.is-active > :deep(.panel),
.reel__cell.is-active .reel__all {
box-shadow: 0 18px 38px -24px rgba(20, 22, 26, 0.55);
}
// ProductPanel fills its cell so the scale transform drives the size.
.reel__cell > :deep(.panel) {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.reel__all {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
cursor: pointer;
font-family: inherit;
padding: 18px 16px;
border: 0;
border-radius: 32px;
// Same fill as ProductPanel, so the CTA reads as one more card in the rail
// rather than a different kind of thing.
background: @hairlineSoft;
color: @wine;
transition: transform 0.2s ease, background 0.2s ease;
}
.reel__all:active {
transform: scale(0.985);
}
.reel__allMark {
width: 56px;
height: 56px;
border-radius: 999px;
display: grid;
place-items: center;
// The card now shares ProductPanel's fill, so the mark needs its own tint to
// stay visible against it.
background: @canvas;
}
.reel__allText {
font-size: 13.5px;
font-weight: 700;
line-height: 1.7;
}
</style>
@@ -0,0 +1,117 @@
<!--
Product card from the design: a soft grey rounded panel with the piece
centred on it, the title beneath, then a meta line of weight + making charge.
On honesty of the meta line — the design mocks up a struck-through "اجرت ۱۵٪"
next to a red "اجرت ۱۰٪", i.e. a discounted wage. The API exposes no
promotion or discount data today (useProduct's `hasDiscountWage` is always
null, see its header), so the struck-through half only renders when a real
`wageBefore` is passed in. Until the backend returns one, the card shows the
variant's genuine wage alone — the layout is identical, minus an invented
number.
-->
<template>
<article class="panel" @click="emit('open', value)">
<div class="panel__art">
<img v-lazy="image" :alt="title" />
</div>
<h3 class="panel__title">{{ title }}</h3>
<div class="panel__meta">
<span class="panel__weight">{{ min_gram }} گرم</span>
<template v-if="wage != null">
<span class="panel__sep" aria-hidden="true"></span>
<span v-if="wageBefore != null" class="panel__wageOld">اجرت {{ format(wageBefore) }}٪</span>
<span class="panel__wage">{{ format(wage) }}٪ اجرت</span>
</template>
</div>
</article>
</template>
<script setup lang="ts">
import { useProduct } from '../../composables/useProduct'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary; wageBefore?: number | null }>()
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
const { image, title, min_gram, wage } = useProduct(props.value)
// Whole percents print bare (۱۰٪), fractional ones keep two places (۱۰.۵٪).
function format(v: number) {
return v % 1 === 0 ? String(v) : v.toFixed(2)
}
</script>
<style scoped lang="less">
.panel {
cursor: pointer;
border-radius: 32px;
background: @hairlineSoft;
padding: 18px 16px 16px;
text-align: center;
transition: transform 0.2s ease, background 0.2s ease;
}
.panel:active {
transform: scale(0.985);
}
.panel__art {
height: 148px;
display: flex;
align-items: center;
justify-content: center;
}
.panel__art img {
max-width: 78%;
max-height: 100%;
object-fit: contain;
display: block;
}
.panel__title {
margin: 14px 0 0;
font-size: 14px;
font-weight: 600;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel__meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-top: 9px;
font-size: 12px;
}
.panel__weight {
color: @inkSoft;
font-weight: 500;
font-size: 14px;
}
.panel__sep {
width: 1px;
height: 12px;
background: @hairline;
}
.panel__wageOld {
color: @inkFaint;
text-decoration: line-through;
}
.panel__wage {
color: @danger;
font-weight: 600;
font-size: 12px !important;
}
</style>
@@ -0,0 +1,166 @@
<!--
Live 18k gold rate strip. Real data: GET /pricing/latest (PricingController).
The strip degrades to a quiet placeholder when the quote endpoint has no
data yet, rather than rendering a fabricated number.
-->
<template>
<div class="rate-strip" :class="{ 'is-empty': !quote, 'rate-strip--compact': compact }">
<div class="rate-strip__label">
<span class="rate-strip__dot" aria-hidden="true"></span>
نرخ طلای ۱۸ عیار
</div>
<div v-if="quote" class="rate-strip__value">
<span class="rate-strip__num">{{ price(perGram) }}</span>
<span class="rate-strip__unit">ریال / گرم</span>
</div>
<div v-else class="rate-strip__value rate-strip__value--muted">در انتظار اعلام نرخ</div>
<div v-if="quote" class="rate-strip__time">{{ jdate(quote.quotedAt) }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { price, jdate } from '../../utils/filters'
import type { GoldPriceQuote } from '../../api/client'
// `compact` is the home header's variant: a small inline pill that sits beside
// the logo. The default full-width card is still what DashboardView renders.
const props = defineProps<{ quote: GoldPriceQuote | null; compact?: boolean }>()
// pricePerGramRial is a decimal string from the ledger — never parsed into a
// float for display, only trimmed of any fractional tail.
const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split('.')[0] : ''))
</script>
<style scoped lang="less">
.rate-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
background: @ink;
color: #fff;
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
}
// Header variant: a compact pill beside the logo.
//
// It deliberately does NOT reuse the dark card treatment. In the header the
// pill sits next to the brand mark on a light bar, and a near-black slab there
// outweighs the logo and clashes with it — @ink is a neutral black while the
// logo and the rest of the page are @primary navy, so the two darks read as
// two unrelated colours. This variant instead tints the page's own navy: a
// pale navy wash, navy text, a hairline navy border. Same family as everything
// around it, and light enough that the logo stays the heaviest thing in the
// bar.
.rate-strip--compact {
gap: 7px;
padding: 6px 12px 6px 10px;
border-radius: 999px;
box-shadow: none;
background: fade(@primary, 6%);
border: 1px solid fade(@primary, 10%);
color: @primary;
}
.rate-strip--compact .rate-strip__label {
font-size: 11px;
font-weight: 500;
// Softer than the number, so the eye lands on the rate first.
color: fade(@primary, 62%);
}
.rate-strip--compact .rate-strip__num {
font-size: 12.5px;
font-weight: 700;
color: @primary;
}
.rate-strip--compact .rate-strip__value {
margin-inline-start: 0;
}
// The live dot picks up the page's gold accent rather than the wine red, which
// appears nowhere else in the header.
.rate-strip--compact .rate-strip__dot {
width: 5px;
height: 5px;
background: @secondary;
box-shadow: none;
animation: none;
}
.rate-strip--compact .rate-strip__value--muted {
font-size: 11px;
color: fade(@primary, 45%);
}
.rate-strip--compact .rate-strip__unit,
.rate-strip--compact .rate-strip__time {
display: none;
}
.rate-strip__label {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: rgba(255, 255, 255, 0.68);
white-space: nowrap;
}
.rate-strip__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: @wineSoft;
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.65);
animation: ratePulse 2.4s ease-out infinite;
}
.rate-strip__value {
margin-inline-start: auto;
display: flex;
align-items: baseline;
gap: 5px;
}
.rate-strip__value--muted {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.rate-strip__num {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
}
.rate-strip__unit {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.55);
}
.rate-strip__time {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.42);
padding-inline-start: 10px;
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
white-space: nowrap;
}
@keyframes ratePulse {
0% {
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.6);
}
70% {
box-shadow: 0 0 0 7px rgba(163, 60, 85, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0);
}
}
</style>
@@ -0,0 +1,59 @@
<!-- Section heading with an optional "see all" affordance. -->
<template>
<header class="sec">
<div class="sec__text">
<h2 class="sec__title">{{ title }}</h2>
<p v-if="subtitle" class="sec__sub">{{ subtitle }}</p>
</div>
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
{{ actionLabel }}
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
</button>
</header>
</template>
<script setup lang="ts">
import Icon from '../icon/Icon.vue'
defineProps<{ title: string; subtitle?: string; actionLabel?: string }>()
const emit = defineEmits<{ (e: 'action'): void }>()
</script>
<style scoped lang="less">
.sec {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
padding-bottom: 13px;
}
.sec__title {
margin: 0;
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
.sec__sub {
margin: 5px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.sec__action {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: none;
cursor: pointer;
padding: 0;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: @wine;
white-space: nowrap;
}
</style>
@@ -0,0 +1,156 @@
<!--
Product card for the redesigned home screen.
Shows only fields that genuinely exist on ProductSummary today: title, the
cheapest variant's rash weight, its making-charge (اجرت) percent, and the
catalogue image. No price is printed on the card, because a finished piece's
rial price is a function of the live gold rate plus wage and is computed at
order time, not stored on the product — printing one here would be invented.
-->
<template>
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
<div class="card__art">
<img v-lazy="image" :alt="title" />
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="17" />
</button>
<span v-if="badge" class="card__badge">{{ badge }}</span>
</div>
<div class="card__body">
<h3 class="card__title">{{ title }}</h3>
<div class="card__meta">
<span class="card__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="card__wage">اجرت {{ wageLabel }}٪</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary; wide?: boolean; badge?: string }>()
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage } = useProduct(props.value)
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style scoped lang="less">
.card {
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:active {
transform: scale(0.985);
}
.card__art {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft;
}
.card--wide .card__art {
aspect-ratio: 4 / 3;
}
.card__art img {
width: 100%;
max-width: 200px;
height: 100%;
max-height: 200px;
object-fit: contain;
object-position: center;
display: block;
}
.card__fav {
position: absolute;
top: 9px;
left: 9px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
.card__fav.is-on {
color: @wine;
}
.card__fav:active {
transform: scale(0.9);
}
.card__badge {
position: absolute;
top: 9px;
right: 9px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @wine;
}
.card__body {
padding: 11px 12px 13px;
}
.card__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 7px;
}
.card__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.card__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
</style>
@@ -0,0 +1,201 @@
<!--
Editorial hero for the home screen. Replaces the legacy swiper banner with a
self-contained, dependency-free slider: no swiper CSS to fight, no autoplay
jitter, and it keeps working when a slide has no artwork.
-->
<template>
<section class="showcase" v-if="slides.length">
<div class="showcase__frame">
<transition-group name="showcase-fade" tag="div" class="showcase__stage">
<article v-for="(slide, i) in slides" :key="slide.key" v-show="i === index" class="showcase__slide">
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" />
<div class="showcase__scrim" aria-hidden="true"></div>
<div class="showcase__copy">
<span class="showcase__eyebrow">{{ slide.eyebrow }}</span>
<h2 class="showcase__title">{{ slide.title }}</h2>
<p class="showcase__sub">{{ slide.subtitle }}</p>
<button type="button" class="showcase__cta" @click="emit('select', slide)">
{{ slide.cta }}
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</button>
</div>
</article>
</transition-group>
</div>
<div class="showcase__dots" v-if="slides.length > 1">
<button
v-for="(slide, i) in slides"
:key="slide.key"
type="button"
class="showcase__dot"
:class="{ 'is-active': i === index }"
:aria-label="slide.title"
@click="go(i)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
export interface ShowcaseSlide {
key: string
eyebrow: string
title: string
subtitle: string
cta: string
image?: string
categoryId?: string
}
const props = defineProps<{ slides: ShowcaseSlide[] }>()
const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
const index = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
function go(i: number) {
index.value = i
restart()
}
function restart() {
if (timer) clearInterval(timer)
if (props.slides.length > 1) {
timer = setInterval(() => {
index.value = (index.value + 1) % props.slides.length
}, 6000)
}
}
onMounted(restart)
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped lang="less">
.showcase__frame {
position: relative;
border-radius: 22px;
overflow: hidden;
background: @ink;
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
}
.showcase__stage {
position: relative;
display: block;
aspect-ratio: 320 / 208;
}
.showcase__slide {
position: absolute;
inset: 0;
}
.showcase__art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.5;
transform: scale(1.04);
}
.showcase__scrim {
position: absolute;
inset: 0;
background:
linear-gradient(255deg, rgba(122, 27, 51, 0.55) 0%, rgba(122, 27, 51, 0) 52%),
linear-gradient(to left, rgba(20, 22, 26, 0.2) 0%, rgba(20, 22, 26, 0.92) 72%);
}
.showcase__copy {
position: absolute;
inset: 0;
padding: 22px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
color: #fff;
}
.showcase__eyebrow {
font-size: 10.5px;
letter-spacing: 1.6px;
color: rgba(255, 255, 255, 0.62);
padding-bottom: 9px;
}
.showcase__title {
font-size: 22px;
font-weight: 800;
line-height: 1.35;
color: #fff;
margin: 0 0 7px;
}
.showcase__sub {
font-size: 12.5px;
line-height: 1.85;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 16px;
max-width: 30ch;
}
.showcase__cta {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
cursor: pointer;
padding: 9px 16px;
border-radius: 999px;
background: #fff;
color: @ink;
font-size: 12.5px;
font-weight: 700;
font-family: inherit;
transition: transform 0.18s ease, background 0.18s ease;
}
.showcase__cta:active {
transform: scale(0.97);
}
.showcase__dots {
display: flex;
justify-content: center;
gap: 6px;
padding-top: 12px;
}
.showcase__dot {
width: 6px;
height: 6px;
padding: 0;
border: 0;
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition: all 0.25s ease;
}
.showcase__dot.is-active {
width: 20px;
background: @wine;
}
.showcase-fade-enter-active,
.showcase-fade-leave-active {
transition: opacity 0.55s ease;
}
.showcase-fade-enter-from,
.showcase-fade-leave-to {
opacity: 0;
}
</style>