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,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
Reference in New Issue
Block a user