Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell. Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
12 KiB
Vue
439 lines
12 KiB
Vue
<!--
|
|
Ported from legacy henza_pwa/src/views/cart.vue, restyled into the
|
|
redesigned design language (see components/home/* and HomeView).
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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>
|
|
<div class="cart">
|
|
<PageBar title="سبد خرید" back-to="home" />
|
|
|
|
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
|
|
<container class="cart__body">
|
|
<!-- Step 1: the basket -->
|
|
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
|
|
<template v-if="cart.items.length">
|
|
<div class="cart__items" :class="{ 'is-revealed': revealed }">
|
|
<CartItem
|
|
v-for="(item, i) in cart.items"
|
|
:key="item.pid + item.vid"
|
|
:value="item"
|
|
class="reveal"
|
|
:style="revealDelay(i)"
|
|
@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>
|
|
</AppPullRefresh>
|
|
|
|
<!-- 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>
|
|
<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>
|
|
|
|
<!-- 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>
|
|
</Drawer>
|
|
|
|
<DialogVant :timeOut="-1" v-model:show="cartData.notify" :closeOnClickOverlay="true">
|
|
<template v-slot:img>
|
|
<img :src="dialogEmpty4" alt="" class="opacity-70" />
|
|
</template>
|
|
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
|
|
<template v-slot:button>
|
|
<div class="text-center text-[color:var(--c-primary)]" @click="submitOrder">بله</div>
|
|
<div class="text-center text-[color:var(--c-inkMuted)]" @click="cartData.notify = false">بی خیال</div>
|
|
</template>
|
|
</DialogVant>
|
|
|
|
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
|
<template v-slot:img>
|
|
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
|
</template>
|
|
<template v-slot:title>
|
|
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
|
|
</template>
|
|
<template v-slot:button>
|
|
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
|
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
|
|
</template>
|
|
</DialogVant>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, reactive, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
|
|
import AppPullRefresh from '../components/ui/AppPullRefresh.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 PageBar from '../components/ui/PageBar.vue'
|
|
import Spinner from '../components/Spinner.vue'
|
|
import DialogVant from '../components/DialogVant.vue'
|
|
import CartItem from '../components/product/CartItem.vue'
|
|
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
|
|
import { useSessionStore } from '../stores/session'
|
|
import { useReveal } from '../composables/useReveal'
|
|
import { api } from '../api/client'
|
|
import { useCatalogStore } from '../stores/catalogStore'
|
|
import { KEYS, useQueryCache } from '../stores/queryCache'
|
|
|
|
const router = useRouter()
|
|
const cart = useCartStore()
|
|
const session = useSessionStore()
|
|
|
|
const loginNotify = ref(false)
|
|
const refreshing = ref(false)
|
|
|
|
// First-paint entrance for the basket rows, the same choreography HomeView
|
|
// uses with a slightly shorter beat, since a basket can hold many rows.
|
|
const { revealed, revealDelay, reveal } = useReveal('cart', 60)
|
|
|
|
const cartData = reactive({
|
|
loadingPage: false,
|
|
showPopUp: false,
|
|
step: 1,
|
|
notify: false,
|
|
orderID: '',
|
|
tempRowIndex: -1,
|
|
tempRow: { product: undefined } as Partial<CartItemType>,
|
|
})
|
|
const formModel = reactive<{ note: string | undefined }>({ note: undefined })
|
|
|
|
const setNote = () => {
|
|
cart.updateFeild(formModel.note, cartData.tempRowIndex, cartData.tempRow as CartItemType, 'note')
|
|
cartData.showPopUp = false
|
|
formModel.note = undefined
|
|
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
|
|
// Gate on the token, not on a remembered id: the id alone is no longer a
|
|
// credential, and POST /orders now requires a customer JWT (SEC-02).
|
|
if (!session.isLoggedIn) {
|
|
loginNotify.value = true
|
|
return
|
|
}
|
|
try {
|
|
cartData.loadingPage = true
|
|
const order = await api.createOrder({
|
|
// Ignored by the server, which takes the customer from the token; still
|
|
// sent so older builds keep validating. Removed in Phase 2.
|
|
customerId: session.customerId ?? undefined,
|
|
items: cart.items.map((item) => {
|
|
const variant = cart.variant(item)
|
|
return {
|
|
productVariantId: item.vid,
|
|
quantity: item.qty,
|
|
// Sent for older API builds only: the server prices app orders itself
|
|
// (MON-08) and ignores this figure.
|
|
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
|
|
}
|
|
}),
|
|
})
|
|
cartData.orderID = order.orderNumber
|
|
cartData.step = 2
|
|
cart.clear()
|
|
// The order shows up in the list and the balance, and may have taken stock.
|
|
useQueryCache().invalidate(KEYS.orders, KEYS.balance, KEYS.profile, KEYS.ledger)
|
|
useCatalogStore().invalidate()
|
|
} catch (e) {
|
|
console.log(e)
|
|
} finally {
|
|
cartData.loadingPage = false
|
|
}
|
|
}
|
|
|
|
async function onRefresh() {
|
|
try {
|
|
await cart.init(true)
|
|
} finally {
|
|
refreshing.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await cart.init()
|
|
reveal()
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.cart {
|
|
background: @page;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.cart__body {
|
|
padding-top: 76px;
|
|
padding-bottom: 150px;
|
|
}
|
|
|
|
.cart__items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 11px;
|
|
padding-top: 16px;
|
|
}
|
|
|
|
.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: @primary;
|
|
}
|
|
|
|
.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: @primary;
|
|
}
|
|
|
|
.cart__bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 5;
|
|
background: alpha(@canvas, 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: @primary;
|
|
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: @primary;
|
|
}
|
|
</style>
|