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,142 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVant.vue.
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a
`show`/`modelValue` prop, and the internal `watch(() => props.modelValue, ...)`
references a prop that was never declared (always undefined, so that watcher
effectively never fires — its `showNotify`/timeout auto-hide logic is dead
code). Actual visibility works anyway, because <van-dialog> is this
component's sole root element and Vue's automatic attribute fallthrough
passes the parent's `v-model:show` straight through to it. Preserved exactly.
Visual pass: redesigned to the app's own card language instead of Vant's
stock dialog chrome — rounded @canvas card, the illustration slot framed in
a soft circular badge (@wineWash) rather than floating loose, and the
button row rebuilt as two pill buttons (primary filled @ink, secondary
ghost) instead of Vant's bare text-grid. Every call site only ever fills
the img/title/button slots with content (never Vant's own button classes),
so this is a pure chrome swap: no call site needs to change, and slot
content (including per-call-site text color utility classes already baked
into that content) still renders as-is inside the new layout.
-->
<template>
<van-dialog class="mDialog">
<div class="mDialog__body">
<div class="mDialog__art">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="mDialog__title">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="mDialog__actions">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
// Vant's own dialog chrome (white slab, hard corners, drop shadow) is
// replaced wholesale — the shape and elevation now match the rest of the
// app's cards (HomeView's hero, Dashboard's rows) rather than the library
// default.
.mDialog.van-dialog {
width: min(320px, calc(100vw - 48px));
border-radius: 26px;
background: @canvas;
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
overflow: hidden;
}
.mDialog__body {
padding: 26px 22px 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
// The illustration sits inside a soft circular badge in the page's wine
// wash, sized to a consistent footprint regardless of the source SVG's
// native dimensions — mirrors DashboardView's icon-badge treatment.
.mDialog__art {
width: 84px;
height: 84px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: @wineWash;
margin-bottom: 18px;
}
.mDialog__art img,
.mDialog__art svg,
.mDialog__art canvas {
width: 56px;
height: 56px;
object-fit: contain;
opacity: 1 !important;
}
.mDialog__title {
font-size: 14.5px;
font-weight: 700;
line-height: 1.9;
color: @ink;
}
// Two pill buttons instead of Vant's bordered text-grid. Each call site
// passes two <div>s with a click handler; the first (the affirmative action)
// is promoted to the filled primary pill by DOM order, the second becomes
// the ghost/secondary pill — no call-site markup changes needed.
.mDialog__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 22px;
font-size: 13px;
font-weight: 700;
}
.mDialog__actions > * {
padding: 12px;
border-radius: 14px;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
}
.mDialog__actions > *:active {
transform: scale(0.97);
}
.mDialog__actions > *:first-child {
color: #fff !important;
background: @ink;
}
.mDialog__actions > *:last-child {
color: @inkFaint !important;
background: none;
}
</style>
@@ -0,0 +1,104 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the
single-action variant of DialogVant (one full-width button instead of a
two-column grid). Used by App.vue's "new version available" dialog.
Same structure as DialogVant.vue: `show` is not a declared prop; the parent's
v-model:show falls through onto <van-dialog>, which is this component's sole
root element. Legacy's internal watch on an undeclared `modelValue` prop was
dead code and is not carried over.
Visual pass: same card chrome as DialogVant.vue's redesign (rounded
@canvas card, wine-wash art badge, filled @ink pill button), kept in sync
so the two dialog variants read as one family. The art badge is sized
larger here than DialogVant's since this component's sole caller
(App.vue's update dialog) fills it with a full animated Lottie player, not
a static icon.
-->
<template>
<van-dialog class="mDialog mDialog--single">
<div class="mDialog__body">
<div class="mDialog__art">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="mDialog__title">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="mDialog__actions">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
.mDialog.van-dialog {
width: min(320px, calc(100vw - 48px));
border-radius: 26px;
background: @canvas;
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
overflow: hidden;
}
.mDialog__body {
padding: 26px 22px 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.mDialog--single .mDialog__art {
width: 100%;
margin-bottom: 8px;
}
.mDialog__title {
font-size: 14.5px;
font-weight: 700;
line-height: 1.9;
color: @ink;
}
.mDialog__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 9px;
margin-top: 10px;
font-size: 13px;
font-weight: 700;
}
.mDialog__actions > * {
padding: 12px;
border-radius: 14px;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease;
color: #fff !important;
background: @ink;
}
.mDialog__actions > *:active {
transform: scale(0.97);
}
</style>
@@ -0,0 +1,32 @@
<!-- Ported from legacy henza_pwa/src/components/empty.vue -->
<template>
<div class="flex justify-center items-center empty">
<Empty description="موردی یافت نشد" :image="image" class="h-[200px] w-[200px] opacity-70" />
</div>
</template>
<script setup lang="ts">
import { Empty } from 'ant-design-vue'
withDefaults(
defineProps<{
description?: string
image?: string
}>(),
{},
)
</script>
<style lang="less">
.ant-empty {
.ant-empty-description {
font-size: 16px !important;
font-weight: 600;
margin-top: -36px;
}
}
.empty {
min-height: calc(100vh - 350px);
vertical-align: middle;
}
</style>
@@ -0,0 +1,50 @@
<!-- Ported from legacy henza_pwa/src/components/NotifyVant.vue -->
<template>
<div v-if="modelValue" class="flex">
<van-notify v-model:show="show" :style="color">
<div class="w-full text-white my-2">
<div class="w-full flex content-end">
<Icon icon="vuesax-linear:info-circle" class="ml-[20px] text-white" size="25" />
<div>
<slot>{{ text }}</slot>
</div>
</div>
</div>
</van-notify>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Notify as VanNotify } from 'vant'
import Icon from './icon/Icon.vue'
const show = ref(true)
const props = withDefaults(
defineProps<{
modelValue: boolean
timeOut: number
text?: string
color?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
watch(
() => props.modelValue,
() => {
if (props.timeOut !== -1) showNotify()
},
)
const showNotify = () => {
setTimeout(() => {
emit('update:modelValue', false)
}, props.timeOut)
}
</script>
<!--
Legacy also had a `.notify-enter-active`/`.notify-leave-active` style block
referencing an undefined `notify-in` @keyframes and unused by any
<transition name="notify"> in this component — genuinely dead, dropped.
-->
+116
View File
@@ -0,0 +1,116 @@
<!-- Ported from legacy henza_pwa/src/components/spinner.vue -->
<template>
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
<div class="loading" v-else>
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</template>
<div :class="cls">
<slot />
</div>
</Spin>
</template>
<script setup lang="ts">
import { Spin } from 'ant-design-vue'
withDefaults(
defineProps<{
tip?: string
size?: string
cls?: string
delay?: number
spinning: boolean
indicator?: boolean
fixed?: boolean
}>(),
{ size: 'large', fixed: true },
)
</script>
<style lang="less">
.loading * {
box-sizing: border-box;
}
.loading {
position: relative;
width: 50px;
height: 50px;
border-radius: 50%;
box-sizing: border-box;
border: 3px solid transparent;
}
.loading .effect-1,
.loading .effect-2 {
position: absolute;
width: 50px;
height: 50px;
border: 3px solid transparent;
border-left: 3px solid @secondary;
border-radius: 50%;
box-sizing: border-box;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotateOpacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
position: absolute;
width: 50px;
height: 50px;
border: 3px solid transparent;
border-left: 3px solid @secondary;
animation: rotateOpacity 1s ease infinite 0.2s;
border-radius: 50%;
box-sizing: border-box;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotateOpacity {
0% {
transform: rotate(0deg);
opacity: 0.1;
}
100% {
transform: rotate(1turn);
opacity: 1;
}
}
.spinner-brand {
display: block;
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 8px;
}
.ant-spin-text {
color: @primary;
}
.loading + .ant-spin-text {
margin-top: 24px;
}
</style>
@@ -0,0 +1,19 @@
<!-- Ported from legacy henza_pwa/src/components/viewAll.vue.
Legacy left `to` undeclared and relied on Vue's attribute fallthrough to
land it on the root <router-link>; declared explicitly here only because
vue-tsc's template type-checking (which legacy's own build never ran)
requires router-link's `to` to be a known prop — same runtime value ends
up bound either way, no behavior change. -->
<template>
<router-link :to="to" class="text-primary w-[280px] rounded-[10px] p-[8px] bg-light flex flex-col justify-center items-center">
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer" size="25" />
مشاهده همه
</router-link>
</template>
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import Icon from './icon/Icon.vue'
defineProps<{ to: RouteLocationRaw }>()
</script>
@@ -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>
@@ -0,0 +1,34 @@
<template>
<span ref="refIcon" :style="{ color: color ?? undefined }" class="liman-icons flex" v-html="tempIcon"></span>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import icons from './icons.json'
interface Props {
icon: string
color?: string | null
size?: string | number
}
const props = withDefaults(defineProps<Props>(), {
color: null,
})
const tempIcon = ref<string>()
const refIcon = ref<HTMLSpanElement>()
tempIcon.value = (icons as Record<string, string>)[`${props.icon}`]
onMounted(() => {
if (props.size) {
const svg = refIcon.value?.children[0] as SVGElement
svg.setAttribute('width', props.size as string)
svg.setAttribute('height', props.size as string)
}
})
</script>
<style scoped>
.liman-icons :deep(svg path) {
stroke: currentColor;
}
</style>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
<!-- Ported from legacy henza_pwa/src/components/input/otpInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import SingleOtpInput from './SingleOtpInput.vue'
const BACKSPACE = 8
const LEFT_ARROW = 37
const RIGHT_ARROW = 39
const DELETE = 46
const props = withDefaults(
defineProps<{
defaultFocus?: boolean
numInputs?: number
separator?: string
inputClasses?: string
inputType?: string
shouldAutoFocus?: boolean
placeholder?: string
disabled?: boolean
}>(),
{ numInputs: 4, shouldAutoFocus: false, disabled: false },
)
const emit = defineEmits<{
(e: 'on-change', value: string | number): void
(e: 'on-complete', value: string): void
}>()
const activeInput = ref(0)
const otp = ref<string[]>([])
const oldOtp = ref<string[]>([])
const handleOnFocus = (index: number) => {
if (props.defaultFocus && index < props.numInputs && !otp.value[index]) {
activeInput.value = otp.value.length
} else activeInput.value = index
}
const handleOnBlur = () => {
activeInput.value = -1
}
const checkFilledAllInputs = () => {
if (otp.value.join('').length === props.numInputs) {
return emit('on-complete', otp.value.join(''))
}
return 'Wait until the user enters the required number of characters'
}
const focusInput = (input: number) => {
activeInput.value = Math.max(Math.min(props.numInputs - 1, input), 0)
}
const focusNextInput = () => focusInput(activeInput.value + 1)
const focusPrevInput = () => focusInput(activeInput.value - 1)
const changeCodeAtFocus = (value: number | string) => {
oldOtp.value = Object.assign([], otp.value)
if (typeof value === 'string' && value === '') otp.value.splice(activeInput.value, 1)
else otp.value[activeInput.value] = value as string
if (oldOtp.value.join('') !== otp.value.join('')) {
emit('on-change', otp.value.join(''))
checkFilledAllInputs()
}
}
const handleOnPaste = (event: Event) => {
event.preventDefault()
const clipboardEvent = event as ClipboardEvent
const pastedData = (clipboardEvent.clipboardData?.getData('text/plain') ?? '').slice(0, props.numInputs - activeInput.value).split('')
if (props.inputType === 'number' && !pastedData.join('').match(/^\d+$/)) {
return 'Invalid pasted data'
}
const currentCharsInOtp = otp.value.slice(0, activeInput.value)
const combinedWithPastedData = currentCharsInOtp.concat(pastedData)
combinedWithPastedData.slice(0, props.numInputs).forEach((value, i) => {
otp.value[i] = value
})
focusInput(combinedWithPastedData.slice(0, props.numInputs).length)
return checkFilledAllInputs()
}
const handleOnChange = (value: number | string) => {
changeCodeAtFocus(value)
focusNextInput()
}
const handleOnKeyDown = (event: KeyboardEvent) => {
switch (event.keyCode) {
case BACKSPACE:
event.preventDefault()
changeCodeAtFocus('')
focusPrevInput()
break
case DELETE:
event.preventDefault()
changeCodeAtFocus('')
break
case LEFT_ARROW:
event.preventDefault()
focusPrevInput()
break
case RIGHT_ARROW:
event.preventDefault()
focusNextInput()
break
default:
break
}
}
</script>
<template>
<div>
<div class="flex ltr justify-around w-full">
<SingleOtpInput
v-for="(item, i) in numInputs"
:key="i"
:focus="activeInput === i"
:value="otp[i]"
:separator="separator"
:input-type="inputType"
:input-classes="inputClasses"
:is-last-child="i === numInputs - 1"
:should-auto-focus="shouldAutoFocus"
:placeholder="placeholder"
:disabled="disabled"
@on-change="handleOnChange"
@on-keydown="handleOnKeyDown"
@on-paste="handleOnPaste"
@on-focus="handleOnFocus(i)"
@on-blur="handleOnBlur"
/>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<!-- Ported from legacy henza_pwa/src/components/input/SingleOtpInput.vue -->
<template>
<div class="otp-single-input">
<input
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
ref="input"
min="0"
max="9"
maxlength="1"
pattern="[0-9]"
v-model="model"
:class="inputClasses"
@input="handleOnChange"
@keydown="handleOnKeyDown"
@paste="handleOnPaste"
@focus="handleOnFocus"
@blur="handleOnBlur"
/>
<span v-if="!isLastChild && separator">
<span v-html="separator"></span>
</span>
</div>
</template>
<script setup lang="ts">
import { onBeforeMount, ref, watch, type Ref } from 'vue'
const props = withDefaults(
defineProps<{
inputType?: string
value?: string | number
separator?: string
focus?: boolean
inputClasses?: string
shouldAutoFocus?: boolean
isLastChild?: boolean
placeholder?: string
disabled?: boolean
}>(),
{ inputType: 'tel' },
)
const emit = defineEmits<{
(e: 'on-change', value: string | number): void
(e: 'on-keydown', value: KeyboardEvent): void
(e: 'on-paste', value: Event): void
(e: 'on-focus'): void
(e: 'on-blur'): void
}>()
const model = ref(props.value || '')
const input = ref<HTMLInputElement | null>(null) as Ref<HTMLInputElement>
const handleOnChange = () => {
model.value = model.value.toString()
if (model.value.length > 1) {
model.value = model.value.slice(0, 1)
}
return emit('on-change', model.value)
}
const isCodeNumeric = (charCode: number) => (charCode >= 48 && charCode <= 57) || (charCode >= 96 && charCode <= 105)
const handleOnKeyDown = (event: KeyboardEvent) => {
if (props.disabled) {
event.preventDefault()
}
const keyEvent = event || window.event
const charCode = keyEvent.which ? keyEvent.which : keyEvent.keyCode
if (isCodeNumeric(charCode) || [8, 9, 13, 37, 39, 46, 86].includes(charCode)) {
emit('on-keydown', event)
} else {
keyEvent.preventDefault()
}
}
const handleOnPaste = (event: ClipboardEvent) => emit('on-paste', event)
const handleOnFocus = () => {
input.value.select()
return emit('on-focus')
}
const handleOnBlur = () => emit('on-blur')
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue !== oldValue) {
model.value = newValue ?? ''
}
},
)
watch(
() => props.focus,
(newFocusValue, oldFocusValue) => {
if (oldFocusValue !== newFocusValue && input.value && props.focus) {
input.value.focus()
input.value.select()
}
},
)
onBeforeMount(() => {
if (input.value && props.focus && props.shouldAutoFocus) {
input.value.focus()
input.value.select()
}
})
</script>
<style lang="less">
.otp-single-input {
display: flex;
align-items: center;
}
</style>
@@ -0,0 +1,236 @@
<!--
Cart line item, restyled into the redesigned design language (see
components/home/* and HomeView): LESS colour tokens, BEM class names,
hairline borders, wine accent.
Ported originally from legacy henza_pwa/src/components/product/cartItem.vue.
Dropped there and still absent: the "اجرت ریالی" (Rial wage) block, which
displayed `selectedVariant.rial_pay` — no such per-variant Rial field exists
anywhere in our domain model, so there is nothing honest to show.
All cart behaviour is unchanged: quantity increment/decrement, remove at
qty 1, and the note affordance that opens the parent's drawer.
-->
<template>
<article class="ci">
<div class="ci__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="ci__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="20" />
</div>
</div>
<div class="ci__main">
<h3 class="ci__title">{{ title }}</h3>
<div class="ci__meta">
<span class="ci__weight">{{ selectedVariant?.rashWeightGrams }} گرم</span>
<span v-if="wage != null" class="ci__wage">اجرت {{ wageLabel }}٪</span>
</div>
<div class="ci__row">
<div class="ci__qty">
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
<Icon icon="vuesax-linear:add" size="16" />
</button>
<span class="ci__qtyValue">{{ value.qty }}</span>
<button
v-if="value.qty === 1"
type="button"
class="ci__qtyBtn ci__qtyBtn--danger"
aria-label="حذف"
@click="cart.remove(value)"
>
<Icon icon="vuesax-linear:trash" size="16" />
</button>
<button v-else type="button" class="ci__qtyBtn" aria-label="کاهش" @click="decrement">
<Icon icon="vuesax-linear:minus" size="16" />
</button>
</div>
<button type="button" class="ci__note" @click="emit('note', index)">
<Icon icon="vuesax-linear:note-text" size="15" />
{{ !value.note || value.note === '' ? 'توضیحات' : 'ویرایش توضیحات' }}
</button>
</div>
<p v-if="value.note" class="ci__noteText">{{ value.note }}</p>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
const cart = useCartStore()
const index = cart.find_index(props.value)
const increment = () => {
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
}
const decrement = () => {
cart.updateFeild(props.value.qty - 1, index, props.value, 'qty')
}
const { image, title, selectedVariant, wage } = useProduct(props.value.product, cart.variant(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">
// Borderless: the cart is one continuous list, and the divider the parent
// draws between rows is what separates them now. Keeping a box per row on top
// of that would draw the same separation twice.
.ci {
display: flex;
gap: 14px;
padding: 16px 2px;
background: transparent;
}
.ci__art {
flex: 0 0 84px;
width: 84px;
height: 84px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
overflow: hidden;
background: @hairlineSoft;
}
.ci__art img {
width: 90px;
height: 90px;
object-fit: contain;
object-position: center;
display: block;
}
.ci__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.ci__main {
flex: 1;
min-width: 0;
}
.ci__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ci__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 5px;
}
.ci__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.ci__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
.ci__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
}
.ci__qty {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 999px;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.ci__qtyBtn {
width: 27px;
height: 27px;
border: 0;
cursor: pointer;
border-radius: 50%;
display: grid;
place-items: center;
color: @inkSoft;
background: @canvas;
transition: transform 0.15s ease, color 0.15s ease;
}
.ci__qtyBtn--danger {
color: @wine;
}
.ci__qtyBtn:active {
transform: scale(0.9);
}
.ci__qtyValue {
min-width: 22px;
text-align: center;
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.ci__note {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: none;
cursor: pointer;
padding: 0;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: @wine;
white-space: nowrap;
}
.ci__noteText {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 11px;
background: @hairlineSoft;
font-size: 11.5px;
line-height: 1.7;
color: @inkMuted;
}
</style>
@@ -0,0 +1,45 @@
<!--
Ported from legacy henza_pwa/src/components/product/category.vue.
Not yet wired to real data: the new API has no categories-with-icon endpoint
(Prisma has a Category model, but no controller/route exposes it, and there
are no per-category icon images). HomeView passes an empty list for now, so
this renders nothing rather than fabricated placeholder tiles — see the
"existing issue found" note in the report about the legacy `id === 45`
special-case, preserved here for whenever real category data exists.
-->
<template>
<router-link :to="{ name: 'search' }" @click="setCats" class="cursor-pointer h-full block">
<div
class="h-full w-full text-center flex flex-col items-center justify-between rounded-[4px]"
:class="value.id === '45' ? 'bg-primary' : 'border border-1 border-secondary'"
>
<div class="flex justify-center mt-4 shrink-0">
<img v-lazy="value.icon?.image" alt="category" class="w-[32px] h-[32px] text-center" />
</div>
<div
class="mb-4 mt-4 py-1 px-1 font-semibold flex-1 flex items-center justify-center text-center min-h-[40px]"
:class="value.id === '45' ? 'text-white' : 'text-primary'"
>
{{ value.title }}
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import { useFiltersStore } from '../../stores/filtersStore'
interface CategoryValue {
id: string
title: string
icon?: { image: string }
}
const props = withDefaults(defineProps<{ value: CategoryValue }>(), {})
const filters = useFiltersStore()
const setCats = () => {
filters.query.cats = [props.value.id]
}
</script>
@@ -0,0 +1,163 @@
<!--
Order summary row, restyled into the redesigned design language (see
components/home/* and HomeView). Ported originally from legacy
henza_pwa/src/components/product/order.vue.
Layout follows the same leading-icon-badge row pattern as every other list
row in the app (TransactionsView's .tx__row, OrderItemCard's .oi__art) —
a status-coloured circular badge on the lead edge, content stacked beside
it — instead of the previous all-stacked-text block, which was the one
list card in the app with no leading visual anchor.
Status colours still come from utils/orderStatus.ts, which is the single
source of truth shared with the detail view — not re-declared here.
-->
<template>
<article class="oc" @click="emit('view', value)">
<span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="16" />
</span>
<div class="oc__main">
<div class="oc__head">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<div class="oc__figures">
<span class="oc__figure">
{{ value.items.length }}
<i>آیتم</i>
</span>
<span class="oc__figure">
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i>
</span>
</div>
<div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
</script>
<style scoped lang="less">
.oc {
display: flex;
gap: 11px;
cursor: pointer;
padding: 12px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.18s ease;
}
.oc:active {
transform: scale(0.99);
}
.oc__mark {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: @hairlineSoft;
color: @inkFaint;
}
.oc__main {
flex: 1;
min-width: 0;
}
.oc__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.oc__id {
margin: 0;
font-size: 13.5px;
font-weight: 800;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oc__status {
flex: 0 0 auto;
padding: 4px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.oc__figures {
display: flex;
gap: 14px;
padding-top: 6px;
}
.oc__figure {
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.oc__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
.oc__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 8px;
}
.oc__date {
font-size: 11px;
color: @inkFaint;
}
.oc__more {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
</style>
@@ -0,0 +1,144 @@
<!--
One line of an order's contents, restyled into the redesigned design
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries
productVariantId.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
ours isn't), so it could only ever print a dash. An always-empty row is
noise, not information.
-->
<template>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
</div>
<div class="oi__main">
<div class="oi__head">
<h4 class="oi__title">{{ lookup?.productTitle ?? '—' }}</h4>
<span v-if="lookup?.variantTitle" class="oi__variant">{{ lookup.variantTitle }}</span>
</div>
<div class="oi__figures">
<span class="oi__figure">
{{ value.quantity }}
<i>عدد</i>
</span>
<span class="oi__figure">
{{ value.computedWeightGrams }}
<i>گرم</i>
</span>
<span class="oi__figure">
{{ value.wagePercentSnapshot }}
<i>٪ اجرت</i>
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>()
onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId)
})
</script>
<style scoped lang="less">
.oi {
display: flex;
gap: 11px;
padding: 11px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 16px;
}
.oi__art {
flex: 0 0 62px;
width: 62px;
height: 62px;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
}
.oi__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
}
.oi__head {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oi__variant {
flex: 0 0 auto;
font-size: 10.5px;
color: @inkFaint;
}
.oi__figures {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding-top: 9px;
}
.oi__figure {
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
@@ -0,0 +1,243 @@
<!--
Product card in the redesigned design language (components/home/*): LESS
colour tokens, BEM class names, wine accent.
This is the grid card used on every catalogue screen (search, collection,
newest, favorites). It is built to the supplied catalogue mock, which is the
same card the home screen's ProductPanel draws: a borderless soft-grey panel
at the 32px radius ProductPanel and the reel CTA use, the piece centred on
it, the title beneath, then the weight, then the making charge. The previous
bordered white card with a full-bleed square image and a right-aligned body
is gone.
Kept from the previous version, because the catalogue needs them and the
home screen does not: the favourite toggle and the stock flag. Both are
overlaid on the art so the content column below stays exactly the mock's
three centred lines.
Deliberately still a sibling of components/home/ShowcaseCard.vue (the home
screen's editorial card, with its `wide` 4:3 hero variant and badge) rather
than a merge — that one is a different card doing a different job.
WHAT IS SHOWN IS REAL — same rule as the rest of the redesign. Only fields
that genuinely exist on ProductSummary today: title, the selected variant's
rash weight and making-charge percent, stock (qty/isPreOrder) and the
catalogue image. No rial price is printed, because a finished piece's price
is the live gold rate x weight plus wage, computed at order time and never
stored on the product.
On the mock's struck-through wage: it shows a discounted making charge
("اجرت ۱۵٪" struck through, beside a red "۱۰٪ اجرت"). 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, exactly as ProductPanel handles it. Until the
backend returns one, the card shows the variant's genuine wage alone: same
layout, minus an invented number.
-->
<template>
<article class="pc" @click="open">
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="26" />
</div>
<button
type="button"
class="pc__fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
:aria-label="favorites.isFavorite(id) ? 'حذف از علاقه‌مندی' : 'افزودن به علاقه‌مندی'"
@click.stop="favorites.toggle(id)"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="16" />
</button>
<span v-if="!is_exist" class="pc__flag">ناموجود</span>
<span v-else-if="isPreOrder && qty === 0" class="pc__flag pc__flag--soft">پیش‌سفارش</span>
</div>
<h3 class="pc__title">{{ title }}</h3>
<p class="pc__weight">{{ min_gram }} <span class="pc__weightUnit">گرم</span></p>
<div v-if="wage != null" class="pc__meta">
<span v-if="wageBefore != null" class="pc__wageOld">اجرت {{ wageLabel(wageBefore) }}٪</span>
<span class="pc__wage">{{ wageLabel(wage) }}٪ اجرت</span>
</div>
</article>
<Drawer
v-model:visible="drawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
</Drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
// `wageBefore` mirrors ProductPanel's prop of the same name: the pre-discount
// making charge, for the mock's struck-through half. Nothing passes it yet
// because no promotions data exists — see the file header.
const props = defineProps<{ value: ProductSummary; wageBefore?: number | null }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const drawer = ref(false)
function open() {
drawer.value = true
}
// Whole percents print bare (۱۰٪), fractional ones keep two places (۱۰.۵٪) —
// the same rule ProductPanel and the detail page use.
function wageLabel(v: number) {
return v % 1 === 0 ? String(v) : v.toFixed(2)
}
</script>
<style scoped lang="less">
// The mock's card: a borderless soft-grey panel, everything centred on it.
// Same fill and radius as the home screen's ProductPanel, so a piece looks
// identical whether it is met on the home rail or in a search result.
.pc {
cursor: pointer;
border-radius: 32px;
background: @hairlineSoft;
padding: 16px 14px 15px;
text-align: center;
transition: transform 0.2s ease, background 0.2s ease;
}
.pc:active {
transform: scale(0.985);
}
// No panel fill of its own and no clipping — the piece floats on the card, as
// in the mock. The overlays are positioned against this box, which is why it
// keeps `position: relative` despite having no background.
.pc__art {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
}
.pc__art img {
max-width: 82%;
max-height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.pc__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
// Overlaid on the art rather than sitting in the content column, so the three
// centred lines below match the mock exactly.
.pc__fav {
position: absolute;
top: 0;
left: 0;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkFaint;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
.pc__fav.is-on {
color: @wine;
background: @canvas;
}
.pc__fav:active {
transform: scale(0.9);
}
.pc__flag {
position: absolute;
top: 0;
right: 0;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @ink;
}
.pc__flag--soft {
background: @wineSoft;
}
.pc__title {
margin: 12px 0 0;
font-size: 12px;
font-weight: 500;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// The mock sets the weight as the card's headline figure, a step above the
// title — it is the number that distinguishes one piece from another here,
// since no price is printed.
.pc__weight {
margin: 5px 0 0;
font-size: 16px;
font-weight: 700;
line-height: 1.4;
color: @inkSoft;
}
.pc__weightUnit {
font-size: 12px;
font-weight: 600;
color: @inkMuted;
}
.pc__meta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-top: 5px;
font-size: 11.5px;
}
.pc__wageOld {
color: @inkFaint;
text-decoration: line-through;
}
.pc__wage {
color: @danger;
font-weight: 500;
font-size: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,290 @@
<!--
Ported from legacy henza_pwa/src/components/search/Filters.vue, restyled
into the redesigned design language (see components/home/* and HomeView) —
previously raw Tailwind utility classes (text-primary, border-grey,
rounded-[5px] Button/Checkbox from ant-design-vue), the one filter sheet in
the app that didn't match the LESS-token look every other surface uses.
Adapted:
- Brand filter: real, but derived client-side from the product list (via
`products` prop) rather than a customer-specific `myStore.state.filterBrand`
list the old backend used to send. Rendered as a toggle-chip list (same
pattern as SingleProduct's variant picker) instead of an ant CheckboxGroup.
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound). Still uses ant-design-vue's Slider for the actual
drag/range behaviour, restyled via :deep() to the wine accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
- DROPPED: "میزان اجرت" (wage-percent range) filter — gated behind
`myStore.bankerPermission` in legacy, i.e. banker-only. Consistent with
the architectural cut already made in DashboardView (no banker/type
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
Top padding (120px) accounts for PageBar + the search input row with no
chips row below it (chips are hidden while this sheet is open) — same
bar-chrome math SearchView/OrdersView use for their own #below content.
-->
<template>
<section class="filt">
<div class="filt__body">
<DropDown text="فیلتر بر اساس برند">
<div v-if="availableBrands.length" class="filt__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="filt__chip"
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
@click="toggleBrand(item.id)"
>
{{ item.title }}
</button>
</div>
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
<DropDown text="فیلتر بر اساس گرم">
<div class="filt__gram">
<Slider
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
/>
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
<span class="filt__gramFigure">
<i>کمترین</i>
{{ modelValue.limit_gram[0] }} گرم
</span>
<span class="filt__gramFigure">
<i>بیشترین</i>
{{ modelValue.limit_gram[1] }} گرم
</span>
</div>
</div>
</DropDown>
</div>
<div class="filt__bar">
<div class="filt__barInner">
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Slider } from 'ant-design-vue'
import DropDown from '../ui/DropDown.vue'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
const emit = defineEmits<{
(e: 'change', value: { resetFilters: boolean }): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of props.products) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
// The Slider's `range` mode needs a real [start, end] value to place its two
// handles — filtersStore's default `limit_gram: []` has neither, so until
// this seeds it, ant-design-vue's Slider has nothing to derive handle
// positions from and renders no handles at all. Only seeds while still
// empty, so it never clobbers a value the user already dragged.
watch(
gramBounds,
(bounds) => {
if (props.modelValue.limit_gram.length === 0) {
props.modelValue.limit_gram = [...bounds]
}
},
{ immediate: true },
)
const toggleBrand = (id: string) => {
const brands = props.modelValue.brands
const index = brands.indexOf(id)
if (index === -1) brands.push(id)
else brands.splice(index, 1)
}
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
if (
props.modelValue.limit_gram[0] !== undefined &&
props.modelValue.limit_gram[1] !== undefined &&
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
) {
filterKeys.push('limit_gram')
}
}
if (sendQuery && filterKeys.length > 0) {
emit('update:filterKeys', filterKeys)
emit('change', { resetFilters: false })
} else {
emit('update:filterKeys', [])
emit('change', { resetFilters: true })
}
}
</script>
<style scoped lang="less">
.filt {
background: @canvas;
min-height: 100vh;
}
.filt__body {
max-width: 768px;
margin: 0 auto;
padding: 120px 16px 110px;
display: flex;
flex-direction: column;
gap: 12px;
}
.filt__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filt__chip {
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.filt__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.filt__chip:active {
transform: scale(0.97);
}
.filt__empty {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.filt__gram {
padding: 4px 6px 0;
}
.filt__gram :deep(.ant-slider-track) {
background-color: @wine;
}
.filt__gram :deep(.ant-slider-handle) {
border-color: @wine;
}
.filt__gram :deep(.ant-slider-rail) {
background-color: @hairline;
}
.filt__gramFigures {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 18px;
}
.filt__gramFigure {
font-size: 12px;
font-weight: 700;
color: @ink;
}
.filt__gramFigure i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-end: 4px;
}
.filt__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 16px;
}
.filt__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.filt__clear,
.filt__apply {
flex: 1;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.15s ease;
}
.filt__clear {
color: @inkSoft;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.filt__apply {
color: #fff;
background: @ink;
}
.filt__clear:active,
.filt__apply:active {
transform: scale(0.98);
}
</style>
@@ -0,0 +1,108 @@
<!--
Ported from legacy henza_pwa/src/components/search/SearchInput.vue, restyled
into the redesigned design language (see components/home/* and HomeView) —
previously still Tailwind utility classes with legacy tokens
(rounded-[5px], border-lightGray), the one search input in the app that
didn't match the LESS-token look every other surface uses.
EXISTING ISSUE, ported as-is: the debounce watcher checks `if (value)` (the
ref object itself, always truthy) instead of `if (value.value)`, so the "x"
clear icon shows after any input and never hides itself based on actual
emptiness — only the explicit clear() click correctly hides it.
-->
<template>
<div class="si">
<Icon icon="vuesax-linear:search-normal-1" class="si__icon" size="18" @click="onSearch" />
<input v-model="value" class="si__input" :placeholder="placeHolder" @keyup.enter="onSearch" />
<button v-if="data.clear" type="button" class="si__clear" aria-label="پاک کردن" @click="clear">
<Icon icon="vuesax-linear:add" size="16" />
</button>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'
import Icon from '../icon/Icon.vue'
defineProps<{ placeHolder: string }>()
const emit = defineEmits<{ (e: 'search', value: string | undefined): void }>()
const data = reactive({ clear: false })
const value = ref<string | undefined>(undefined)
const onSearch = (): void => {
emit('search', value.value)
}
const clear = (): void => {
value.value = undefined
data.clear = false
emit('search', undefined)
}
const debounced = useDebounce(value, 700)
watch(debounced, () => {
if (value) data.clear = true
else data.clear = false
onSearch()
})
</script>
<style scoped lang="less">
.si {
position: relative;
display: flex;
align-items: center;
}
.si__icon {
position: absolute;
right: 14px;
color: @inkFaint;
cursor: pointer;
}
// The catalogue mock's search field: a tall outlined pill on the page's own
// white, not a filled grey box.
.si__input {
width: 100%;
height: 48px;
padding: 0 44px;
font-family: inherit;
font-size: 13px;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
border-radius: 999px;
transition: border-color 0.18s ease, background 0.18s ease;
}
.si__input::placeholder {
color: @inkFaint;
}
.si__input:focus {
outline: none;
background: @canvas;
border-color: @wineSoft;
}
.si__clear {
position: absolute;
left: 10px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkFaint;
background: @hairline;
transform: rotate(45deg);
transition: color 0.18s ease, background 0.18s ease;
}
.si__clear:active {
transform: rotate(45deg) scale(0.92);
}
</style>
@@ -0,0 +1,146 @@
<!--
Ported from legacy henza_pwa/src/components/slider/homeSlider.vue.
`$prefix` (legacy global property, always the compile-time-constant 'montzar'
from themeConfig) is inlined directly here rather than re-registered as a
global — same resolved value, no behavior change.
-->
<template>
<div>
<Swiper
:grabCursor="true"
:centeredSlides="true"
:space-between="5"
:loop="true"
:slidesPerView="1.3"
class="w-full home-slider"
:autoplay="data.autoplay"
:pagination="{ clickable: true }"
>
<SwiperSlide class="bg-center bg-cover h-auto" v-for="(item, index) in slides" :key="index">
<img @click="clickHandler(item)" v-lazy="item.src" width="100%" class="block w-full rounded-[10px] object-cover" :style="`height:${height}`" />
</SwiperSlide>
</Swiper>
<Drawer
v-model:visible="data.show"
placement="bottom"
:closable="false"
@close="setAutoPlay"
height="60%"
class="montzar-rounded-drawer rtl text-white bg-primaryLighten whitespace-pre-wrap text[13px]"
>
{{ data.slideContent.text }}
</Drawer>
</div>
</template>
<script setup lang="ts">
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import 'swiper/css/scrollbar'
import SwiperCore, { Autoplay, Navigation, Pagination } from 'swiper'
SwiperCore.use([Pagination, Navigation, Autoplay])
import { reactive } from 'vue'
import { Drawer } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useFiltersStore } from '../../stores/filtersStore'
interface Slide {
text: string | undefined
type: string | undefined
src: string | undefined
link: string | undefined
}
interface Data {
show: boolean
slideContent: Slide
autoplay: { delay: number; disableOnInteraction: boolean } | boolean
}
withDefaults(defineProps<{ slides: Slide[]; height: string }>(), {})
const data: Data = reactive({
show: false,
slideContent: { text: undefined, type: undefined, src: undefined, link: undefined },
autoplay: { delay: 4000, disableOnInteraction: false },
})
const router = useRouter()
const filters = useFiltersStore()
const clickHandler = async (item: Slide) => {
if (item) {
data.slideContent = item
switch (item.type) {
case 'popup':
if (item && !(!item.text || item.text.length === 0)) data.show = true
break
case 'link':
if (item && !(!item.link || item.link.length === 0)) window.open(item.link)?.focus()
break
case 'discount':
if (!(!item.link || item.link.length === 0)) {
// EXISTING ISSUE, ported as-is: legacy sets `query.value.discount`,
// but the actual filter field is `has_discount` — this assignment
// never affected search filtering in legacy either. Not "fixed"
// here; flagged separately.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(filters.query as any).discount = true
await router.push({ name: 'search' })
}
break
default:
// code block
}
data.autoplay = false
}
}
const setAutoPlay = async () => {
data.autoplay = { delay: 4000, disableOnInteraction: false }
}
</script>
<style lang="less">
.home-slider {
&.swiper {
padding: 24px 0 24px 0;
}
.swiper-pagination-bullet {
transition: all 0.2s;
width: 6px;
height: 6px;
}
.swiper-pagination-bullet-active,
.swiper-pagination-bullet-active-main {
width: 24px;
border-radius: 10px;
}
.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal,
.swiper-pagination-custom,
.swiper-pagination-fraction {
bottom: 0 !important;
}
.swiper-pagination-bullets-dynamic * {
transform: unset !important;
}
.swiper-pagination-bullet-active {
background: @primary;
}
.swiper-slide {
transition: all 0.6s;
}
.swiper-slide:not(.swiper-slide-active) {
transform: scale(0.9);
}
}
</style>
@@ -0,0 +1,139 @@
<!--
Ported from legacy henza_pwa/src/components/slider/slider.vue.
EXISTING ISSUE, ported as-is (not fixed): `showPopup` takes (src, index) but
the template calls it with a single argument (`showPopup(item)`), and the
v-for doesn't even destructure an index. `index` is therefore always
undefined inside showPopup, so its "rotate the slides array to start at the
tapped image" logic never runs — tapping any thumbnail always opens the
full-screen zoom viewer starting from the first image, never the tapped one.
-->
<template>
<div>
<Swiper :grabCursor="true" :centeredSlides="true" :loop="false" :slidesPerView="1" class="w-full slider" :pagination="{ clickable: true }">
<SwiperSlide class="bg-center bg-cover h-auto" v-for="item in slides" :key="item">
<img @click="showPopup(item)" v-lazy="item" class="block w-auto object-cover z-0 pb-1" :style="`height:${height}`" />
</SwiperSlide>
<transition name="slideUp" mode="out-in">
<svg alt="منتزر" class="absolute bottom-0 z-[2] m-auto pointer-events-none w-full h-[90px]" v-if="flagSvg">
<path fill="#FFFFFF" class="w-full h-[90px]" :d="whiteShape" />
<path fill="#3E5279" class="w-full h-[90px]" :d="curvedShape" />
</svg>
</transition>
</Swiper>
<Drawer v-model:visible="show" placement="bottom" :closable="false" :bodyStyle="{ padding: 0 }" class="rtl whitespace-pre-wrap bg-white" height="100vh">
<div @click="show = false" class="fixed top-[30px] right-[20px] z-[5] pb-2 pl-5">
<Icon icon="vuesax-linear:arrow-right-1" @click="show = false" class="cursor-pointer text-white bg-secondary rounded-[5px] p-[2.5px]" size="28" />
</div>
<Swiper :grabCursor="true" :zoom="{ maxRatio: 1.2 }" :modules="[Zoom]" :centeredSlides="true" :loop="false" :slidesPerView="1" class="w-full slider" :key="show">
<SwiperSlide class="bg-center bg-cover h-auto" v-for="item in slides" :key="item">
<div class="swiper-zoom-container">
<img v-lazy="item" class="block object-contain w-[100vw] h-screen" />
</div>
</SwiperSlide>
</Swiper>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import 'swiper/css/scrollbar'
import 'swiper/css/zoom'
import SwiperCore, { Navigation, Pagination, Zoom } from 'swiper'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { useWindowDataStore } from '../../stores/windowDataStore'
SwiperCore.use([Pagination, Navigation])
const props = withDefaults(defineProps<{ slides: string[]; height: string; width?: string }>(), {})
const show = ref(false)
const slides = ref<string[]>([...props.slides])
const showPopup = (src: string, index?: number) => {
slides.value = [...props.slides]
for (let i = 0; i < (index ?? 0); i++) {
const helper = slides.value.shift()
if (helper) slides.value.push(helper)
}
show.value = true
}
const flagSvg = ref(false)
onBeforeMount(() => {
setTimeout(() => {
flagSvg.value = true
}, 100)
})
const windowData = useWindowDataStore()
const shapeHeight = 90
const wcp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 21.8878249)
const wcp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 2.37406336)
const crcr1cp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 1.56947374)
const crcr1cp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 5.47609844)
const crcr2cp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 15.8024691)
const crcr2cp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 2.2606383)
const whiteShape = computed(() => `M0,${shapeHeight}H${Math.min(windowData.width, 768)}V0C${wcp1x.value},19.17,${wcp2x.value},119.08,0,49Z`)
const curvedShape = computed(
() => `M0,39.77C${crcr1cp1x.value},91.74,${crcr1cp2x.value},61.5,${Math.min(windowData.width, 768)},0,C${crcr2cp1x.value},29.34,${crcr2cp2x.value},112.54,0,57.1Z`,
)
</script>
<style lang="less">
.slider {
.swiper-pagination-bullet {
transition: all 0.2s;
width: 6px;
height: 6px;
}
.swiper-pagination-bullet-active,
.swiper-pagination-bullet-active-main {
width: 24px;
border-radius: 10px;
}
.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal,
.swiper-pagination-custom,
.swiper-pagination-fraction {
bottom: 40px !important;
}
.swiper-pagination-bullets-dynamic * {
transform: unset !important;
}
.swiper-pagination-bullet-active {
background: @primary;
}
.swiper-slide img {
margin: auto;
}
}
.slideUp-enter-active,
.slideUp-leave-active {
opacity: 1;
transition: all @transition-duration ease @transition-delay;
}
.slideUp-enter-from {
opacity: 0;
transform: translateY(100%);
}
.slideUp-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style>
@@ -0,0 +1,13 @@
<!-- Ported from legacy henza_pwa/src/components/ui/container.vue -->
<template>
<main class="px-[16px] max-w-screen-md m-auto relative no-scrollbar">
<slot />
</main>
</template>
<script setup lang="ts">
interface Props {
padding?: string
}
withDefaults(defineProps<Props>(), {})
</script>
@@ -0,0 +1,12 @@
<!-- Ported from legacy henza_pwa/src/components/content.vue -->
<template>
<main :style="{ marginTop: transition.transitionEnded ? `${transition.headerHeight}px` : 'unset' }">
<slot />
</main>
</template>
<script setup lang="ts">
import { useTransitionStore } from '../../stores/transitionStore'
const transition = useTransitionStore()
</script>
@@ -0,0 +1,97 @@
<!--
Ported from legacy henza_pwa/src/components/dropDown.vue, restyled into the
redesigned design language (see components/home/* and HomeView) — previously
raw Tailwind utility classes with legacy tokens (text-primary,
border-lightGray/20), the one accordion in the app that didn't match the
LESS-token look every other surface uses.
-->
<template>
<div class="dd" :class="{ 'is-open': show }">
<button type="button" class="dd__head" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h3>
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
</button>
<transition name="drop" mode="out-in" appear>
<div class="dd__body" v-if="show">
<slot />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
</script>
<style scoped lang="less">
.dd {
padding: 15px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.dd.is-open {
border-color: @wineSoft;
}
.dd__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: 0;
padding: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
}
.dd__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
color: @wine;
}
.dd__body {
overflow: hidden;
padding-top: 15px;
}
</style>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
.drop-leave-to {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
}
@keyframes drop {
0% {
max-height: 0;
}
100% {
max-height: 240px;
}
}
</style>
@@ -0,0 +1,119 @@
<!--
Redesigned bottom nav — replaces the old GSAP SVG-notch bar (4 tabs: user,
cart, search, home) with a flat pill-bar of 5 tabs (Home, Search, Likes,
Notifications, Profile), each showing icon + label, and a single sliding
indicator behind the active tab instead of a cut-out notch.
windowData/SVG path math from the old version is gone — the indicator is
now pure CSS, positioned with a transform driven by the active tab's index,
so it needs no measurement of the container width.
-->
<template>
<nav class="tabbar" v-show="nav.showNav">
<div class="tabbar__inner" :style="{ '--count': nav.navBarRoutes.length }">
<div
class="tabbar__indicator"
:style="{ transform: `translateX(${indicatorOffset}%)` }"
aria-hidden="true"
/>
<router-link
v-for="item in nav.navBarRoutes"
:key="item.index"
:to="{ name: item.name }"
class="tabbar__item"
:class="{ 'is-active': nav.activeLink === item.index }"
>
<Icon :icon="item.icon" size="21" />
<span class="tabbar__label">{{ item.label }}</span>
</router-link>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useNavStore } from '../../stores/navStore'
const nav = useNavStore()
// Index is 1-based; the indicator sits in tab slot (activeLink - 1) out of N.
// The whole app runs `direction: rtl` (see App.vue), which flips the grid's
// visual column order but NOT a physical translateX — so slot 0 renders on
// the right, and moving right needs a NEGATIVE offset here.
const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
</script>
<style scoped lang="less">
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: transparent;
}
.tabbar__inner {
position: relative;
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
max-width: 420px;
margin: 0 auto;
padding: 6px;
border-radius: 22px;
background: @canvas;
border: 1px solid @hairline;
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
}
.tabbar__indicator {
position: absolute;
inset: 6px 6px 6px auto;
width: calc((100% - 12px) / var(--count));
border-radius: 16px;
background: @wineWash;
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
.tabbar__item {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 4px;
border-radius: 16px;
color: @inkFaint;
text-decoration: none;
transition: color 0.25s ease, transform 0.25s cubic-bezier(0.65, 0, 0.35, 1.4);
}
.tabbar__item :deep(svg) {
transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
}
.tabbar__item.is-active {
color: @wine;
transform: translateY(-2px);
}
.tabbar__item.is-active :deep(svg) {
transform: scale(1.08);
}
.tabbar__item:active {
transform: scale(0.94);
}
.tabbar__label {
font-size: 10px;
font-weight: 600;
line-height: 1;
opacity: 0.9;
}
</style>
@@ -0,0 +1,117 @@
<!--
Sticky translucent page bar in the redesigned design language, extracted from
the home screen's own `.home__bar` so every other screen gets the identical
chrome instead of the old dark AppHeader.
Layout mirrors the home bar exactly: a leading action (usually back), a
centred title, and an optional trailing action via the `trailing` slot — so
the bar's height, blur, hairline and icon buttons stay consistent app-wide.
-->
<template>
<header class="bar">
<div class="bar__inner">
<button v-if="back" type="button" class="bar__btn" aria-label="بازگشت" @click="onBack">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<span v-else class="bar__spacer" aria-hidden="true" />
<h1 class="bar__title">{{ title }}</h1>
<div class="bar__trailing">
<slot name="trailing">
<span class="bar__spacer" aria-hidden="true" />
</slot>
</div>
</div>
<slot name="below" />
</header>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
const props = withDefaults(
defineProps<{
title: string
/** Show the back button. */
back?: boolean
/** Route name to go to instead of history.back(). */
backTo?: string
}>(),
{ back: true },
)
const router = useRouter()
function onBack() {
if (props.backTo) router.push({ name: props.backTo })
else router.back()
}
</script>
<style scoped lang="less">
.bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.bar__inner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.bar__title {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar__btn {
position: relative;
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
transition: background 0.18s ease;
}
.bar__btn:active {
transform: scale(0.94);
}
.bar__trailing {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.bar__spacer {
display: block;
width: 38px;
height: 38px;
}
</style>
@@ -0,0 +1,41 @@
<!-- Ported from legacy henza_pwa/src/components/ui/ScrollFetch.vue — the plain
infinite-scroll container (default ant Empty, no custom illustration). -->
<template>
<main
class="overflow-auto no-scrollbar w-[100vw] h-screen max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import Spinner from '../Spinner.vue'
import EmptyState from '../EmptyState.vue'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>
@@ -0,0 +1,48 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchAfterSearch.vue.
Legacy imported emp_after_search.svg / emp_before_search.svg and an `isSearch`
prop to pick between them, but the live template used neither — it rendered
`ordersEmpty.svg` unconditionally. Ported as-is (unused imports dropped,
`isSearch` kept since it is part of the component's declared prop contract).
-->
<template>
<main
class="overflow-auto no-scrollbar w-[100vw] h-screen max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !loading" :image="ordersEmpty" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import Spinner from '../Spinner.vue'
import EmptyState from '../EmptyState.vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
isSearch?: boolean
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>
@@ -0,0 +1,50 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchSearch.vue.
Legacy rendered two separate empty states, gated on an `isFav` prop, that
both used the SAME favoritEmpty image — a distinction without a difference,
preserved verbatim while this was a faithful port. The only caller that ever
passed `is-fav` was FavoritesView, which now renders its own empty state in
the redesigned language, so the prop had no remaining callers and the two
identical branches collapse to one.
-->
<template>
<main
class="flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag" :image="favoritEmpty" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import EmptyState from '../EmptyState.vue'
import Spinner from '../Spinner.vue'
import favoritEmpty from '../../assets/img/empty/favoritEmpty.svg'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>