user-app: extend the redesign across the purchase path

Applies the home/product-detail design language (LESS colour tokens, BEM
class names, hairline-bordered cards, SectionHead, dashed empty states) to
the screens a shopper actually walks through: search, collection, newest,
cart and favorites.

Two shared pieces are extracted so the language stays consistent rather than
re-typed per screen:
  - components/ui/PageBar.vue — the sticky translucent bar with blur and a
    hairline, lifted from the home screen's own header so every other screen
    gets identical chrome instead of the old dark AppHeader.
  - components/product/ProductCard.vue — the catalogue workhorse card. It is
    deliberately a sibling of home/ShowcaseCard (which keeps its editorial "wide"
    variant and badge) and additionally surfaces stock, which the
    catalogue needs and the home screen does not.

Deleted as orphaned once nothing imported them: ProductList,
ProductVerticalFull, ProductVertical, ProductHorizontal, CollectionCard —
all Tailwind utility soup carried over from the legacy PWA.

Behaviour is preserved throughout; only chrome and cards change. The cart
still posts to the real POST /orders with wagePercent from each item's
pinned variant, still gates on a real session, and still keeps the note
drawer and success step. Search keeps its client-side filtering, the Filters
sheet, category chips and collection tiles. No rial price is printed
anywhere, for the same reason as the rest of the redesign: a finished
piece's price is the live gold rate x weight plus wage, computed at order
time and never stored on the product.

FIXED a regression this redesign introduced before it shipped: dropping
ScrollFetchSearch from the search grid meant all 205 catalogue products
mounted at once (205 cards, 205 images). Both search and newest now render
a 20-item window that grows as the user reaches the end.

Verified in a real browser against the running API and native database, not
just a green build: home 13 cards, search 20 -> 40 -> 60 -> 80 on scroll,
newest 20 -> 40 -> 60, cart empty state, favorites 3 cards behind a seeded
session. All images loaded (0 broken), no horizontal overflow, no console
errors beyond pre-existing vue3-lazyload noise that predates this work.
vue-tsc and vite build both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-15 01:01:19 +03:30
co-authored by Claude Opus 5
parent f2d83c53df
commit cc62975dd0
14 changed files with 1302 additions and 537 deletions
+197 -48
View File
@@ -1,54 +1,71 @@
<!--
Ported from legacy henza_pwa/src/components/product/cartItem.vue.
Dropped: the "اجرت ریالی" (Rial wage) block, which displayed
`selectedVariant.rial_pay` — no such per-variant Rial field exists anywhere
in our domain model (not a stubbed-off feature like promotions, genuinely
nothing to adapt), so there's nothing honest to show here.
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>
<div class="border border-solid border-[#E5E7EB] bg-[#F9F9F9] block w-full rounded-[10px] p-[8px] flex">
<img v-lazy="image" class="w-[70px] h-[70px] m-auto" />
<div class="flex-1">
<h6 class="font-bold mb-2">{{ title }}</h6>
<div class="flex justify-between text-center mb-4">
<div class="flex-col justify-start">
<div class="mb-2 text-[11px]">وزن حدودی</div>
<div class="text-secondary text-[14px] font-bold">{{ selectedVariant?.rashWeightGrams }} گرم</div>
</div>
<div>
<div v-if="showDiscountWage" class="mb-2 text-[11px]">اجرت</div>
<div v-if="showDiscountWage" :class="{ 'text-[14px] font-bold text-secondary': true, 'line-through decoration-inherit ml-4': hasDiscountWage }">
{{ wage?.toFixed(2) }}% اجرت
</div>
<div class="text-[14px] font-bold text-danger" v-if="showDiscountWage">{{ hasDiscountWage?.toFixed(2) }}% اجرت</div>
</div>
<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 class="flex justify-between">
<div class="mb-4">تعداد</div>
<div class="flex">
<Icon icon="vuesax-linear:add-square" class="cursor-pointer text-secondary" size="20" @click="increment" />
<span class="mx-[16px]">{{ value.qty }}</span>
<Icon icon="vuesax-linear:trash" @click="cart.remove(value)" v-if="value.qty === 1" class="cursor-pointer text-danger" size="20" />
<Icon icon="vuesax-linear:minus-square" @click="decrement" v-else class="cursor-pointer text-secondary" size="20" />
</div>
</div>
<Button class="w-full" type="primary" @click="emit('note', index)">
<template v-if="!value.note || value.note === ''">ثبت توضیحات</template>
<template v-else>ویرایش توضیحات</template>
</Button>
</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 { Button } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
const props = withDefaults(defineProps<{ value: CartItem }>(), {})
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
const cart = useCartStore()
@@ -61,22 +78,154 @@ const decrement = () => {
cart.updateFeild(props.value.qty - 1, index, props.value, 'qty')
}
const { image, title, selectedVariant, wage, hasDiscountWage } = useProduct(props.value.product, cart.variant(props.value))
const { image, title, selectedVariant, wage } = useProduct(props.value.product, cart.variant(props.value))
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style lang="less">
.description-item {
border-left: 1px solid @grey;
<style scoped lang="less">
.ci {
display: flex;
gap: 12px;
padding: 11px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
}
.description-item:last-child {
padding-left: 0;
border-left: 0;
.ci__art {
flex: 0 0 76px;
width: 76px;
height: 76px;
border-radius: 13px;
overflow: hidden;
background: @hairlineSoft;
}
.description-item:first-child {
padding-right: 0;
.ci__art img {
width: 100%;
height: 100%;
object-fit: cover;
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>
@@ -1,15 +0,0 @@
<!-- Ported from legacy henza_pwa/src/views/search/collection/components/CollectionCard.vue -->
<template>
<div class="flex flex-col items-center justify-center font-bold text-lg" style="color: #8c6a48">
<img class="w-[6rem] h-[6rem]" :src="image" />
<span>{{ minGram }} گرم</span>
</div>
</template>
<script setup lang="ts">
import { useProduct } from '../../composables/useProduct'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ item: ProductSummary }>()
const { image, min_gram: minGram } = useProduct(props.item)
</script>
@@ -0,0 +1,199 @@
<!--
Product card in the redesigned design language (components/home/*): LESS
colour tokens, BEM class names, hairline borders, wine accent.
This is the grid/reel card used everywhere outside the home screen (search,
collection, newest, favorites). It is deliberately a sibling of
components/home/ShowcaseCard.vue rather than a merge of the two: ShowcaseCard
is the home screen's editorial card (supports a `wide` 4:3 hero variant and a
badge), while this one is the catalogue workhorse and additionally surfaces
stock, which the catalogue needs and the home screen does not.
Replaces ProductVertical/ProductVerticalFull, whose markup was Tailwind
utility soup carried over from the legacy PWA.
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.
-->
<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>
<div class="pc__body">
<h3 class="pc__title">{{ title }}</h3>
<div class="pc__meta">
<span class="pc__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="pc__wage">اجرت {{ wageLabel }}٪</span>
</div>
</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 { computed, 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'
const props = defineProps<{ value: ProductSummary }>()
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
}
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">
.pc {
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.pc:active {
transform: scale(0.985);
}
.pc__art {
position: relative;
aspect-ratio: 1 / 1;
background: @hairlineSoft;
}
.pc__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pc__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.pc__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;
}
.pc__fav.is-on {
color: @wine;
}
.pc__fav:active {
transform: scale(0.9);
}
.pc__flag {
position: absolute;
top: 9px;
right: 9px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @ink;
}
.pc__flag--soft {
background: @wineSoft;
}
.pc__body {
padding: 11px 12px 13px;
}
.pc__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pc__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 7px;
}
.pc__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.pc__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
</style>
@@ -1,65 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productHorizontal.vue.
See useProduct.ts for the honest gaps vs. legacy (no promotions/warranty/
stock backend data yet).
-->
<template>
<div @click="openDrawer" class="block flex items-stretch rounded-[10px] bg-light px-[12px] py-[12px] cursor-pointer mb-4" v-bind="$attrs">
<img v-lazy="image" class="ml-[12px] w-[70px]" />
<div class="flex-1" style="width: calc(100% - 82px)">
<div class="w-full title flex justify-between">
<h5 class="font-bold h-[25px] text-black flex-1 truncate mb-4">{{ title }}</h5>
<div v-if="favorites.isFavorite(id)">
<Icon icon="vuesax-linear:heart-fill" class="cursor-pointer text-secondary z-[2]" @click.stop="favorites.toggle(id)" size="25" />
</div>
<div v-else>
<Icon icon="vuesax-linear:heart" class="cursor-pointer text-secondary z-[2]" @click.stop="favorites.toggle(id)" size="25" />
</div>
</div>
<div class="font-bold text-[15px] text-secondary text-align-start mb-4">{{ min_gram }} گرم</div>
<div>
<div class="flex justify-between items-center">
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary text-align-start': true, 'line-through decoration-inherit ml-8': hasDiscountWage }"
>{{ wage != null && wage % 1 === 0 ? wage : wage?.toFixed(2) }}% اجرت</span
>
</div>
<div v-if="has_warranty" class="flex mt-2 font-bold">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] mr-2">تضمین بهترین قیمت</div>
</div>
</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, 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 { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
// legacy: myStore.bankerPermission && hasDiscountWage && isUserLoggedIn() — no
// banker/login state exists yet, so this always evaluates false for now.
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
@@ -1,44 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productList.vue.
The "ciny"/collection tile grid (only shown on the /search route) now
receives real collection data (see catalog.service.ts's `collections`
include, added specifically to power this).
-->
<template>
<ScrollFetchSearch class="h-screen" :loading="loading" :flag="flag" :padding="padding" :is-fav="isFav" @fetch="emit('pull')">
<div v-if="$route.name === 'search'" class="grid grid-cols-2 gap-2">
<div
v-for="item in ciny"
:key="item.id"
class="w-full bg-light flex justify-center rounded-[8px] border-gray-200 border-[1px] p-3 items-center gap-2 cursor-pointer"
@click="$router.push({ path: `/collection/${item.id}`, query: { page: item.title } })"
>
<Icon icon="vuesax-linear:money-recive" class="cursor-pointer" size="25" />
<div class="text-lg font-bold mt-1">{{ item.title }}</div>
</div>
</div>
<div class="mt-4">
<ProductVerticalFull :class="index > 0 ? 'mt-[16px]' : undefined" v-for="(item, index) in value" :key="item.id" :value="item" />
</div>
</ScrollFetchSearch>
</template>
<script setup lang="ts">
import Icon from '../icon/Icon.vue'
import ProductVerticalFull from './ProductVerticalFull.vue'
import ScrollFetchSearch from '../ui/ScrollFetchSearch.vue'
import type { ProductSummary } from '../../api/client'
withDefaults(
defineProps<{
value: ProductSummary[]
ciny?: Array<{ id: string; title: string }>
loading: boolean
flag: boolean
padding?: string
isFav?: boolean
}>(),
{ ciny: () => [] },
)
const emit = defineEmits<{ (e: 'pull'): void }>()
</script>
@@ -1,75 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productVertical.vue.
See useProduct.ts for the honest gaps vs. legacy.
-->
<template>
<div @click="openDrawer" class="block relative w-[280px] rounded-[10px] p-[8px] bg-light" v-bind="$attrs">
<Icon
v-if="favorites.isFavorite(id)"
icon="vuesax-linear:heart-fill"
class="cursor-pointer text-secondary absolute top-[15.5px] left-[14px]"
@click.stop="favorites.toggle(id)"
size="25"
/>
<Icon
v-else
icon="vuesax-linear:heart"
class="cursor-pointer text-secondary absolute top-[15.5px] left-[14px]"
@click.stop="favorites.toggle(id)"
size="25"
/>
<span
class="absolute top-[15.5px] right-[14px] text-danger text-[12px] px-[8px] py-[4px] rounded-[5px] border-[1px] border-danger"
v-if="hasDiscountWage"
>تخفیف</span
>
<img v-lazy="image" class="w-[200px] h-[200px] mt-[35px] m-auto" />
<div class="bg-white rounded-[5px] p-[16px] mt-[8px]">
<div class="flex mb-[16px]">
<div class="font-bold text-[15px] pl-2 text-secondary border-solid border-grey" :class="isUserLoggedIn() ? 'border-l' : ''">{{ min_gram }} گرم</div>
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary mr-2 text-align-start': true, 'line-through decoration-inherit ml-2': hasDiscountWage }"
>{{ wage?.toFixed(2) }}% اجرت</span
>
</div>
<h5 class="font-bold text-[16px] text-black m-0">{{ title }}</h5>
<div v-if="has_warranty" class="flex mt-2">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] text-xl mr-2">تضمین بهترین قیمت</div>
</div>
<div v-else>
<div>&nbsp;</div>
<div>&nbsp;</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, 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 { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
@@ -1,66 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productVerticalFull.vue.
See useProduct.ts for the honest gaps vs. legacy.
-->
<template>
<div @click="openDrawer" class="block w-full rounded-[10px] p-[8px] bg-light cursor-pointer relative mb-4" v-bind="$attrs">
<img v-lazy="image" class="w-[277px] h-[277px] m-auto" />
<span
class="absolute top-[15.5px] right-[14px] text-danger text-[12px] px-[8px] py-[4px] rounded-[5px] border-[1px] border-danger"
v-if="hasDiscountWage"
>تخفیف</span
>
<div class="bg-white rounded-[5px] p-[16px] mt-[8px] relative">
<div class="flex w-full gap-4">
<h5 class="font-bold text-[17px] text-black mb-[16px] flex-1 truncate">{{ title }}</h5>
<Icon
v-if="favorites.isFavorite(id)"
icon="vuesax-linear:heart-fill"
class="cursor-pointer text-secondary"
@click.stop="favorites.toggle(id)"
size="25"
/>
<Icon v-else icon="vuesax-linear:heart" class="cursor-pointer text-secondary" @click.stop="favorites.toggle(id)" size="25" />
</div>
<div class="flex">
<div class="font-bold text-[15px] pl-2 text-secondary border-solid border-grey" :class="showDiscountWage ? 'border-l' : ''">{{ min_gram }} گرم</div>
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary mr-2 text-align-start': true, 'line-through decoration-inherit ml-4': hasDiscountWage }"
>{{ wage?.toFixed(2) }}% اجرت</span
>
</div>
<div v-if="has_warranty" class="flex mt-2">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] text-xl mr-2">تضمین بهترین قیمت</div>
</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, 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 { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
+115
View File
@@ -0,0 +1,115 @@
<!--
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: sticky;
top: 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>
+5 -3
View File
@@ -1,7 +1,9 @@
// Adapts our actual ProductSummary (apps/user-app/src/api/client.ts) into the
// same output shape legacy's Mixins/product/index.ts `useProduct()` produced,
// so the ported product-card components (ProductHorizontal/ProductVertical/
// ProductVerticalFull) can render unchanged.
// same output shape legacy's Mixins/product/index.ts `useProduct()` produced.
// The legacy card components it was originally shaped for (ProductHorizontal/
// ProductVertical/ProductVerticalFull) are gone, replaced by the redesigned
// components/product/ProductCard.vue, components/home/ShowcaseCard.vue and
// components/product/CartItem.vue — all of which consume this same contract.
//
// Honest gaps vs. legacy (no backend equivalent exists yet — apps/api was not
// touched to add these):
+295 -113
View File
@@ -1,89 +1,114 @@
<!--
Ported from legacy henza_pwa/src/views/cart.vue.
Ported from legacy henza_pwa/src/views/cart.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
"ثبت نهایی سفارش" (submit order) is wired for real against the actual,
already-existing POST /orders endpoint — not a stub. Legacy's own
`/set-order` payload had two hardcoded placeholder numbers with the
original author's own comment "how to get?!" (wage_weight/weight sent
regardless of actual cart contents) — a genuine, self-acknowledged
incompleteness in legacy itself, not something to faithfully reproduce.
The real Orders API computes gold weight server-side from
productVariantId + wagePercent, so there's nothing dubious to port here;
wagePercent is taken from the cart item's pinned variant.
EVERY REAL BEHAVIOUR IS CARRIED OVER UNCHANGED — only the chrome changes:
- "ثبت نهایی سفارش" still posts to the real POST /orders endpoint. Legacy's
own `/set-order` payload had two hardcoded placeholder numbers with the
original author's comment "how to get?!"; the real Orders API computes
gold weight server-side from productVariantId + wagePercent, so
wagePercent is taken from the cart item's pinned variant.
- submitting requires being logged in for real (the Orders API requires a
real customerId). Legacy's router didn't gate the cart route, so this
gate is an adaptation, kept from the previous version.
- the per-item note drawer, the confirm dialog and the success step are
all unchanged in behaviour.
ADAPTED (not in legacy's cart.vue): submitting now requires being logged in
for real (a real customerId is required by the Orders API). Legacy's own
router didn't gate the cart route itself, so this gate is new — reusing the
same login-nudge dialog pattern as Home/SingleProduct for a consistent UX
rather than a raw failure.
No rial total is printed, for the same reason the rest of the redesign
prints no price: the figure is the live gold rate x weight plus wage,
computed at order time. The honest total the cart can show is total weight,
which is what it shows.
"لیست سفارشات" routes to `home` instead of the legacy `ordersList` route,
which doesn't exist in this port yet (deferred).
EXISTING ISSUE, ported as-is: the unscoped `.rtl { direction: ltr }` rule
below is dead — it can never actually apply, since the global `.rtl`
defined in style.less carries `!important` and a non-!important rule can
never override an !important one regardless of load order. Kept for fidelity.
DROPPED: the old unscoped `.rtl { direction: ltr }` rule, which was dead —
the global `.rtl` in style.less carries !important, so a non-!important rule
could never override it. It was previously kept "for fidelity" while the
file was a faithful port; now that the file is deliberately redesigned,
carrying a rule that provably cannot apply is just noise.
-->
<template>
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<nav class="flex justify-center items-center">
<div class="flex mb-0 items-center h-[19px]">
<Icon icon="vuesax-linear:bag-happy" class="ml-2 text-white" size="19" />
<h4 class="bold mb-0 text-white">سبد خرید</h4>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<template v-if="cartData.step === 1">
<template v-if="cart.items.length">
<SectionHead title="اقلام سبد" :subtitle="`${cart.items.length} قلم`" />
<div class="cart__items">
<CartItem
v-for="item in cart.items"
:key="item.pid + item.vid"
:value="item"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید شما خالی است.</p>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</template>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
<div class="cart__doneIcon" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="30" />
</div>
<span />
</nav>
</AppHeader>
<h2 class="cart__doneTitle">سفارش شما ثبت شد</h2>
<p class="cart__doneSub">
شماره سفارش
<b>{{ cartData.orderID }}</b>
</p>
<p class="cart__doneHint">برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید.</p>
<button type="button" class="cart__doneCta" @click="goOrders">فهرست سفارش‌ها</button>
</section>
</container>
</Spinner>
<!-- Sticky summary + submit -->
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<div class="cart__barInner">
<div class="cart__total">
<span class="cart__totalLabel">جمع وزن</span>
<span class="cart__totalValue">
{{ cart.total() }}
<i>گرم</i>
</span>
</div>
<button type="button" class="cart__submit" @click="cartData.notify = true">ثبت نهایی سفارش</button>
</div>
</div>
<div class="h-16"></div>
<container class="overflow-y-scroll pt-4">
<div v-show="cartData.step === 1">
<EmptyState v-if="cart.items.length === 0" :image="cartEmpty" />
<template v-else>
<CartItem
v-for="item in cart.items"
:value="item"
:key="item.pid + item.vid"
class="mb-4"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</template>
<!-- Per-item note -->
<Drawer
v-model:visible="cartData.showPopUp"
placement="bottom"
v-if="cartData.tempRow?.product"
:closable="false"
height="300px"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="sheet">
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote">
<FormItem has-feedback name="note">
<Textarea v-model:value="formModel.note" :rows="4" placeholder="مثلاً سایز یا جزئیات سفارش" />
</FormItem>
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
</Form>
</div>
<div v-show="cartData.step === 2" class="h-full flex justify-center items-center flex-col">
<h4 class="text-primary">اطلاعات سفارش با موفقیت ثبت شد</h4>
<div class="mb-2 text-secondary">شماره سفارش {{ cartData.orderID }}</div>
<small class="mb-2 text-secondary">برای بررسی وضعیت سفارش خود روی لیست سفارشات کلیک کنید</small>
<Button
type="primary"
@click="
async () => {
await router.push({ name: 'home' });
cartData.step = 1;
}
"
>
لیست سفارشات
</Button>
</div>
</container>
<Drawer v-model:visible="cartData.showPopUp" placement="bottom" v-if="cartData.tempRow?.product" :closable="false" height="280px" :class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']">
<div class="mb-2 font-semibold">توضیح برای محصول {{ cartData.tempRow.product.title }}</div>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote" class="henza-form bg-primaryLighten">
<FormItem has-feedback name="note">
<Textarea v-model:value="formModel.note" :rows="5" />
</FormItem>
<Button class="btn-px-double w-full" type="primary" size="large" html-type="submit">ثبت</Button>
</Form>
</Drawer>
<DialogVant :timeOut="-1" v-model:show="cartData.notify" :closeOnClickOverlay="true">
@@ -109,31 +134,19 @@
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<div class="h-36"></div>
<div class="shadowset flex flex-row-reverse p-[16px] pb-[75px] justify-between fixed bottom-0 w-full bg-white z-[2] items-center border-t border-solid border-primary/10 shadow-md" v-if="cart.items.length > 0 && cartData.step === 1">
<div class="text-[16px]">
جمع وزن :
<span class="font-bold">{{ cart.total() }}</span
><span class="pr-1">گرم</span>
</div>
<Button class="w-1/2" type="primary" @click="cartData.notify = true">ثبت نهایی سفارش</Button>
</div>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import cartEmpty from '../assets/img/empty/cartEmpty.svg'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import EmptyState from '../components/EmptyState.vue'
import SectionHead from '../components/home/SectionHead.vue'
import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
@@ -164,6 +177,13 @@ const setNote = () => {
cartData.tempRow = { product: undefined }
}
// legacy routed to `home` because this port had no orders list yet; it exists
// now, so the success step links to the real one.
const goOrders = async () => {
await router.push({ name: 'ordersList' })
cartData.step = 1
}
const submitOrder = async () => {
cartData.notify = false
if (!session.customerId) {
@@ -198,32 +218,194 @@ onMounted(async () => {
})
</script>
<style lang="less">
.rtl {
direction: ltr;
.ant-form-vertical .ant-form-item-label,
.ant-col-24.ant-form-item-label,
.ant-col-xl-24.ant-form-item-label {
text-align: right !important;
}
<style scoped lang="less">
.cart {
background: @canvas;
min-height: 100vh;
}
.btn-px-double {
padding-right: 24px !important;
padding-left: 24px !important;
.cart__body {
padding-top: 16px;
padding-bottom: 150px;
}
.ant-btn-primary[disabled],
.ant-btn-primary[disabled]:hover,
.ant-btn-primary[disabled]:focus,
.ant-btn-primary[disabled]:active {
border-color: @primary-color;
background: @primary-color;
.cart__items {
display: flex;
flex-direction: column;
gap: 11px;
}
.shadowset {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
border-radius: 10px 10px 0 0;
.cart__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.cart__empty p {
margin: 0;
font-size: 12.5px;
}
.cart__emptyCta {
margin-top: 4px;
padding: 9px 18px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.cart__done {
margin-top: 20px;
padding: 34px 22px;
border: 1px solid @hairline;
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.cart__doneIcon {
width: 56px;
height: 56px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.cart__doneTitle {
margin: 14px 0 0;
font-size: 15px;
font-weight: 800;
color: @ink;
}
.cart__doneSub {
margin: 8px 0 0;
font-size: 12.5px;
color: @inkMuted;
}
.cart__doneSub b {
color: @ink;
}
.cart__doneHint {
margin: 5px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.cart__doneCta {
margin-top: 18px;
padding: 11px 22px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.cart__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: 66px;
}
.cart__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.cart__total {
display: flex;
flex-direction: column;
gap: 3px;
}
.cart__totalLabel {
font-size: 11px;
color: @inkFaint;
}
.cart__totalValue {
font-size: 16px;
font-weight: 800;
color: @ink;
}
.cart__totalValue i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 3px;
}
.cart__submit {
flex: 1;
max-width: 200px;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
transition: transform 0.15s ease;
}
.cart__submit:active {
transform: scale(0.98);
}
.sheet {
padding: 18px 16px 22px;
}
.sheet__title {
margin: 0 0 14px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.sheet__cta {
width: 100%;
padding: 12px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
}
</style>
+75 -17
View File
@@ -1,25 +1,44 @@
<!-- Ported from legacy henza_pwa/src/views/newest.vue.
See useProducts.ts for the pagination-simplification note. -->
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
-->
<template>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<AppHeader>
<nav class="flex justify-start items-center">
<router-link :to="{ name: 'home' }" class="h-[25px] w-[25px]">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="28" />
</router-link>
<h4 class="text-white bold mb-0 mr-[20%] md:mr-[40%]">جدید ترین محصولات</h4>
</nav>
</AppHeader>
<ProductList padding="81px" class="px-[16px] pb-[66px]" @pull="pull" :value="productsData.productList" :loading="loadingPull" :flag="productsData.flag" />
</Spinner>
<div class="newest">
<PageBar title="جدیدترین محصولات" back-to="home" />
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<ScrollFetchSearch
class="newest__scroll"
:loading="loadingPull"
:flag="productsData.flag"
padding="0px"
@fetch="pull"
>
<container class="newest__body">
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
</container>
</ScrollFetchSearch>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onBeforeMount, ref } from 'vue'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import { computed, onBeforeMount, ref } from 'vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import ProductList from '../components/product/ProductList.vue'
import SectionHead from '../components/home/SectionHead.vue'
import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
@@ -27,10 +46,49 @@ const { productsData, getProducts } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
await getProducts(loadingPull, true)
}
</script>
<style scoped lang="less">
.newest {
background: @canvas;
min-height: 100vh;
}
.newest__scroll {
height: calc(100vh - 61px);
}
.newest__body {
padding-top: 16px;
padding-bottom: 90px;
}
.newest__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
</style>
@@ -1,56 +1,59 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue.
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language (see components/home/* and HomeView).
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach.
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
-->
<template>
<div>
<AppHeader>
<nav class="grid grid-cols-10 justify-center items-center">
<div class="w-full col-span-1" @click="router.push({ name: 'search' })">
<Icon icon="vuesax-linear:arrow-right-1" class="ml-2 text-white" size="19" />
<div class="coll">
<PageBar :title="collectionTitle" back-to="search" />
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
</div>
<div class="w-full col-span-8 flex mb-0 justify-center items-center h-[19px]">
<Icon icon="vuesax-linear:money-2" class="ml-2 text-white" size="19" />
<h4 class="bold mb-0 text-white">{{ route.query.page }}</h4>
</div>
<div class="w-full col-span-1"></div>
</nav>
</AppHeader>
<div class="flex flex-col gap-4 m-4 p-2 bg-gray-50 h-[90vh] rounded-lg">
<span class="text-center font-bold mt-2">برای مشاهده جزئیات محصول روی آیتم ضربه بزنید</span>
<Spin :spinning="loadingPage">
<div class="grid grid-cols-4 gap-8 overflow-y-auto mx-4 max-h-[85vh] pb-10">
<CollectionCard v-for="item in collectionProducts" :key="item.id" @click="openDrawer(item.id)" :item="item" />
</div>
</Spin>
</div>
<section v-else-if="!loadingPage" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
</container>
</Spinner>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="closeDrawer" :key="activeProductId" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Drawer, Spin } from 'ant-design-vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import CollectionCard from '../../components/product/CollectionCard.vue'
import SingleProduct from '../../components/product/SingleProduct.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client'
const route = useRoute()
const router = useRouter()
const visible = ref(false)
const activeProductId = ref<string | null>(null)
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const collectionProducts = computed(() => allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)))
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
onMounted(async () => {
loadingPage.value = true
@@ -60,12 +63,38 @@ onMounted(async () => {
loadingPage.value = false
}
})
function openDrawer(id: string) {
activeProductId.value = id
visible.value = true
}
function closeDrawer() {
visible.value = false
}
</script>
<style scoped lang="less">
.coll {
background: @canvas;
min-height: 100vh;
}
.coll__body {
padding-top: 16px;
padding-bottom: 90px;
}
.coll__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
.coll__empty {
margin-top: 8px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.coll__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
+269 -33
View File
@@ -1,51 +1,118 @@
<!--
Ported from legacy henza_pwa/src/views/search/searchPage.vue. All filtering
happens client-side against the one flat product list (no server-side query
params exist for any of this — consistent with NewestView's own "single
fetch" adaptation from an earlier batch).
Ported from legacy henza_pwa/src/views/search/searchPage.vue, restyled into
the redesigned design language (see components/home/* and HomeView).
All filtering still happens client-side against the one flat product list
(no server-side query params exist for any of this — consistent with
NewestView's own "single fetch" adaptation from an earlier batch). The
filter logic, the Filters sheet, the category chips and the collection
tiles are all carried over unchanged; only the chrome and the cards change.
The collection tiles keep their original destination (/collection/:id with
the title in ?page=), and are shown above the results exactly as the legacy
ProductList did on this route.
-->
<template>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<header class="w-full z-10 top-0 bg-white shadow-md fixed">
<div class="p-[16px]">
<div class="pb-[10px] flex w-fit justify-center items-center text-primary cursor-pointer" v-if="showFilter" @click="hideFilters()">
<Icon icon="vuesax-linear:arrow-right-1" class="ml-1" size="25" />
بازگشت
<div class="search">
<PageBar :title="showFilter ? 'فیلترها' : 'جستجو'" :back="showFilter" @click.capture="onBarClick">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
<div v-if="!showFilter" class="search__chips no-scrollbar">
<button
type="button"
class="search__chip search__chip--filter"
:class="{ 'is-on': filterKeys.length > 0 }"
@click="showFilters"
>
<Icon icon="vuesax-linear:filter" size="15" />
{{ filterKeys.length > 0 ? `${filterKeys.length} فیلتر` : 'فیلتر' }}
</button>
<button
type="button"
class="search__chip"
:class="{ 'is-on': !filters.query.cats }"
@click="setCats(undefined)"
>
همه
</button>
<button
v-for="item in availableCategories"
:key="item.id"
type="button"
class="search__chip"
:class="{ 'is-on': filters.query.cats && filters.query.cats[0] === item.id }"
@click="setCats(item.id)"
>
{{ item.title }}
</button>
</div>
</div>
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
</div>
<div class="no-scrollbar flex overflow-x-auto my-[16px] mt-0 pr-[16px]" v-if="!showFilter">
<Button :type="('secondary' as any)" class="btn-secondary ml-[8px] whitespace-nowrap" v-if="filterKeys.length > 0" @click="showFilters">{{ `${filterKeys.length} فیلتر` }}</Button>
<Button :type="('secondary' as any)" class="ml-[8px] whitespace-nowrap" v-else @click="showFilters">اعمال فیلتر</Button>
<div class="flex-1 flex overflow-x-auto no-scrollbar pl-[16px]">
<Button :type="('secondary' as any)" :ghost="!!filters.query.cats" class="last:ml-0 ml-[8px] whitespace-nowrap" @click="setCats(undefined)">همه</Button>
<Button
:type="('secondary' as any)"
:ghost="!(filters.query.cats && filters.query.cats[0] === item.id)"
class="last:ml-0 ml-[8px] whitespace-nowrap"
v-for="item in availableCategories"
</template>
</PageBar>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<Filters
v-model:filterKeys="filterKeys"
v-show="showFilter"
v-model="filters.query"
:products="allProducts"
@change="hideFilters"
/>
<container v-show="!showFilter" class="search__body">
<div v-if="availableCollections.length" class="search__collections">
<button
v-for="item in availableCollections"
:key="item.id"
@click="setCats(item.id)"
>{{ item.title }}</Button
type="button"
class="search__collection"
@click="$router.push({ path: `/collection/${item.id}`, query: { page: item.title } })"
>
<Icon icon="vuesax-linear:money-recive" size="19" />
<span>{{ item.title }}</span>
</button>
</div>
</div>
</header>
<Filters v-model:filterKeys="filterKeys" v-show="showFilter" v-model="filters.query" :products="allProducts" @change="hideFilters" />
<SectionHead
v-if="filteredProducts.length"
title="نتایج"
:subtitle="`${filteredProducts.length} محصول`"
/>
<ProductList class="px-[16px] pb-[66px]" padding="146px" v-show="!showFilter" @pull="() => {}" :value="filteredProducts" :ciny="availableCollections" :loading="false" :flag="filteredProducts.length === 0" />
</Spinner>
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Button } from 'ant-design-vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import ProductList from '../../components/product/ProductList.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { useNavStore } from '../../stores/navStore'
import { api, type ProductSummary } from '../../api/client'
@@ -81,11 +148,52 @@ const filteredProducts = computed(() => {
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
if (q.limit_gram.length === 2) {
list = list.filter((p) => p.variants.some((v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1]))
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
}
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() {
loadingPage.value = true
try {
@@ -114,5 +222,133 @@ function hideFilters(event?: { resetFilters: boolean }) {
}
}
// While the filter sheet is open the bar's back button closes the sheet
// rather than leaving the route, matching the legacy "بازگشت" affordance.
function onBarClick(e: MouseEvent) {
if (!showFilter.value) return
const btn = (e.target as HTMLElement)?.closest('button')
if (btn) {
e.stopPropagation()
hideFilters()
}
}
onMounted(loadProducts)
</script>
<style scoped lang="less">
.search {
background: @canvas;
min-height: 100vh;
}
.search__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 11px;
}
.search__chips {
display: flex;
gap: 8px;
overflow-x: auto;
margin: 11px -16px 0;
padding: 0 16px;
scroll-snap-type: x proximity;
}
.search__chip {
scroll-snap-align: start;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.search__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.search__chip--filter.is-on {
background: @wine;
border-color: @wine;
}
.search__chip:active {
transform: scale(0.97);
}
.search__body {
padding-top: 16px;
padding-bottom: 90px;
}
.search__collections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
margin-bottom: 22px;
}
.search__collection {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 10px;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: @inkSoft;
background: @hairlineSoft;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.search__collection:active {
transform: scale(0.98);
}
.search__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
.search__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.search__empty {
margin-top: 8px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.search__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
+76 -16
View File
@@ -1,34 +1,47 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue.
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Real, not stubbed: favorites are the local favoritesStore (per-device, no
backend persistence — see favoritesStore.ts from an earlier batch), filtered
against the real product catalog client-side. No pagination — the whole
catalog is fetched once (same "small catalog" approach as everywhere else).
That logic is carried over unchanged; only the chrome and the card change.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">علاقمندی</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-16"></div>
<div class="favs">
<PageBar title="علاقه‌مندی" back-to="userPage::hasSubRoute" />
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
<ProductList class="px-[16px] pt-8" padding="px" :flag="favoriteProducts.length === 0" :value="favoriteProducts" :loading="false" is-fav @pull="() => {}" />
</Spinner>
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
<container class="favs__body">
<template v-if="favoriteProducts.length">
<SectionHead title="ذخیره‌شده‌ها" :subtitle="`${favoriteProducts.length} محصول`" />
<div class="favs__grid">
<ProductCard v-for="item in favoriteProducts" :key="item.id" :value="item" />
</div>
</template>
<section v-else-if="!loading" class="favs__empty">
<Icon icon="vuesax-linear:heart" size="26" />
<p>هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید.</p>
<button type="button" class="favs__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import ProductList from '../../components/product/ProductList.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
@@ -48,3 +61,50 @@ onMounted(async () => {
}
})
</script>
<style scoped lang="less">
.favs {
background: @canvas;
min-height: 100vh;
}
.favs__body {
padding-top: 16px;
padding-bottom: 90px;
}
.favs__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
.favs__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.favs__empty p {
margin: 0;
font-size: 12.5px;
}
.favs__emptyCta {
margin-top: 4px;
padding: 9px 18px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
</style>