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