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>
This commit is contained in:
@@ -46,9 +46,8 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="dash">
|
||||
<header class="dash__bar">
|
||||
<div class="dash__barInner">
|
||||
<span class="dash__brand">میز کار</span>
|
||||
<PageBar title="میز کار" :back="false">
|
||||
<template #trailing>
|
||||
<button
|
||||
v-if="session.isLoggedIn"
|
||||
type="button"
|
||||
@@ -58,20 +57,16 @@
|
||||
>
|
||||
<Icon icon="vuesax-linear:logout" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<span v-else class="dash__exitSpacer" aria-hidden="true" />
|
||||
</template>
|
||||
</PageBar>
|
||||
|
||||
<PullRefresh 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>
|
||||
|
||||
<container class="dash__body" :class="{ 'is-revealed': revealed }">
|
||||
<!-- Signed in: real position from GET /customers/:id, quick actions
|
||||
built into the same dark surface as the balance itself. -->
|
||||
<section v-if="customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
|
||||
<div class="dash__heroTop">
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="dash__body" :class="{ 'is-revealed': revealed }">
|
||||
<!-- Signed in: real position from GET /customer/me. Auth is the token —
|
||||
never treat a slow/failed profile fetch as a guest session. -->
|
||||
<section v-if="session.isLoggedIn && customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
|
||||
<header class="dash__heroHead">
|
||||
<div class="dash__heroIdentity">
|
||||
<span class="dash__heroName">{{ customer.fullName }}</span>
|
||||
<span class="dash__heroMobile">{{ session.mobile }}</span>
|
||||
@@ -79,27 +74,49 @@
|
||||
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
|
||||
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dash__heroFigure">
|
||||
<span class="dash__heroNum">{{ grams }}</span>
|
||||
<span class="dash__heroUnit">گرم {{ remainText }}</span>
|
||||
<div class="dash__heroBalance">
|
||||
<div class="dash__heroFigure">
|
||||
<span class="dash__heroNum">{{ gramsLabel }}</span>
|
||||
<span class="dash__heroUnit">گرم</span>
|
||||
</div>
|
||||
<span class="dash__heroEyebrow">موجودی طلا</span>
|
||||
</div>
|
||||
|
||||
<div class="dash__heroDebt">
|
||||
<span>بدهی ریالی</span>
|
||||
<span class="dash__heroDebtLabel">بدهی ریالی</span>
|
||||
<b>{{ price(customer.fiatDebtRial) }} <i>ریال</i></b>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Signed out: a quiet invitation, no fabricated numbers -->
|
||||
<section v-else class="dash__invite dash__reveal" :style="revealDelay(0)">
|
||||
<div class="dash__inviteIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="22" color="#fff" />
|
||||
</div>
|
||||
<h3>حساب گرمی شما</h3>
|
||||
<p>برای دیدن موجودی، سفارشها و اسناد خود وارد شوید.</p>
|
||||
<section
|
||||
v-else-if="session.isLoggedIn"
|
||||
class="dash__hero dash__hero--pending dash__reveal"
|
||||
:style="revealDelay(0)"
|
||||
>
|
||||
<header class="dash__heroHead">
|
||||
<div class="dash__heroIdentity">
|
||||
<span class="dash__heroName">حساب شما</span>
|
||||
<span class="dash__heroMobile">{{ session.mobile }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<p class="dash__heroPending">
|
||||
{{ profileError ? 'موجودی الان در دسترس نیست. دوباره تلاش کنید.' : 'در حال دریافت موجودی…' }}
|
||||
</p>
|
||||
<button
|
||||
v-if="profileError"
|
||||
type="button"
|
||||
class="dash__heroRetry"
|
||||
@click="loadDashboard"
|
||||
>
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Signed out: same card shell as the logged-in hero; logo + CTA only. -->
|
||||
<section v-else class="dash__hero dash__invite dash__reveal" :style="revealDelay(0)">
|
||||
<img class="dash__inviteLogo" src="/img/brand-logo.png" :alt="brand.name" />
|
||||
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
|
||||
ورود / ثبتنام
|
||||
</button>
|
||||
@@ -114,9 +131,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>سفارشات</b>
|
||||
<small>پیگیری سفارشهای شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(1)">
|
||||
@@ -125,9 +143,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>اسناد</b>
|
||||
<small>تراکنشها و رسیدهای حساب</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(2)">
|
||||
@@ -136,9 +155,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>پروفایل</b>
|
||||
<small>اطلاعات و تنظیمات حساب</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(3)">
|
||||
@@ -147,9 +167,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>علاقمندی</b>
|
||||
<small>محصولات ذخیرهشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
<!--
|
||||
D24 — the account statement. The reference put it on the dashboard
|
||||
@@ -163,9 +184,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>گزارش</b>
|
||||
<small>گردش حساب طلایی و ریالی شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Appearance (theme + reduced motion). Device-level preferences, not
|
||||
@@ -176,9 +198,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>تنظیمات ظاهری</b>
|
||||
<small>پوسته روشن و تیره، انیمیشنها</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!--
|
||||
@@ -195,9 +218,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>سفارش مشتریان</b>
|
||||
<small>سفارشهای مشتریان معرفیشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(5)">
|
||||
@@ -206,9 +230,10 @@
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>مشتریان من</b>
|
||||
<small>فهرست مشتریان معرفیشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="var(--c-inkFaint)" />
|
||||
<span class="dash__rowChevron" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
@@ -216,7 +241,7 @@
|
||||
<!-- Live 18k rate — a single inline row, not a titled section. -->
|
||||
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
|
||||
<IslandSheet
|
||||
v-model:show="data.notify"
|
||||
@@ -244,8 +269,9 @@
|
||||
import { brand } from '../../brand'
|
||||
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import RateStrip from '../../components/home/RateStrip.vue'
|
||||
import IslandSheet from '../../components/ui/IslandSheet.vue'
|
||||
import { price } from '../../utils/filters'
|
||||
@@ -261,15 +287,20 @@ const reads = useCachedReads()
|
||||
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
|
||||
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
|
||||
const refreshing = ref(false)
|
||||
const profileError = ref(false)
|
||||
|
||||
// The ledger stores the gram position as a signed figure — positive means the
|
||||
// customer owes gold. Labelled the same way AccountStrip does on the home
|
||||
// screen so the two never disagree.
|
||||
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
|
||||
const remainText = computed(() => (isDebtor.value ? 'بدهکار' : 'بستانکار'))
|
||||
const grams = computed(() =>
|
||||
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
|
||||
)
|
||||
const gramsLabel = computed(() => {
|
||||
const n = grams.value
|
||||
if (!Number.isFinite(n)) return '۰'
|
||||
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
|
||||
})
|
||||
|
||||
const data = reactive({ notify: false, loginNotify: false })
|
||||
|
||||
@@ -314,6 +345,7 @@ async function loadDashboard() {
|
||||
// the exception uncaught, so onRefresh's refreshing.value = false never
|
||||
// ran and PullRefresh stayed stuck in its loading state — a working pull
|
||||
// gesture never dismissed it again until a full page reload.
|
||||
profileError.value = false
|
||||
const [me, price] = await Promise.allSettled([
|
||||
// The balance on this tab is the one figure read fresh on every visit;
|
||||
// the cached copy only fills the screen until it arrives.
|
||||
@@ -325,14 +357,22 @@ async function loadDashboard() {
|
||||
// to control, so it is gone.
|
||||
reads.goldPrice({ force: true }),
|
||||
])
|
||||
if (me.status === 'fulfilled') customer.value = me.value
|
||||
else console.log(me.reason)
|
||||
if (me.status === 'fulfilled') {
|
||||
customer.value = me.value
|
||||
profileError.value = false
|
||||
} else {
|
||||
profileError.value = session.isLoggedIn && !customer.value
|
||||
console.log(me.reason)
|
||||
}
|
||||
goldPrice.value = price.status === 'fulfilled' ? price.value : null
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await loadDashboard()
|
||||
refreshing.value = false
|
||||
try {
|
||||
await loadDashboard()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let activatedOnce = false
|
||||
@@ -369,43 +409,15 @@ onMounted(async () => {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// A bare bar, sized like Home's, but with no border/title-bar chrome — the
|
||||
// hero card below carries the page's identity instead of a header label.
|
||||
.dash__bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: alpha(@canvas, 82%);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
border-bottom: 1px solid alpha(@wine, 5%);
|
||||
box-shadow: 0 8px 20px -18px alpha(@wine, 55%);
|
||||
}
|
||||
|
||||
.dash__barInner {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dash__brand {
|
||||
font-size: 14.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
// Same footprint as PageBar's `.bar__btn` (cart / search / …) so the trailing
|
||||
// logout doesn't shrink the bar relative to the rest of the app.
|
||||
.dash__exit {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 11px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
@@ -413,11 +425,18 @@ onMounted(async () => {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dash__exit:active {
|
||||
transform: scale(0.92);
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.dash__exitSpacer {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.dash__body {
|
||||
padding-top: 58px;
|
||||
// Clears the shared PageBar (same offset as CartView).
|
||||
padding-top: 76px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
@@ -446,22 +465,55 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// ── Hero ────────────────────────────────────────────────────────────────
|
||||
// One full surface: identity, balance and quick actions all on the same
|
||||
// ink → wine gradient, instead of a dark card handing off to a separate
|
||||
// white grid underneath it.
|
||||
// Same cool neutrals as the rest of the workbench (canvasSoft / gray-*), so
|
||||
// labels and nested panels don't sit on a warm cream that fights the page.
|
||||
.dash__hero {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 28px;
|
||||
padding: 20px 18px 16px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5A0027 130%);
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 40px -26px rgba(124, 0, 53, 0.32);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 16px 16px 14px;
|
||||
/* Fixed footprint so guest / pending / signed-in cards stay the same size. */
|
||||
height: 194px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 22px;
|
||||
color: @ink;
|
||||
/* Tailwind gray-50 */
|
||||
background: #f9fafb;
|
||||
border: 1px solid @hairline;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash__heroTop {
|
||||
.dash__hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 1;
|
||||
/* Soft lines on gray-50 — barely-there gray-200 */
|
||||
background-image: repeating-linear-gradient(
|
||||
-28deg,
|
||||
transparent 0,
|
||||
transparent 10px,
|
||||
rgba(229, 231, 235, 0.22) 10px,
|
||||
rgba(229, 231, 235, 0.22) 11px
|
||||
);
|
||||
}
|
||||
|
||||
.dash__hero > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dash__heroHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -469,154 +521,198 @@ onMounted(async () => {
|
||||
.dash__heroIdentity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash__heroName {
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dash__heroMobile {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: @inkFaint;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dash__heroSeal {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 12px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dash__heroSeal.is-debtor {
|
||||
color: #fff;
|
||||
background: @wineSoft;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
.dash__heroSeal.is-creditor {
|
||||
color: #14161A;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.dash__heroBalance {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash__heroEyebrow {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.dash__heroFigure {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash__heroNum {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
font-size: 38px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.6px;
|
||||
line-height: 1.05;
|
||||
color: @ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dash__heroUnit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.dash__heroDebt {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||
margin-top: 0;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
font-size: 11.5px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.dash__heroDebtLabel {
|
||||
color: @inkFaint;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dash__heroDebt b {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dash__heroDebt i {
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-weight: 400;
|
||||
color: @inkFaint;
|
||||
padding-inline-start: 3px;
|
||||
}
|
||||
|
||||
// ── Signed-out invite ──────────────────────────────────────────────────
|
||||
.dash__invite {
|
||||
margin-top: 16px;
|
||||
padding: 26px 20px;
|
||||
border-radius: 26px;
|
||||
background: @heroSurface;
|
||||
color: #fff;
|
||||
.dash__heroPending {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: @inkFaint;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dash__inviteIcon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dash__invite h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.dash__invite p {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.dash__inviteCta {
|
||||
margin-top: 6px;
|
||||
padding: 10px 22px;
|
||||
.dash__heroRetry {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-top: 0;
|
||||
padding: 9px 16px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #14161A;
|
||||
background: #fff;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
// ── Signed-out invite (same footprint as logged-in .dash__hero) ────────
|
||||
.dash__inviteLogo {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
height: 88px;
|
||||
max-height: 88px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dash__inviteCta {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
/* Mirror .dash__heroDebt so the bottom band matches after login. */
|
||||
margin-top: 0;
|
||||
padding: 11px 13px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 14px;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dash__inviteCta:active {
|
||||
transform: scale(0.96);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
// ── Settings-style rows ────────────────────────────────────────────────
|
||||
// Same 16px gutter as <container>, equal inline padding so the leading icon
|
||||
// tile and the trailing chevron sit the same distance from the card edges.
|
||||
.dash__rows {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 17px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
background: @canvas;
|
||||
font-family: inherit;
|
||||
text-align: right;
|
||||
text-align: start;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.dash__row:active {
|
||||
@@ -626,9 +722,9 @@ onMounted(async () => {
|
||||
|
||||
.dash__rowIcon {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
@@ -638,22 +734,24 @@ onMounted(async () => {
|
||||
.dash__rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.dash__rowText b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: @ink;
|
||||
}
|
||||
.dash__rowText small {
|
||||
font-size: 10.5px;
|
||||
|
||||
.dash__rowChevron {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
// ── Rate row ───────────────────────────────────────────────────────────
|
||||
.dash__rate {
|
||||
margin-top: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,9 +23,7 @@
|
||||
|
||||
<container class="auth__body">
|
||||
<section class="auth__intro">
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="28" />
|
||||
</div>
|
||||
<img class="auth__logo" src="/img/brand-logo.png" :alt="brand.name" />
|
||||
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
|
||||
<p class="auth__sub">
|
||||
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
||||
@@ -58,7 +56,15 @@
|
||||
label="تلفن همراه"
|
||||
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
|
||||
>
|
||||
<Input type="number" v-model:value="formModel.username" />
|
||||
<Input
|
||||
:value="formModel.username"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
:maxlength="11"
|
||||
placeholder="09123456789"
|
||||
autocomplete="tel"
|
||||
@update:value="(v: string) => (formModel.username = digits(v))"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<button
|
||||
@@ -85,7 +91,6 @@ import { brand } from '../../brand'
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input } from 'ant-design-vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import OtpInput from '../../components/input/OtpInput.vue'
|
||||
@@ -105,6 +110,15 @@ const body = reactive({
|
||||
defaultFocus: false,
|
||||
})
|
||||
|
||||
/** Digits only, Persian/Arabic numerals normalised, capped at 11 (09xxxxxxxxx). */
|
||||
function digits(v: string): string {
|
||||
return (v ?? '')
|
||||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
|
||||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, 11)
|
||||
}
|
||||
|
||||
const timer = ref(0)
|
||||
const countDownTimer = () => {
|
||||
if (timer.value > 0) {
|
||||
@@ -137,9 +151,12 @@ const sendPhone = async () => {
|
||||
body.step = 2
|
||||
timer.value = 120
|
||||
countDownTimer()
|
||||
await nextTick(() => {
|
||||
body.defaultFocus = true
|
||||
})
|
||||
body.defaultFocus = true
|
||||
// After the OTP boxes mount (and after the submit button releases focus),
|
||||
// put the caret in the first digit field.
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document.querySelector<HTMLInputElement>('.auth__otp .otp-input')?.focus()
|
||||
} catch (e: unknown) {
|
||||
// Both of these carry a Persian, user-facing `message` from the server, so
|
||||
// show it instead of a generic one:
|
||||
@@ -236,18 +253,15 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
.auth__logo {
|
||||
display: block;
|
||||
height: 72px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
margin: 14px 0 0;
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
|
||||
They are chips now instead of ant Tabs, which lets them scroll
|
||||
horizontally like every other filter rail in the redesign.
|
||||
- Fetches real order history via the already-existing GET
|
||||
/customers/:id/orders endpoint, filtered client-side by chip (that
|
||||
endpoint has no server-side status filter — small enough dataset that
|
||||
client-side filtering is reasonable, and apps/api stays untouched).
|
||||
- Fetches paginated order history via GET /customer/me/orders (page /
|
||||
status / q). Infinite-scroll like ReportView; filters run server-side
|
||||
so a status chip never misses rows on later pages.
|
||||
- Search matches against `orderNumber` (our real user-facing identifier)
|
||||
instead of legacy's numeric `id`.
|
||||
|
||||
@@ -26,7 +25,7 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="ord">
|
||||
<PageBar title="سفارشات" back-to="home">
|
||||
<PageBar title="سفارشات" :back="false">
|
||||
<template #below>
|
||||
<div class="ord__barExtra">
|
||||
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
|
||||
@@ -47,25 +46,30 @@
|
||||
</template>
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh 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>
|
||||
<container class="ord__body">
|
||||
<template v-if="filteredOrders.length">
|
||||
<Spinner :spinning="loading && !orders.length" tip="درحال دریافت اطلاعات">
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="ord__body">
|
||||
<template v-if="orders.length">
|
||||
<div class="ord__list">
|
||||
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
|
||||
<OrderCard v-for="item in orders" :key="item.id" :value="item" @view="openDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="ord__empty">
|
||||
<section v-else-if="!loading && !failed" class="ord__empty">
|
||||
<Icon icon="vuesax-linear:box" size="26" />
|
||||
<p>سفارشی با این مشخصات پیدا نشد.</p>
|
||||
</section>
|
||||
|
||||
<section v-if="failed" class="ord__empty">
|
||||
<Icon icon="vuesax-linear:danger" size="26" />
|
||||
<p>دریافت سفارشها با خطا روبهرو شد.</p>
|
||||
<button type="button" class="ord__retry" @click="reload">تلاش دوباره</button>
|
||||
</section>
|
||||
|
||||
<div v-if="loading && orders.length" class="ord__more">در حال دریافت…</div>
|
||||
<div ref="sentinel" class="ord__sentinel" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</AppPullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
@@ -79,39 +83,31 @@
|
||||
>
|
||||
<div class="detail">
|
||||
<header class="detail__bar">
|
||||
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
|
||||
<button type="button" class="detail__close" aria-label="بستن" @click="closeDetail">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
|
||||
</button>
|
||||
<h2 class="detail__title">جزئیات سفارش</h2>
|
||||
<span class="detail__spacer" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="detail__trail"
|
||||
aria-label="تاریخچه وضعیت"
|
||||
@click="timelineOpen = true"
|
||||
>
|
||||
<Icon icon="vuesax-linear:clock-1" size="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="detail__scroll">
|
||||
<!-- Progress -->
|
||||
<section class="detail__block">
|
||||
<div class="detail__steps">
|
||||
<div
|
||||
v-for="(step, index) in ORDER_STEPS"
|
||||
:key="step"
|
||||
class="detail__step"
|
||||
:class="{ 'is-done': index <= currentStep, 'is-active': index === currentStep }"
|
||||
>
|
||||
<span class="detail__stepDot">{{ index + 1 }}</span>
|
||||
<span class="detail__stepTitle">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Headline facts -->
|
||||
<!-- Headline facts — order path lives in the timeline sheet (header clock). -->
|
||||
<section class="detail__block">
|
||||
<div class="detail__headline">
|
||||
<div>
|
||||
<div class="detail__idBlock">
|
||||
<div class="detail__eyebrow">شماره سفارش</div>
|
||||
<div class="detail__number">{{ viewData.item.orderNumber }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="detail__status"
|
||||
:style="{ background: modalColor?.background, color: modalColor?.font }"
|
||||
:style="{ background: modalColor.background, color: modalColor.font }"
|
||||
>
|
||||
{{ modalStatusLabel }}
|
||||
</span>
|
||||
@@ -143,13 +139,82 @@
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<!-- Status history as an island action sheet (same pattern as cart note). -->
|
||||
<Drawer
|
||||
v-model:visible="timelineOpen"
|
||||
placement="bottom"
|
||||
:closable="false"
|
||||
:zIndex="1100"
|
||||
height="70%"
|
||||
class="rtl island-sheet"
|
||||
:bodyStyle="{ padding: 0 }"
|
||||
>
|
||||
<div class="tlSheet">
|
||||
<header class="tlSheet__banner">
|
||||
<span class="tlSheet__chip">وضعیت</span>
|
||||
<span class="tlSheet__dash" aria-hidden="true" />
|
||||
<span
|
||||
v-if="viewData.item"
|
||||
class="tlSheet__badge"
|
||||
:style="{
|
||||
background: modalColor.background,
|
||||
color: modalColor.font,
|
||||
}"
|
||||
>
|
||||
<Icon icon="vuesax-linear:tick-circle" size="14" />
|
||||
{{ modalStatusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ol v-if="timelinePath.length" class="tlSheet__rail">
|
||||
<li
|
||||
v-for="(node, index) in timelinePath"
|
||||
:key="node.status"
|
||||
class="tlSheet__node"
|
||||
:class="[`is-${node.state}`]"
|
||||
>
|
||||
<div class="tlSheet__axis" aria-hidden="true">
|
||||
<span
|
||||
v-if="node.state === 'current'"
|
||||
class="tlSheet__halo"
|
||||
:style="{ color: orderStatusColor(node.status).font }"
|
||||
/>
|
||||
<span class="tlSheet__marker" :style="markerStyle(node)">
|
||||
<Icon :icon="orderStatusIcon(node.status)" size="16" />
|
||||
</span>
|
||||
<span
|
||||
v-if="index < timelinePath.length - 1"
|
||||
class="tlSheet__stem"
|
||||
:style="node.state === 'pending' ? undefined : stemStyle(node.status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tlSheet__main">
|
||||
<div class="tlSheet__row">
|
||||
<div class="tlSheet__copy">
|
||||
<div class="tlSheet__status">{{ orderStatusLabel(node.status) }}</div>
|
||||
<p class="tlSheet__blurb">{{ orderStatusBlurb(node.status) }}</p>
|
||||
</div>
|
||||
<time v-if="node.at" class="tlSheet__when" :datetime="node.at">
|
||||
{{ jtimelineWhen(node.at) }}
|
||||
</time>
|
||||
<span v-else class="tlSheet__when is-wait">در انتظار</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-else class="tlSheet__empty">مسیری برای این سفارش ثبت نشده است.</p>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
@@ -157,19 +222,26 @@ import SearchInput from '../../components/search/SearchInput.vue'
|
||||
import OrderCard from '../../components/product/OrderCard.vue'
|
||||
import OrderItemCard from '../../components/product/OrderItemCard.vue'
|
||||
import {
|
||||
ORDER_STATUS_COLORS,
|
||||
ORDER_PIPELINE,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STEPS,
|
||||
orderStepIndex,
|
||||
orderStatusBlurb,
|
||||
orderStatusColor,
|
||||
orderStatusIcon,
|
||||
orderStatusLabel,
|
||||
} from '../../utils/orderStatus'
|
||||
import { jdate } from '../../utils/filters'
|
||||
import { jdate, jtimelineWhen } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import type { OrderDetail } from '../../api/client'
|
||||
import { useCachedReads } from '../../api/cachedReads'
|
||||
import { KEYS } from '../../stores/queryCache'
|
||||
import { api, type OrderDetail } from '../../api/client'
|
||||
import { useQueryCache, KEYS } from '../../stores/queryCache'
|
||||
|
||||
type TimelineNode = {
|
||||
status: string
|
||||
at: string | null
|
||||
state: 'done' | 'current' | 'pending'
|
||||
}
|
||||
|
||||
const session = useSessionStore()
|
||||
const reads = useCachedReads()
|
||||
const cache = useQueryCache()
|
||||
|
||||
const tabs = [
|
||||
{ title: 'همه', key: 'ALL' },
|
||||
@@ -184,81 +256,179 @@ const tabs = [
|
||||
]
|
||||
const activeTab = ref('ALL')
|
||||
|
||||
const orders = ref<OrderDetail[]>([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const allOrders = ref<OrderDetail[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
|
||||
const failed = ref(false)
|
||||
const searchTerm = ref<string | undefined>(undefined)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let list = allOrders.value
|
||||
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
|
||||
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
|
||||
return list
|
||||
})
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
/** Bumped on every filter/reload so an in-flight page response is dropped. */
|
||||
let fetchSeq = 0
|
||||
|
||||
function readSearch(value: string | undefined) {
|
||||
searchTerm.value = value
|
||||
}
|
||||
|
||||
async function loadOrders(force = false) {
|
||||
async function fetchPage() {
|
||||
if (!session.isLoggedIn) {
|
||||
allOrders.value = []
|
||||
orders.value = []
|
||||
hasMore.value = false
|
||||
return
|
||||
}
|
||||
loading.value = reads.peek(KEYS.orders) === undefined
|
||||
if (loading.value || !hasMore.value) return
|
||||
const my = fetchSeq
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
allOrders.value = await reads.myOrders({ force })
|
||||
const data = await api.getMyOrders({
|
||||
page: page.value,
|
||||
status: activeTab.value,
|
||||
q: searchTerm.value,
|
||||
})
|
||||
if (my !== fetchSeq) return
|
||||
const batch = data.result ?? []
|
||||
orders.value = page.value === 1 ? batch : [...orders.value, ...batch]
|
||||
if (batch.length < (data.limit ?? 20)) hasMore.value = false
|
||||
else page.value += 1
|
||||
} catch (e) {
|
||||
if (my !== fetchSeq) return
|
||||
failed.value = true
|
||||
hasMore.value = false
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (my === fetchSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetList() {
|
||||
orders.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
failed.value = false
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
fetchSeq += 1
|
||||
loading.value = false
|
||||
resetList()
|
||||
await fetchPage()
|
||||
}
|
||||
|
||||
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
|
||||
const timelineOpen = ref(false)
|
||||
const modalStatusLabel = computed(() =>
|
||||
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : '',
|
||||
viewData.item ? orderStatusLabel(viewData.item.status) : '',
|
||||
)
|
||||
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
|
||||
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
|
||||
const modalColor = computed(() =>
|
||||
viewData.item ? orderStatusColor(viewData.item.status) : orderStatusColor('PLACED'),
|
||||
)
|
||||
|
||||
/** Pending stays grey. Reached steps colour the icon/border only — fill stays opaque so the halo never shows through. */
|
||||
function markerStyle(node: TimelineNode) {
|
||||
if (node.state === 'pending') return undefined
|
||||
const c = orderStatusColor(node.status)
|
||||
return { color: c.font, borderColor: c.font }
|
||||
}
|
||||
|
||||
function stemStyle(status: string) {
|
||||
return { background: orderStatusColor(status).font }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full order path for the timeline sheet (replaces the old detail stepper).
|
||||
* Past steps are dated from statusEvents; future ones stay «در انتظار».
|
||||
*/
|
||||
const timelinePath = computed((): TimelineNode[] => {
|
||||
const item = viewData.item
|
||||
if (!item) return []
|
||||
|
||||
const times = new Map<string, string>()
|
||||
for (const ev of item.statusEvents ?? []) {
|
||||
if (!times.has(ev.toStatus)) times.set(ev.toStatus, ev.createdAt)
|
||||
}
|
||||
if (!times.has(item.status)) times.set(item.status, item.createdAt)
|
||||
|
||||
if (item.status === 'SUSPENDED') {
|
||||
return [
|
||||
{ status: 'SUSPENDED', at: times.get('SUSPENDED') ?? item.createdAt, state: 'current' },
|
||||
...ORDER_PIPELINE.map((status) => ({ status, at: null, state: 'pending' as const })),
|
||||
]
|
||||
}
|
||||
|
||||
if (item.status === 'CANCELLED') {
|
||||
const prior = ORDER_PIPELINE.filter((status) => times.has(status)).map((status) => ({
|
||||
status,
|
||||
at: times.get(status) ?? null,
|
||||
state: 'done' as const,
|
||||
}))
|
||||
return [
|
||||
...prior,
|
||||
{ status: 'CANCELLED', at: times.get('CANCELLED') ?? item.createdAt, state: 'current' },
|
||||
]
|
||||
}
|
||||
|
||||
const idx = (ORDER_PIPELINE as readonly string[]).indexOf(item.status)
|
||||
const currentIdx = idx >= 0 ? idx : 0
|
||||
return ORDER_PIPELINE.map((status, i) => ({
|
||||
status,
|
||||
at: times.get(status) ?? (i === currentIdx ? item.createdAt : null),
|
||||
state: i < currentIdx ? 'done' : i === currentIdx ? 'current' : 'pending',
|
||||
}))
|
||||
})
|
||||
|
||||
function openDetail(item: OrderDetail) {
|
||||
viewData.item = item
|
||||
viewData.model = true
|
||||
timelineOpen.value = false
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
timelineOpen.value = false
|
||||
viewData.model = false
|
||||
}
|
||||
|
||||
|
||||
async function onRefresh() {
|
||||
await loadOrders(true)
|
||||
refreshing.value = false
|
||||
try {
|
||||
cache.invalidate(KEYS.orders)
|
||||
await reload()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([activeTab, searchTerm], () => {
|
||||
void reload()
|
||||
})
|
||||
|
||||
/**
|
||||
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
|
||||
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
|
||||
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
|
||||
* نمیخوره، هم دیتاها بهروز میمونه».
|
||||
*
|
||||
* `force` matters because an order's status changes on the ADMIN side — nothing
|
||||
* in this app invalidates the cache when that happens, so without it a list
|
||||
* fetched once was shown unchanged for the rest of the session.
|
||||
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened.
|
||||
* Status changes on the ADMIN side, so each visit reloads from page 1.
|
||||
*
|
||||
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
|
||||
* keep-alive list, so it is mounted once and every later visit only activates
|
||||
* it. With `onMounted` alone the second visit made no request at all.
|
||||
*
|
||||
* The cached copy still renders instantly (`peek` above) and `loading` only
|
||||
* shows a spinner when there is nothing to show, so the refresh is invisible.
|
||||
*
|
||||
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
|
||||
* shape DashboardView uses.
|
||||
* keep-alive list. `activatedOnce` guards the first visit where Vue runs both.
|
||||
*/
|
||||
onMounted(() => loadOrders(true))
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
await nextTick()
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void fetchPage()
|
||||
})
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
}
|
||||
})
|
||||
|
||||
let activatedOnce = false
|
||||
onActivated(() => {
|
||||
if (activatedOnce) void loadOrders(true)
|
||||
if (activatedOnce) void reload()
|
||||
activatedOnce = true
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -289,7 +459,7 @@ onActivated(() => {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
@@ -335,6 +505,25 @@ onActivated(() => {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.ord__retry {
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid @hairline;
|
||||
background: @canvas;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ord__more,
|
||||
.ord__sentinel {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
/* ── Detail drawer ─────────────────────────────────────────────────── */
|
||||
.detail {
|
||||
height: 100vh;
|
||||
@@ -357,8 +546,8 @@ onActivated(() => {
|
||||
|
||||
.detail__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
@@ -374,10 +563,20 @@ onActivated(() => {
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.detail__spacer {
|
||||
display: block;
|
||||
.detail__trail {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @ink;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.detail__trail:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.detail__scroll {
|
||||
@@ -393,63 +592,235 @@ onActivated(() => {
|
||||
|
||||
.detail__blockTitle {
|
||||
margin: 0 0 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.detail__steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
.tlSheet {
|
||||
padding: 4px 18px 22px;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail__step {
|
||||
flex: 1 0 74px;
|
||||
.tlSheet__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tlSheet__chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid @hairline;
|
||||
background: @canvas;
|
||||
box-shadow: 0 1px 2px rgba(20, 22, 26, 0.04);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
.tlSheet__dash {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
border-top: 1.5px dashed @hairline;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.tlSheet__badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tlSheet__rail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.tlSheet__node {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.tlSheet__axis {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail__stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
/* Soft shadow-like halo — sits behind the opaque marker disc. */
|
||||
.tlSheet__halo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-left: -20px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tlSheet__halo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, currentColor 32%, transparent) 0%,
|
||||
color-mix(in srgb, currentColor 12%, transparent) 38%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(1.5px);
|
||||
opacity: 0;
|
||||
animation: tlSheetHalo 4.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tlSheetHalo {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tlSheet__halo::before {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.tlSheet__marker {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid currentColor;
|
||||
background: @canvas;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @inkFaint;
|
||||
background: @hairlineSoft;
|
||||
border: 1px solid @hairline;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail__step.is-done .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
.tlSheet__node.is-pending .tlSheet__marker {
|
||||
/* Tailwind gray-400 — clearer than hairline for inactive dashed rings */
|
||||
color: #9ca3af;
|
||||
background: @canvas;
|
||||
border-color: #9ca3af;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.detail__step.is-active .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @wineDeep;
|
||||
border-color: @wineDeep;
|
||||
.tlSheet__node.is-done .tlSheet__marker,
|
||||
.tlSheet__node.is-current .tlSheet__marker {
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.detail__stepTitle {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
.tlSheet__node.is-pending .tlSheet__status,
|
||||
.tlSheet__node.is-pending .tlSheet__blurb,
|
||||
.tlSheet__node.is-pending .tlSheet__when {
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.detail__step.is-done .detail__stepTitle {
|
||||
color: @inkSoft;
|
||||
font-weight: 600;
|
||||
.tlSheet__stem {
|
||||
flex: 1 1 auto;
|
||||
width: 1.5px;
|
||||
margin: 4px 0;
|
||||
background: @hairline;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.tlSheet__node.is-pending .tlSheet__stem {
|
||||
background: @hairlineSoft;
|
||||
border-inline-start: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
@hairline 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.tlSheet__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.tlSheet__node:last-child .tlSheet__main {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.tlSheet__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tlSheet__copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tlSheet__status {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.tlSheet__blurb {
|
||||
margin: 3px 0 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.tlSheet__when {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @inkFaint;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tlSheet__when.is-wait {
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tlSheet__empty {
|
||||
margin: 4px 0 0;
|
||||
padding: 20px 12px;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
text-align: center;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.detail__headline {
|
||||
@@ -460,24 +831,32 @@ onActivated(() => {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.detail__idBlock {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail__eyebrow {
|
||||
font-size: 11px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.detail__number {
|
||||
padding-top: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
padding-top: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: @inkSoft;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail__status {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -510,7 +889,7 @@ onActivated(() => {
|
||||
.detail__spec dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user