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