Files
tehran_taj/apps/user-app/src/views/CartView.vue
T
s.esmaeiliandCursor afb068c643 feat: paginate customer orders and polish the dashboard hero
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>
2026-10-17 13:11:42 +03:30

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>