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,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>