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:
co-authored by
Claude Opus 5
parent
f2d83c53df
commit
cc62975dd0
@@ -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> </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>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user