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