SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروشهای این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
@@ -0,0 +1,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.
|
||||
-->
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user