The user declined to defer this to Phase 2, correctly: a public POST /orders with customerId in the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the customer id as a separate argument that the controller fills from the token; the body's customerId is ignored and a mismatch is logged. The field stays optional in the DTO and in shared-types only so both apps keep validating while they still send it -- they drop it in Phase 2. The admin builder is unaffected: it legitimately places orders for a chosen customer and passes that id explicitly, behind the admin guard. GET /orders/:id is scoped to the owner. A foreign or nonexistent id both return 404, never 403, so ids stay unenumerable. Operators read through the new GET /orders/admin/:id behind rid order.list, declared before the :id route because Nest matches in declaration order. Both apps' CartView now gates on session.isLoggedIn rather than a remembered customerId, which is no longer a credential. OTP audit (requested): no master code, no bypass, no hardcoded code anywhere; one code path issues a token and only after bcrypt.compare. No SMS provider is wired up -- in development the code is logged once, guarded by NODE_ENV !== 'production'. Fixed while auditing (SEC-11): deliver() persisted the real message text, code included, into SmsMessage.body, which the admin SMS grid lists and full-text searches. Any operator with the sms rid could read live codes, defeating the hashing of codeHash. The stored row is now a redacted audit line. Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken from the client on order creation. The reference is worse -- it takes rash_weight from the client too -- so this is already a partial improvement; logged rather than changed, per D4. Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body customerId being ignored in favour of the token. Typecheck clean on api, user-app, user-app-v2 and shared-types. [ref: henza_server ApiMobileController.php:533,536-545,615-632] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
14 KiB
Vue
495 lines
14 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 -->
|
|
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
|
|
<template #pulling><Loading size="18" /></template>
|
|
<template #loosing><Loading size="18" /></template>
|
|
<template #loading><Loading size="18" /></template>
|
|
<template #success><span /></template>
|
|
<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="cart__row"
|
|
: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>
|
|
</PullRefresh>
|
|
|
|
<!-- 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-[#1C2D56]" @click="submitOrder">بله</div>
|
|
<div class="text-center text-[#6b7280]" @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-[#1C2D56]" @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 { nextTick, onMounted, reactive, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
|
|
import { Loading, PullRefresh } from 'vant'
|
|
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 { api } from '../api/client'
|
|
|
|
// Module scope, so it survives this view being unmounted and remounted as the
|
|
// user moves between tabs, and resets only on a full app reload — the same
|
|
// approach HomeView's first-paint reveal uses, and for the same reason: the
|
|
// choreography is a first-run flourish, not something to replay on every visit
|
|
// to the cart.
|
|
let hasRevealed = false
|
|
|
|
const router = useRouter()
|
|
const cart = useCartStore()
|
|
const session = useSessionStore()
|
|
|
|
const loginNotify = ref(false)
|
|
const refreshing = ref(false)
|
|
|
|
// First-paint entrance for the basket rows. Flipped once, after the cart has
|
|
// loaded; pull-to-refresh does not replay it.
|
|
const revealed = ref(hasRevealed)
|
|
|
|
// Each row starts a beat after the one above it. Kept small — a full basket
|
|
// finishes well under a second, so it reads as the list arriving rather than
|
|
// as something the user has to wait through.
|
|
const REVEAL_STEP_MS = 60
|
|
|
|
function revealDelay(i: number) {
|
|
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
|
|
}
|
|
|
|
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,
|
|
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
|
|
}
|
|
}),
|
|
})
|
|
cartData.orderID = order.orderNumber
|
|
cartData.step = 2
|
|
cart.clear()
|
|
} catch (e) {
|
|
console.log(e)
|
|
} finally {
|
|
cartData.loadingPage = false
|
|
}
|
|
}
|
|
|
|
async function onRefresh() {
|
|
await cart.init()
|
|
refreshing.value = false
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await cart.init()
|
|
// Wait for the rows the load just populated to be in the DOM, then let one
|
|
// frame paint them in their "before" state — otherwise the browser coalesces
|
|
// both states into one and there is nothing to animate.
|
|
if (revealed.value) return
|
|
await nextTick()
|
|
requestAnimationFrame(() => {
|
|
revealed.value = true
|
|
hasRevealed = true
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.cart {
|
|
background: @canvas;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.cart__body {
|
|
padding-top: 76px;
|
|
padding-bottom: 150px;
|
|
}
|
|
|
|
// One continuous list rather than a stack of boxes: the rows are borderless
|
|
// and a hairline divider separates each from the next. No gap — the rows carry
|
|
// their own vertical padding, so the divider sits centred between them.
|
|
.cart__items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.cart__row + .cart__row {
|
|
border-top: 1px solid @hairline;
|
|
}
|
|
|
|
// First-paint entrance, the same choreography HomeView uses: each row rises a
|
|
// little, scales up from just under full size and fades in, one beat after the
|
|
// one above it.
|
|
//
|
|
// The curve is a long decelerate with no overshoot — it starts fast, settles
|
|
// slowly, and never bounces back. The travel is deliberately small (12px, 2%
|
|
// of scale): the list should look like it was always there and simply came
|
|
// into focus, not like it flew in.
|
|
.cart__row {
|
|
opacity: 0;
|
|
transform: translate3d(0, 12px, 0) scale(0.98);
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.is-revealed .cart__row {
|
|
opacity: 1;
|
|
transform: translate3d(0, 0, 0) scale(1);
|
|
transition:
|
|
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
|
|
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
|
|
}
|
|
|
|
// Respect a reduced-motion preference: show the list, skip the choreography.
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.cart__row {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
.is-revealed .cart__row {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
.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>
|