Fix wholesale app orders, polish admin/user chrome, and harden order detail UX.

Customer placements now store WHOLESALE (with a migration for prior RETAIL rows), order status opens in a right drawer, logout asks for confirm, and the dashboard profile nudge plus IRANSans panel typeface are tightened for readability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-16 23:39:15 +03:30
co-authored by Cursor
parent f444a19845
commit 7fca434da6
32 changed files with 1755 additions and 527 deletions
@@ -113,11 +113,13 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__status {
flex: 0 0 auto;
padding: 4px 11px;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-size: 11px;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
line-height: 1.2;
}
.oc__figures {
@@ -15,9 +15,17 @@
-->
<template>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<button
v-if="lookup?.imageUrl"
type="button"
class="oi__art oi__art--preview"
:aria-label="`پیش‌نمایش ${lookup.productTitle ?? 'تصویر'}`"
@click="openPreview"
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art">
<div class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
</div>
@@ -43,11 +51,29 @@
</span>
</div>
</div>
<!-- Above the orders drawer (z-index 2000); teleported so overflow:hidden
on the drawer body cannot clip the lightbox. -->
<Teleport to="body">
<Transition name="oi-preview">
<div v-if="previewOpen && lookup?.imageUrl" class="oi__preview" @click.self="closePreview">
<button type="button" class="oi__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
<img
class="oi__previewImg"
:src="lookup.imageUrl"
:alt="lookup.productTitle ?? ''"
draggable="false"
/>
</div>
</Transition>
</Teleport>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
@@ -58,6 +84,26 @@ const props = defineProps<{ value: OrderItemDetail }>()
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
const previewOpen = ref(false)
let lockedBody = false
function openPreview() {
if (!lookup.value?.imageUrl) return
previewOpen.value = true
if (document.documentElement.style.overflow !== 'hidden') {
document.body.style.overflow = 'hidden'
lockedBody = true
}
}
function closePreview() {
previewOpen.value = false
if (lockedBody) document.body.style.overflow = ''
lockedBody = false
}
onBeforeUnmount(closePreview)
</script>
<style scoped lang="less">
@@ -74,16 +120,23 @@ const lookup = computed(() => orderItemLookup(props.value))
flex: 0 0 62px;
width: 62px;
height: 62px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
}
.oi__art--preview {
cursor: zoom-in;
}
.oi__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
@@ -94,6 +147,57 @@ html.theme-dark .oi__art img {
border-radius: 12px;
}
.oi__preview {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 56px 20px calc(24px + env(safe-area-inset-bottom, 0px));
background: rgba(10, 10, 12, 0.55);
backdrop-filter: blur(28px) saturate(140%);
}
.oi__previewClose {
position: absolute;
top: calc(14px + env(safe-area-inset-top, 0px));
inset-inline-end: 14px;
z-index: 2;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 12px;
cursor: pointer;
color: #fff;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
transform: rotate(45deg);
}
.oi__previewClose:active {
transform: rotate(45deg) scale(0.92);
}
.oi__previewImg {
max-width: min(100%, 560px);
max-height: 100%;
object-fit: contain;
border-radius: 16px;
background: #fff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
}
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.2s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
opacity: 0;
}
.oi__artEmpty {
width: 100%;
height: 100%;
@@ -0,0 +1,252 @@
<!--
Shared pull-to-refresh: Vant handles the gesture; content stays put.
Full-viewport premium overlay (dark + blur) with a large centered logo.
The ring around the logo fills with pull distance, then spins while loading.
-->
<template>
<div class="app-ptr">
<Teleport to="body">
<div
class="app-ptr__overlay"
aria-hidden="true"
:class="{ 'is-visible': overlayVisible, 'is-loading': isLoading }"
:style="overlayStyle"
>
<div class="app-ptr__stage" :style="stageStyle">
<div class="app-ptr__mark">
<svg
class="app-ptr__ring keep-motion"
:class="{ 'is-spinning': isLoading }"
viewBox="0 0 120 120"
aria-hidden="true"
>
<circle class="app-ptr__ringTrack" cx="60" cy="60" :r="RING_R" />
<circle
class="app-ptr__ringFill"
cx="60"
cy="60"
:r="RING_R"
:style="ringFillStyle"
/>
</svg>
<img
src="/img/brand-logo.png"
:alt="brand.name"
class="app-ptr__logo"
draggable="false"
/>
</div>
</div>
</div>
</Teleport>
<PullRefresh
class="app-ptr__vant"
:model-value="modelValue"
:disabled="disabled"
:head-height="HEAD_HEIGHT"
:pull-distance="PULL_DISTANCE"
:success-duration="0"
@update:model-value="onModelUpdate"
@refresh="emit('refresh')"
@change="onChange"
>
<template #pulling><span /></template>
<template #loosing><span /></template>
<template #loading><span /></template>
<template #success><span /></template>
<slot />
</PullRefresh>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { PullRefresh } from 'vant'
import { brand } from '../../brand'
// Longer travel so the ring fill is readable (short pulls used to jump to full).
const HEAD_HEIGHT = 100
const PULL_DISTANCE = 168
const RING_R = 54
const RING_C = 2 * Math.PI * RING_R
const props = withDefaults(
defineProps<{
modelValue: boolean
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
refresh: []
}>()
type PtrStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
const distance = ref(0)
const status = ref<PtrStatus>('normal')
function onChange(payload: { status: string; distance: number }) {
status.value = payload.status as PtrStatus
distance.value = payload.distance
}
function onModelUpdate(value: boolean) {
emit('update:modelValue', value)
if (!value) {
distance.value = 0
status.value = 'normal'
}
}
const isLoading = computed(() => status.value === 'loading' || props.modelValue)
/** Linear pull 0→1 over PULL_DISTANCE (overlay / scale use this). */
const pullProgress = computed(() => {
if (isLoading.value) return 1
return Math.min(distance.value / PULL_DISTANCE, 1)
})
/**
* Ring fill eased so early pull stays visibly incomplete — linear fill on a
* short gesture looked "already full" before the eye caught up.
*/
const ringProgress = computed(() => {
if (isLoading.value) return 1
const p = pullProgress.value
return Math.pow(p, 1.55)
})
const overlayVisible = computed(() => pullProgress.value > 0.02 || isLoading.value)
const overlayStyle = computed(() => {
const p = pullProgress.value
return {
opacity: overlayVisible.value ? Math.min(0.18 + p * 0.82, 1) : 0,
pointerEvents: (isLoading.value ? 'auto' : 'none') as 'auto' | 'none',
}
})
const stageStyle = computed(() => {
const p = pullProgress.value
const scale = 0.78 + p * 0.22
return {
transform: `scale(${scale})`,
opacity: Math.min(0.45 + p * 0.55, 1),
}
})
/** Arc fill follows eased pull; full ring while loading (then CSS spins). */
const ringFillStyle = computed(() => {
const p = ringProgress.value
return {
strokeDasharray: `${RING_C}`,
strokeDashoffset: `${RING_C * (1 - p)}`,
}
})
</script>
<style scoped lang="less">
.app-ptr {
position: relative;
min-height: 100%;
}
.app-ptr__overlay {
position: fixed;
inset: 0;
z-index: 1900;
display: grid;
place-items: center;
background: rgba(8, 8, 10, 0.58);
backdrop-filter: blur(28px) saturate(130%);
-webkit-backdrop-filter: blur(28px) saturate(130%);
transition: opacity 160ms ease-out;
}
.app-ptr__overlay:not(.is-visible) {
visibility: hidden;
}
.app-ptr__stage {
display: grid;
place-items: center;
transform-origin: center center;
will-change: transform, opacity;
transition: transform 120ms ease-out, opacity 120ms ease-out;
}
.app-ptr__mark {
position: relative;
width: 152px;
height: 152px;
display: grid;
place-items: center;
}
.app-ptr__logo {
width: 112px;
height: 112px;
object-fit: contain;
filter: drop-shadow(0 14px 32px rgba(0, 0, 0, 0.5));
z-index: 1;
}
.app-ptr__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
overflow: visible;
}
.app-ptr__ringTrack {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 3;
}
.app-ptr__ringFill {
fill: none;
stroke: rgba(255, 255, 255, 0.95);
stroke-width: 3;
stroke-linecap: round;
/* No CSS transition — fill is driven live by finger distance. */
transition: none;
}
.app-ptr__ring.is-spinning {
animation: app-ptr-spin 0.9s linear infinite;
}
.app-ptr__ring.is-spinning .app-ptr__ringFill {
/* Indeterminate feel: short arc chasing while the svg rotates. */
stroke-dasharray: 80 260 !important;
stroke-dashoffset: 0 !important;
transition: none;
}
@keyframes app-ptr-spin {
to {
transform: rotate(270deg);
}
}
/* Kill Vant's content translate — gesture + distance still update via JS. */
.app-ptr__vant {
overflow: visible !important;
}
.app-ptr :deep(.van-pull-refresh__track) {
transform: none !important;
transition: none !important;
}
.app-ptr :deep(.van-pull-refresh__head) {
display: none;
}
</style>
+30 -34
View File
@@ -16,44 +16,40 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
CANCELLED: 'لغو شده',
};
// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever
// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here
// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED
// stay uncolored, same as legacy's untouched sts=0/2).
// Status pill colours for list cards + detail drawer. Every real OrderStatus
// gets a tag wash so none render as bare text (SUSPENDED / CANCELLED /
// ADMIN_APPROVED used to be uncolored and almost invisible).
//
// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the
// opaque pale tints they used to be: a translucent wash reads the same on a
// white card and stays a soft tint on a dark one, where #EFF6FF & co. were
// glaring light patches.
// Backgrounds use ~18% alpha of the font colour so the pill reads as a tag
// on both light and dark canvases.
export const ORDER_STATUS_COLORS: Record<string, { font: string; background: string }> = {
PLACED: { font: '#3B82F6', background: '#3B82F61F' },
SOURCING: { font: '#EAB308', background: '#EAB3081F' },
PREPARING: { font: '#F97316', background: '#F973161F' },
DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' },
DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' },
SUSPENDED: { font: '#B45309', background: '#F59E0B2E' },
PLACED: { font: '#2563EB', background: '#3B82F62E' },
ADMIN_APPROVED: { font: '#4F46E5', background: '#6366F12E' },
SOURCING: { font: '#A16207', background: '#EAB3082E' },
PREPARING: { font: '#C2410C', background: '#F973162E' },
DELIVERED_TO_AGENT: { font: '#0E7490', background: '#06B6D42E' },
DELIVERED_TO_CUSTOMER: { font: '#15803D', background: '#22C55E2E' },
CANCELLED: { font: '#B91C1C', background: '#EF44442E' },
};
// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`).
// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our
// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet
// progressing), matching the spirit of legacy's grouping of early statuses.
export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده'];
/**
* Linear pipeline shown in the order-detail tracker — same order as the admin
* STATUS_FLOW / Prisma OrderStatus happy path. SUSPENDED and CANCELLED sit
* outside this pipeline (badge only; step index is -1).
*/
export const ORDER_FLOW_STATUSES = [
'PLACED',
'ADMIN_APPROVED',
'SOURCING',
'PREPARING',
'DELIVERED_TO_AGENT',
'DELIVERED_TO_CUSTOMER',
] as const;
export const ORDER_STEPS = ORDER_FLOW_STATUSES.map((status) => ORDER_STATUS_LABELS[status]);
/** Index into ORDER_STEPS, or -1 when the status is not on the pipeline. */
export function orderStepIndex(status: string): number {
switch (status) {
case 'PLACED':
case 'ADMIN_APPROVED':
case 'SUSPENDED':
return 0;
case 'SOURCING':
case 'PREPARING':
return 1;
case 'DELIVERED_TO_AGENT':
case 'DELIVERED_TO_CUSTOMER':
return 2;
case 'CANCELLED':
return 3;
default:
return 0;
}
return (ORDER_FLOW_STATUSES as readonly string[]).indexOf(status);
}
+6 -7
View File
@@ -32,11 +32,7 @@
<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>
<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
@@ -62,7 +58,7 @@
دیدن محصولات
</button>
</section>
</PullRefresh>
</AppPullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
@@ -153,7 +149,7 @@
import { 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 AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogLogin.svg'
import Icon from '../components/icon/Icon.vue'
@@ -218,6 +214,9 @@ const submitOrder = async () => {
// 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,
// Wholesale (عمده) — same as legacy mobile; server also forces this for
// customer placements so admin omde list shows the order.
channel: 'WHOLESALE',
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
+3 -7
View File
@@ -34,11 +34,7 @@
</div>
</header>
<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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="home__body" :class="{ 'is-revealed': revealed }">
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
@@ -103,7 +99,7 @@
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
</template>
@@ -111,7 +107,7 @@
import { brand } from '../brand'
import { computed, onActivated, onMounted, 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 RateStrip from '../components/home/RateStrip.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
@@ -36,11 +36,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" 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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cho__body">
<template v-if="rows.length">
<div class="cho__list">
@@ -99,7 +95,7 @@
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
<div ref="sentinel" class="cho__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -146,7 +142,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } 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'
@@ -40,11 +40,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length" 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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="cch__body">
<div v-if="rows.length" class="cch__list">
<article
@@ -88,7 +84,7 @@
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -143,7 +139,7 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
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'
@@ -52,11 +52,7 @@
</PageBar>
<Spinner :spinning="loading && !rows.length && !details.length" 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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="rep__body">
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
<template v-if="activeKey === '1'">
@@ -124,7 +120,7 @@
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
<div ref="sentinel" class="rep__sentinel" />
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -151,7 +147,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
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'
@@ -61,11 +61,7 @@
indicator, over the results already on screen.
-->
<div>
<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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="search__body">
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
<div class="search__skHead">
@@ -117,7 +113,7 @@
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</div>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
@@ -133,7 +129,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from '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'
+102 -10
View File
@@ -48,15 +48,36 @@
</div>
</header>
<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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer">
<section class="dash__balance reveal" :style="revealDelay(0)">
<!--
Incomplete profile notice — same gate as ProfileView's
"تکمیل پروفایل" button (docs/plans/2026-10-16-dashboard-incomplete-profile-banner-design.md).
-->
<section
v-if="needsProfileComplete"
class="dash__profileNudge reveal"
:style="revealDelay(0)"
>
<div class="dash__profileNudgeIcon" aria-hidden="true">
<Icon icon="vuesax-linear:info-circle" size="16" />
</div>
<div class="dash__profileNudgeCopy">
<h3>پروفایل شما تکمیل نشده است</h3>
<p>برای ادامه، مدارک پروفایل را بارگذاری کنید.</p>
</div>
<button
type="button"
class="dash__profileNudgeCta"
@click="router.push({ name: 'completeProfile' })"
>
تکمیل پروفایل
</button>
</section>
<section class="dash__balance reveal" :style="revealDelay(needsProfileComplete ? 1 : 0)">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
@@ -97,7 +118,7 @@
</section>
<!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)">
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 2 : 1)">
<div class="dash__access">
<button
v-for="item in list"
@@ -119,12 +140,12 @@
</section>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section reveal" :style="revealDelay(2)">
<section class="dash__section reveal" :style="revealDelay(customer && needsProfileComplete ? 3 : 2)">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
</container>
</PullRefresh>
</AppPullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
@@ -159,7 +180,7 @@
import { brand } from '../../brand'
import { computed, 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 SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
@@ -190,6 +211,15 @@ const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
/**
* Same gate as ProfileView's "تکمیل پروفایل" CTA: show while documents have
* not been submitted (STEP_TWO_SUBMITTED) or approved (VERIFIED).
*/
const needsProfileComplete = computed(() => {
const step = customer.value?.verificationStep
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
})
const data = reactive({ notify: false, loginNotify: false })
const BASE_TILES = [
@@ -370,6 +400,68 @@ onMounted(async () => {
padding-bottom: 90px;
}
.dash__profileNudge {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid alpha(@wine, 16%);
background: @wineWash;
}
.dash__profileNudgeIcon {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
color: @wine;
background: alpha(@wine, 10%);
}
.dash__profileNudgeCopy {
flex: 1 1 140px;
min-width: 0;
}
.dash__profileNudgeCopy h3 {
margin: 0;
font-size: 12.5px;
font-weight: 500;
color: @ink;
line-height: 1.35;
}
.dash__profileNudgeCopy p {
margin: 2px 0 0;
font-size: 11.5px;
font-weight: 400;
line-height: 1.4;
color: @inkMuted;
}
.dash__profileNudgeCta {
flex: 0 0 auto;
margin-inline-start: auto;
padding: 7px 11px;
border: 0;
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 11.5px;
font-weight: 500;
color: #fff;
background: @primary;
}
.dash__profileNudgeCta:active {
transform: scale(0.96);
}
.dash__balance {
padding: 16px;
border-radius: 20px;
+76 -28
View File
@@ -48,11 +48,7 @@
</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>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="filteredOrders.length">
<div class="ord__list">
@@ -65,7 +61,7 @@
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</AppPullRefresh>
</Spinner>
<Drawer
@@ -73,22 +69,22 @@
placement="bottom"
v-if="viewData.item"
:closable="false"
height="100vh"
class="rtl"
:bodyStyle="{ padding: 0, position: 'relative' }"
height="100%"
:z-index="2000"
class="rtl orders-detail-drawer"
:bodyStyle="{ padding: 0, height: '100%', overflow: 'hidden' }"
>
<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" />
</header>
<div class="detail__scroll">
<!-- Progress -->
<section class="detail__block">
<!-- Progress — real OrderStatus pipeline (not the old 4-step collapse) -->
<section v-if="currentStep >= 0" class="detail__block">
<div class="detail__steps">
<div
v-for="(step, index) in ORDER_STEPS"
@@ -147,9 +143,9 @@
</template>
<script setup lang="ts">
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { computed, 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'
@@ -164,11 +160,13 @@ import {
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useNavStore } from '../../stores/navStore'
import type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const session = useSessionStore()
const nav = useNavStore()
const reads = useCachedReads()
const tabs = [
@@ -227,6 +225,23 @@ function openDetail(item: OrderDetail) {
viewData.model = true
}
function closeDetail() {
viewData.model = false
}
// Full-screen detail must own the viewport: hide the tab bar so it cannot
// cover the sheet, and restore it when the drawer closes (or this view leaves).
watch(
() => viewData.model,
(open) => {
nav.setNavVisibility(!open)
},
)
onBeforeUnmount(() => {
if (!nav.showNav) nav.setNavVisibility(true)
})
async function onRefresh() {
await loadOrders(true)
refreshing.value = false
@@ -339,32 +354,44 @@ onActivated(() => {
/* ── Detail drawer ─────────────────────────────────────────────────── */
.detail {
height: 100vh;
// Fill the drawer body — never 100vh: on mobile browsers that unit is
// taller than the visible viewport, so a bottom sheet's header sat above
// the screen and the close control was unreachable.
height: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
background: @canvas;
min-height: 0;
}
.detail__bar {
flex: 0 0 auto;
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 16px;
padding: calc(11px + env(safe-area-inset-top, 0px)) 16px 11px;
border-bottom: 1px solid @hairline;
background: alpha(@canvas, 92%);
background: alpha(@canvas, 96%);
backdrop-filter: blur(14px);
}
.detail__title {
margin: 0;
flex: 1 1 auto;
font-size: 14px;
font-weight: 800;
color: @ink;
text-align: center;
/* Balance the close button so the title sits visually centered. */
padding-inline-end: 38px;
}
.detail__close {
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
@@ -376,17 +403,17 @@ onActivated(() => {
background: @hairlineSoft;
}
.detail__spacer {
display: block;
width: 38px;
height: 38px;
.detail__close:active {
transform: scale(0.94);
}
.detail__scroll {
flex: 1;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
padding-bottom: 40px;
padding-bottom: calc(40px + env(safe-area-inset-bottom, 0px));
}
.detail__block {
@@ -476,11 +503,13 @@ onActivated(() => {
.detail__status {
flex: 0 0 auto;
padding: 5px 12px;
padding: 6px 14px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
font-size: 11.5px;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
line-height: 1.2;
}
.detail__specs {
@@ -522,3 +551,22 @@ onActivated(() => {
gap: 10px;
}
</style>
<!-- Drawer teleports to <body> — viewport lock must live outside scoped CSS. -->
<style lang="less">
.orders-detail-drawer.ant-drawer-bottom .ant-drawer-content-wrapper {
// Prefer the dynamic viewport so the sheet never taller than what the user
// can see (classic 100vh / mobile URL-bar bug that hid the close bar).
height: 100% !important;
max-height: 100vh !important;
max-height: 100dvh !important;
}
.orders-detail-drawer .ant-drawer-content,
.orders-detail-drawer .ant-drawer-wrapper-body,
.orders-detail-drawer .ant-drawer-body {
height: 100% !important;
max-height: 100%;
overflow: hidden;
}
</style>