SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروشهای این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
<!--
|
||||
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
|
||||
if (!session.customerId) {
|
||||
loginNotify.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
cartData.loadingPage = true
|
||||
const order = await api.createOrder({
|
||||
customerId: session.customerId,
|
||||
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>
|
||||
Reference in New Issue
Block a user