SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروش‌های این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.

DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):

Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.

Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.

LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
s.esmaeili
2026-09-18 12:54:45 +03:30
parent 05fe780c89
commit 53a304b5d5
355 changed files with 22865 additions and 14 deletions
@@ -0,0 +1,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