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,568 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue.
|
||||
|
||||
Legacy's original (820 lines) is a multi-role workbench mixing
|
||||
plain-customer, sales-agent ("reagent") and "banker" (type=1, sub-account
|
||||
switching) views. Kept: the real balance card, the nav grid to
|
||||
Orders/Documents/Profile/Favorites, and the logout/login-nudge dialogs —
|
||||
all backed by real data.
|
||||
|
||||
DROPPED as architecturally incompatible, not merely deferred: everything
|
||||
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
|
||||
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
|
||||
customerChoice. Sales agents are AdminUser records with no customer-facing
|
||||
session at all, and there is no `type` field on our Customer model, so the
|
||||
data model this UI assumed no longer exists.
|
||||
|
||||
DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing)
|
||||
— PreOrder has a DB table but no controller anywhere in apps/api.
|
||||
|
||||
REPLACED, not dropped: legacy's "گزارش حساب" called out to the external
|
||||
Tahesab accounting service, which the first migration audit retired in
|
||||
favour of this system's own internal ledger — the balance card below shows
|
||||
that real internal position directly.
|
||||
|
||||
DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
|
||||
rows). The new API has one single-karat quote endpoint, folded into the
|
||||
bottom rate row rather than fabricating rows that don't exist.
|
||||
|
||||
Second visual pass — a deliberately different structure from the previous
|
||||
"card + 4-cell grid" layout, while staying in the same visual language as
|
||||
HomeView (same bar treatment, same reveal choreography, same @ink/@wine
|
||||
palette — no gold/bronze; this app's identity is black + burgundy only):
|
||||
|
||||
1. a bare, logo-height bar (no title chrome), matching Home's bar rhythm
|
||||
2. one full hero card: balance figure + identity, with quick actions
|
||||
built INTO the card itself as an icon row on its own dark surface —
|
||||
replacing the old "card, then a separate white 4-cell grid below it"
|
||||
split
|
||||
3. "اسناد" / "علاقمندی" as wide settings-style rows with a chevron,
|
||||
replacing the grid tiles those two also occupied
|
||||
4. the live rate folded into a single slim inline row at the foot of the
|
||||
page instead of a titled section
|
||||
|
||||
Data, routes, dialogs and auth behaviour are all unchanged from before —
|
||||
only markup structure and styling moved.
|
||||
-->
|
||||
<template>
|
||||
<div class="dash">
|
||||
<header class="dash__bar">
|
||||
<div class="dash__barInner">
|
||||
<span class="dash__brand">میز کار</span>
|
||||
<button
|
||||
v-if="session.customerId"
|
||||
type="button"
|
||||
class="dash__exit"
|
||||
aria-label="خروج"
|
||||
@click="data.notify = true"
|
||||
>
|
||||
<Icon icon="vuesax-linear:logout" size="18" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<div class="dash__heroIdentity">
|
||||
<span class="dash__heroName">{{ customer.fullName }}</span>
|
||||
<span class="dash__heroMobile">{{ session.mobile }}</span>
|
||||
</div>
|
||||
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
|
||||
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="dash__heroFigure">
|
||||
<span class="dash__heroNum">{{ grams }}</span>
|
||||
<span class="dash__heroUnit">گرم {{ remainText }}</span>
|
||||
</div>
|
||||
|
||||
<div class="dash__heroDebt">
|
||||
<span>بدهی ریالی</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>
|
||||
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
|
||||
ورود / ثبتنام
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Settings-style rows: every destination the hero no longer carries
|
||||
as its own quick-action row. -->
|
||||
<section class="dash__rows dash__reveal" :style="revealDelay(1)">
|
||||
<button type="button" class="dash__row" @click="goToDetails(0)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:box" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>سفارشات</b>
|
||||
<small>پیگیری سفارشهای شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(1)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:receipt-text" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>اسناد</b>
|
||||
<small>تراکنشها و رسیدهای حساب</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(2)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:user" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>پروفایل</b>
|
||||
<small>اطلاعات و تنظیمات حساب</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
|
||||
<button type="button" class="dash__row" @click="goToDetails(3)">
|
||||
<span class="dash__rowIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:heart" size="18" />
|
||||
</span>
|
||||
<span class="dash__rowText">
|
||||
<b>علاقمندی</b>
|
||||
<small>محصولات ذخیرهشده شما</small>
|
||||
</span>
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Live 18k rate — a single inline row, not a titled section. -->
|
||||
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
|
||||
</container>
|
||||
</PullRefresh>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
|
||||
</template>
|
||||
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
|
||||
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="data.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="data.loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import RateStrip from '../../components/home/RateStrip.vue'
|
||||
import DialogVant from '../../components/DialogVant.vue'
|
||||
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
import { price } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
|
||||
const customer = ref<CustomerDetail | null>(null)
|
||||
const goldPrice = ref<GoldPriceQuote | null>(null)
|
||||
const refreshing = 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 data = reactive({ notify: false, loginNotify: false })
|
||||
|
||||
function goToDetails(id: number) {
|
||||
if (!session.customerId) {
|
||||
data.loginNotify = true
|
||||
return
|
||||
}
|
||||
if (id === 0) router.push({ name: 'ordersList' })
|
||||
if (id === 1) router.push({ name: 'transactionsList' })
|
||||
if (id === 2) router.push({ name: 'profileData' })
|
||||
if (id === 3) router.push({ name: 'favoritesList' })
|
||||
}
|
||||
|
||||
function logOut() {
|
||||
session.logout()
|
||||
data.notify = false
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
if (session.customerId) {
|
||||
customer.value = await api.getCustomer(session.customerId)
|
||||
}
|
||||
try {
|
||||
goldPrice.value = await api.getLatestGoldPrice()
|
||||
} catch {
|
||||
goldPrice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await loadDashboard()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
// First-paint entrance, mirroring HomeView's choreography exactly (module-scope
|
||||
// flag so it only replays on a full app reload, not on every tab switch).
|
||||
let hasRevealed = false
|
||||
const revealed = ref(hasRevealed)
|
||||
const REVEAL_STEP_MS = 70
|
||||
|
||||
function revealDelay(i: number) {
|
||||
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
if (revealed.value) return
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
revealed.value = true
|
||||
hasRevealed = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dash {
|
||||
background: @canvas;
|
||||
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: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
border-bottom: 1px solid fade(@primary, 5%);
|
||||
box-shadow: 0 8px 20px -18px fade(@primary, 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;
|
||||
}
|
||||
|
||||
.dash__exit {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dash__exit:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.dash__body {
|
||||
padding-top: 58px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.dash__reveal {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 14px, 0) scale(0.98);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.is-revealed .dash__reveal {
|
||||
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);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dash__reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.is-revealed .dash__reveal {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
.dash__hero {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 28px;
|
||||
padding: 20px 18px 16px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(155deg, @ink 0%, @ink 55%, @wineDeep 130%);
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
|
||||
}
|
||||
|
||||
.dash__heroTop {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dash__heroIdentity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dash__heroName {
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dash__heroMobile {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dash__heroSeal {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dash__heroSeal.is-debtor {
|
||||
color: #fff;
|
||||
background: @wineSoft;
|
||||
}
|
||||
.dash__heroSeal.is-creditor {
|
||||
color: @ink;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.dash__heroFigure {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash__heroNum {
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.dash__heroUnit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.dash__heroDebt {
|
||||
display: flex;
|
||||
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);
|
||||
font-size: 11.5px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.dash__heroDebt b {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.dash__heroDebt i {
|
||||
font-style: normal;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
padding-inline-start: 3px;
|
||||
}
|
||||
|
||||
// ── Signed-out invite ──────────────────────────────────────────────────
|
||||
.dash__invite {
|
||||
margin-top: 16px;
|
||||
padding: 26px 20px;
|
||||
border-radius: 26px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
background: #fff;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dash__inviteCta:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
// ── Settings-style rows ────────────────────────────────────────────────
|
||||
.dash__rows {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.dash__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 17px;
|
||||
cursor: pointer;
|
||||
background: @canvas;
|
||||
font-family: inherit;
|
||||
text-align: right;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.dash__row:active {
|
||||
transform: scale(0.985);
|
||||
border-color: fade(@wine, 20%);
|
||||
}
|
||||
|
||||
.dash__rowIcon {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.dash__rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.dash__rowText b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
.dash__rowText small {
|
||||
font-size: 10.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
// ── Rate row ───────────────────────────────────────────────────────────
|
||||
.dash__rate {
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue,
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Real, not stubbed: favorites are the local favoritesStore (per-device, no
|
||||
backend persistence — see favoritesStore.ts from an earlier batch), filtered
|
||||
against the real product catalog client-side. No pagination — the whole
|
||||
catalog is fetched once (same "small catalog" approach as everywhere else).
|
||||
That logic is carried over unchanged; only the chrome and the card change.
|
||||
-->
|
||||
<template>
|
||||
<div class="favs">
|
||||
<PageBar title="علاقهمندی" back-to="userPage::hasSubRoute" />
|
||||
|
||||
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
|
||||
<container class="favs__body">
|
||||
<template v-if="favoriteProducts.length">
|
||||
<SectionHead title="ذخیرهشدهها" :subtitle="`${favoriteProducts.length} محصول`" />
|
||||
<div class="favs__grid">
|
||||
<ProductCard v-for="item in favoriteProducts" :key="item.id" :value="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="favs__empty">
|
||||
<Icon icon="vuesax-linear:heart" size="26" />
|
||||
<p>هنوز محصولی را به علاقهمندیها اضافه نکردهاید.</p>
|
||||
<button type="button" class="favs__emptyCta" @click="router.push({ name: 'home' })">
|
||||
دیدن محصولات
|
||||
</button>
|
||||
</section>
|
||||
</container>
|
||||
</Spinner>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import ProductCard from '../../components/product/ProductCard.vue'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { api, type ProductSummary } from '../../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = useFavoritesStore()
|
||||
const loading = ref(false)
|
||||
const allProducts = ref<ProductSummary[]>([])
|
||||
|
||||
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
allProducts.value = await api.listProducts()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.favs {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.favs__body {
|
||||
padding-top: 76px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.favs__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.favs__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;
|
||||
}
|
||||
.favs__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.favs__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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- Ported from legacy henza_pwa/src/views/user/LoginRegister/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import LoginView from './LoginView.vue'
|
||||
import RegisterView from './RegisterView.vue'
|
||||
|
||||
const showRegister = ref(false)
|
||||
const register = (state: boolean) => (showRegister.value = state)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView @register="register" v-if="!showRegister" />
|
||||
<RegisterView @register="register" v-if="showRegister" />
|
||||
</template>
|
||||
@@ -0,0 +1,313 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Per the earlier decision: OTP-based flow ported structurally, but the
|
||||
SMS-send step is stubbed — no SMS provider exists. What's real vs stubbed,
|
||||
all carried over unchanged from the previous version:
|
||||
- Step 1 (mobile number) is checked against the REAL, already-existing
|
||||
GET /api/customers endpoint (client-side match on `mobile`) — no fake
|
||||
endpoint invented. A non-match shows legacy's own
|
||||
"اطلاعات کاربری در سیستم وجود ندارد" text, not a new message.
|
||||
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
|
||||
there is no real code to check against. On "verify", the session store
|
||||
logs in as the matched real customer, so downstream features (balance,
|
||||
ledger, favourites-as-logged-in) work against real seeded data.
|
||||
- `token.fireBaseToken` doesn't exist in this port (Firebase deferred).
|
||||
- `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
|
||||
-->
|
||||
<template>
|
||||
<div class="auth">
|
||||
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
|
||||
|
||||
<PageBar title="ورود" back-to="home" />
|
||||
|
||||
<container class="auth__body">
|
||||
<section class="auth__intro">
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="28" />
|
||||
</div>
|
||||
<h2 class="auth__title">ورود به منتزر</h2>
|
||||
<p class="auth__sub">
|
||||
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="auth__card">
|
||||
<!-- Step 2: OTP -->
|
||||
<template v-if="body.step === 2">
|
||||
<OtpInput
|
||||
class="auth__otp"
|
||||
input-classes="otp-input"
|
||||
inputType="tel"
|
||||
:num-inputs="4"
|
||||
:should-auto-focus="true"
|
||||
:defaultFocus="body.defaultFocus"
|
||||
@on-complete="sendToken"
|
||||
/>
|
||||
|
||||
<p v-if="timer > 0" class="auth__timer">
|
||||
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
|
||||
</p>
|
||||
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
|
||||
</template>
|
||||
|
||||
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
|
||||
<FormItem
|
||||
v-show="body.step === 1"
|
||||
name="username"
|
||||
label="تلفن همراه"
|
||||
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
|
||||
>
|
||||
<Input type="number" v-model:value="formModel.username" />
|
||||
</FormItem>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="auth__cta"
|
||||
:class="{ 'is-loading': body.loading }"
|
||||
:disabled="body.loading || (timer > 0 && body.step === 2)"
|
||||
>
|
||||
{{ btnText }}
|
||||
</button>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<p class="auth__switch">
|
||||
اگر ثبت نام نکردهاید
|
||||
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
|
||||
</p>
|
||||
</container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 NotifyVant from '../../components/NotifyVant.vue'
|
||||
import OtpInput from '../../components/input/OtpInput.vue'
|
||||
import { required, mobile as mobileRule } from '../../utils/validator'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type CustomerSummary } from '../../api/client'
|
||||
|
||||
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
const formModel = reactive({ username: undefined as string | undefined })
|
||||
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
|
||||
const body = reactive({
|
||||
step: 1,
|
||||
loading: false,
|
||||
defaultFocus: false,
|
||||
})
|
||||
|
||||
let matchedCustomer: CustomerSummary | null = null
|
||||
|
||||
const timer = ref(0)
|
||||
const countDownTimer = () => {
|
||||
if (timer.value > 0) {
|
||||
setTimeout(() => {
|
||||
if (timer.value > 0) {
|
||||
timer.value -= 1
|
||||
countDownTimer()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const sendPhone = async () => {
|
||||
try {
|
||||
body.loading = true
|
||||
const customers = await api.listCustomers()
|
||||
matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null
|
||||
if (!matchedCustomer) {
|
||||
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
|
||||
notify.show = true
|
||||
return
|
||||
}
|
||||
body.step = 2
|
||||
timer.value = 120
|
||||
countDownTimer()
|
||||
await nextTick(() => {
|
||||
body.defaultFocus = true
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
body.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const sendToken = async (_otpCode: string) => {
|
||||
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
|
||||
// check it against). `matchedCustomer` was already confirmed real in
|
||||
// sendPhone(), so any 4-digit code here logs in as that customer.
|
||||
try {
|
||||
body.loading = true
|
||||
if (!matchedCustomer) return
|
||||
session.login(matchedCustomer.id, matchedCustomer.mobile)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
||||
await router.push(redirect ?? { name: 'home' })
|
||||
notify.text = 'با موفقیت وارد شدید'
|
||||
notify.show = true
|
||||
} finally {
|
||||
body.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
|
||||
|
||||
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.auth {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.auth__body {
|
||||
padding-top: 86px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.auth__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
margin: 14px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.auth__sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
padding: 18px 16px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 20px;
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.auth__otp {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
direction: ltr;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.auth__timer {
|
||||
margin: 12px 0 16px;
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item-label > label) {
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.auth__cta {
|
||||
width: 100%;
|
||||
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, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.auth__cta:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.auth__cta:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.auth__switch {
|
||||
margin: 20px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.auth__switch button {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 3px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
|
||||
be scoped to this component. */
|
||||
.otp-input {
|
||||
width: 48px;
|
||||
height: 52px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 13px;
|
||||
text-align: center;
|
||||
transition: border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.otp-input:focus {
|
||||
outline: none;
|
||||
border-color: @ink;
|
||||
}
|
||||
|
||||
.otp-input.error {
|
||||
border-color: @danger !important;
|
||||
}
|
||||
|
||||
.otp-input::-webkit-inner-spin-button,
|
||||
.otp-input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
New nav destination (bottom nav redesign) — no legacy equivalent and no
|
||||
notifications backend exists in apps/api. Renders the same dashed
|
||||
empty-state pattern used elsewhere (see FavoritesView) rather than
|
||||
fabricating fake notification data.
|
||||
-->
|
||||
<template>
|
||||
<div class="notif">
|
||||
<PageBar title="اعلانها" back-to="home" />
|
||||
|
||||
<container class="notif__body">
|
||||
<section class="notif__empty">
|
||||
<Icon icon="vuesax-linear:notification" size="26" />
|
||||
<p>اعلانی برای نمایش وجود ندارد.</p>
|
||||
</section>
|
||||
</container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.notif {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.notif__body {
|
||||
padding-top: 76px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.notif__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;
|
||||
}
|
||||
.notif__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue,
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Adapted (unchanged from the previous version):
|
||||
- Status filter chips use our real OrderStatus enum values directly (see
|
||||
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).
|
||||
- Search matches against `orderNumber` (our real user-facing identifier)
|
||||
instead of legacy's numeric `id`.
|
||||
|
||||
DROPPED, unchanged: "دریافت فاکتور" — legacy's handler opened a hardcoded
|
||||
link to the real legacy production domain. Not merely unimplemented; an
|
||||
external production URL that must never be carried into this port. No
|
||||
invoice-generation endpoint exists in the new API either.
|
||||
|
||||
The detail view is a bottom Drawer rather than a full-screen ant Modal with
|
||||
hand-written overrides, matching how every other detail surface in the
|
||||
redesign opens (product detail, cart note, ledger entry).
|
||||
-->
|
||||
<template>
|
||||
<div class="ord">
|
||||
<PageBar title="سفارشات" back-to="home">
|
||||
<template #below>
|
||||
<div class="ord__barExtra">
|
||||
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
|
||||
|
||||
<div class="ord__chips no-scrollbar">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="ord__chip"
|
||||
:class="{ 'is-on': activeTab === item.key }"
|
||||
@click="activeTab = item.key"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageBar>
|
||||
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
|
||||
<PullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="ord__body">
|
||||
<template v-if="filteredOrders.length">
|
||||
<div class="ord__list">
|
||||
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="ord__empty">
|
||||
<Icon icon="vuesax-linear:box" size="26" />
|
||||
<p>سفارشی با این مشخصات پیدا نشد.</p>
|
||||
</section>
|
||||
</container>
|
||||
</PullRefresh>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
v-model:visible="viewData.model"
|
||||
placement="bottom"
|
||||
v-if="viewData.item"
|
||||
:closable="false"
|
||||
height="100vh"
|
||||
class="rtl"
|
||||
:bodyStyle="{ padding: 0, position: 'relative' }"
|
||||
>
|
||||
<div class="detail">
|
||||
<header class="detail__bar">
|
||||
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
|
||||
<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">
|
||||
<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 -->
|
||||
<section class="detail__block">
|
||||
<div class="detail__headline">
|
||||
<div>
|
||||
<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 }"
|
||||
>
|
||||
{{ modalStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="detail__specs">
|
||||
<div class="detail__spec">
|
||||
<dt>تعداد کالاها</dt>
|
||||
<dd>{{ viewData.item.items.length }} آیتم</dd>
|
||||
</div>
|
||||
<div class="detail__spec">
|
||||
<dt>وزن سفارش</dt>
|
||||
<dd>{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }} گرم</dd>
|
||||
</div>
|
||||
<div class="detail__spec">
|
||||
<dt>تاریخ سفارش</dt>
|
||||
<dd>{{ jdate(viewData.item.createdAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Items -->
|
||||
<section class="detail__block">
|
||||
<h3 class="detail__blockTitle">آیتمهای سفارش</h3>
|
||||
<div class="detail__items">
|
||||
<OrderItemCard v-for="item in viewData.item.items" :key="item.id" :value="item" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { PullRefresh } from 'vant'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
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_STATUS_LABELS,
|
||||
ORDER_STEPS,
|
||||
orderStepIndex,
|
||||
} from '../../utils/orderStatus'
|
||||
import { jdate } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type OrderDetail } from '../../api/client'
|
||||
|
||||
const session = useSessionStore()
|
||||
|
||||
const tabs = [
|
||||
{ title: 'همه', key: 'ALL' },
|
||||
{ title: ORDER_STATUS_LABELS.SUSPENDED, key: 'SUSPENDED' },
|
||||
{ title: ORDER_STATUS_LABELS.PLACED, key: 'PLACED' },
|
||||
{ title: ORDER_STATUS_LABELS.ADMIN_APPROVED, key: 'ADMIN_APPROVED' },
|
||||
{ title: ORDER_STATUS_LABELS.SOURCING, key: 'SOURCING' },
|
||||
{ title: ORDER_STATUS_LABELS.PREPARING, key: 'PREPARING' },
|
||||
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_AGENT, key: 'DELIVERED_TO_AGENT' },
|
||||
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_CUSTOMER, key: 'DELIVERED_TO_CUSTOMER' },
|
||||
{ title: ORDER_STATUS_LABELS.CANCELLED, key: 'CANCELLED' },
|
||||
]
|
||||
const activeTab = ref('ALL')
|
||||
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const allOrders = ref<OrderDetail[]>([])
|
||||
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
|
||||
})
|
||||
|
||||
function readSearch(value: string | undefined) {
|
||||
searchTerm.value = value
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
if (!session.customerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
allOrders.value = await api.getCustomerOrders(session.customerId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
|
||||
const modalStatusLabel = computed(() =>
|
||||
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? 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))
|
||||
|
||||
function openDetail(item: OrderDetail) {
|
||||
viewData.item = item
|
||||
viewData.model = true
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
await loadOrders()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ord {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.ord__barExtra {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 0;
|
||||
}
|
||||
|
||||
.ord__chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0px 8px -16px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.ord__chip {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ord__chip.is-on {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
|
||||
.ord__chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.ord__body {
|
||||
padding-top: 160px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.ord__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 16px;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.ord__empty {
|
||||
margin-top: 8px;
|
||||
padding: 34px 20px;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.ord__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ── Detail drawer ─────────────────────────────────────────────────── */
|
||||
.detail {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: @canvas;
|
||||
}
|
||||
|
||||
.detail__bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid @hairline;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.detail__close {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @ink;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.detail__spacer {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.detail__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.detail__block {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.detail__blockTitle {
|
||||
margin: 0 0 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.detail__steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail__step {
|
||||
flex: 1 0 74px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail__stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @inkFaint;
|
||||
background: @hairlineSoft;
|
||||
border: 1px solid @hairline;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail__step.is-done .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
|
||||
.detail__step.is-active .detail__stepDot {
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.detail__stepTitle {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.detail__step.is-done .detail__stepTitle {
|
||||
color: @inkSoft;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail__headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.detail__eyebrow {
|
||||
font-size: 11px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.detail__number {
|
||||
padding-top: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.detail__status {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail__specs {
|
||||
margin: 0;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail__spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid @hairlineSoft;
|
||||
}
|
||||
|
||||
.detail__spec:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.detail__spec dt {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.detail__spec dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.detail__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
Heavily simplified when first ported, and still is — legacy's original (971
|
||||
lines, the largest view in the app) is a full edit form (bank info, KYC
|
||||
document uploads, sub-user management for banker accounts). None of that has
|
||||
a backend to back it: there's no update-customer endpoint, no
|
||||
document-upload endpoint, and (as in DashboardView) no banker/sub-account
|
||||
concept in the new Customer model at all. Kept: the real read-only info
|
||||
card, all backed by fields the API genuinely returns.
|
||||
|
||||
DROPPED, unchanged from the previous version: "تکمیل پروفایل", the
|
||||
"افزودن کاربر" sub-user FAB, and the "کارشناس فروش" contact card — that last
|
||||
one needs the referring AdminUser's name/mobile, which GET /customers/:id
|
||||
does not expose (only referredByAgentId, a bare id).
|
||||
-->
|
||||
<template>
|
||||
<div class="prof">
|
||||
<PageBar title="اطلاعات کاربری" back-to="userPage::hasSubRoute" />
|
||||
|
||||
<container class="prof__body">
|
||||
<!-- Identity -->
|
||||
<section class="prof__identity">
|
||||
<div class="prof__avatar" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:user" size="30" />
|
||||
</div>
|
||||
<h2 class="prof__name">{{ customer?.fullName ?? '—' }}</h2>
|
||||
<p v-if="customer" class="prof__mobile">{{ customer.mobile }}</p>
|
||||
<span v-if="customer" class="prof__seal" :class="isVerified ? 'is-ok' : 'is-pending'">
|
||||
{{ VERIFICATION_LABELS[customer.verificationStep] ?? customer.verificationStep }}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<!-- Real, read-only fields -->
|
||||
<section v-if="customer" class="prof__block">
|
||||
<SectionHead title="مشخصات" subtitle="اطلاعات ثبتشده حساب شما" />
|
||||
<dl class="prof__specs">
|
||||
<div class="prof__spec">
|
||||
<dt>نام و نام خانوادگی</dt>
|
||||
<dd>{{ customer.fullName }}</dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>تلفن همراه</dt>
|
||||
<dd>{{ customer.mobile }}</dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>کد ملی</dt>
|
||||
<dd>{{ customer.nationalCode }}</dd>
|
||||
</div>
|
||||
<div class="prof__spec">
|
||||
<dt>وضعیت احراز هویت</dt>
|
||||
<dd>{{ VERIFICATION_LABELS[customer.verificationStep] ?? customer.verificationStep }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!loading" class="prof__empty">
|
||||
<Icon icon="vuesax-linear:user" size="26" />
|
||||
<p>اطلاعات حساب در دسترس نیست.</p>
|
||||
</section>
|
||||
</container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type CustomerDetail } from '../../api/client'
|
||||
|
||||
const VERIFICATION_LABELS: Record<string, string> = {
|
||||
UNVERIFIED: 'احراز نشده',
|
||||
STEP_ONE_SUBMITTED: 'مرحله اول ثبت شده',
|
||||
STEP_TWO_SUBMITTED: 'مرحله دوم ثبت شده',
|
||||
VERIFIED: 'تایید شده',
|
||||
}
|
||||
|
||||
const session = useSessionStore()
|
||||
const customer = ref<CustomerDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!session.customerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
customer.value = await api.getCustomer(session.customerId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.prof {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.prof__body {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.prof__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 26px 20px 28px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 20px;
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.prof__avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.prof__name {
|
||||
margin: 14px 0 0;
|
||||
font-size: 15.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.prof__mobile {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: @inkMuted;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.prof__seal {
|
||||
margin-top: 12px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prof__seal.is-ok {
|
||||
color: @green;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.prof__seal.is-pending {
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.prof__block {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.prof__specs {
|
||||
margin: 0;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prof__spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid @hairlineSoft;
|
||||
}
|
||||
|
||||
.prof__spec:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.prof__spec dt {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.prof__spec dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prof__empty {
|
||||
margin-top: 26px;
|
||||
padding: 34px 20px;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.prof__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue,
|
||||
restyled into the redesigned design language (see components/home/* and
|
||||
HomeView).
|
||||
|
||||
DROPPED as confirmed-dead code (verified unreachable, not merely "possibly
|
||||
dead"): legacy rendered a whole cascading country->province->city picker
|
||||
(WorkActionSheet + selectCityArrays + getCities() against POST /cities/list)
|
||||
alongside a plain "شهر" text <Input>. The only element that ever opened the
|
||||
picker (`#citybtn`) was itself commented out in legacy's own template, so the
|
||||
picker was unreachable UI even in the original app; the visible form uses the
|
||||
plain text input. That whole complex, and the parallel unused
|
||||
`userFormModel`/`defaultUserFormModel` objects, are not ported.
|
||||
|
||||
STUBBED, not real, and unchanged from the previous version: legacy's
|
||||
POST /major/register — apps/api has no create-customer endpoint. Submitting
|
||||
shows legacy's exact success message without persisting anything. The copy
|
||||
says the account awaits review, which is what legacy said too, so the screen
|
||||
does not claim an account exists when none was created.
|
||||
-->
|
||||
<template>
|
||||
<div class="auth">
|
||||
<PageBar title="ثبت نام" back-to="home" />
|
||||
|
||||
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
|
||||
<container class="auth__body">
|
||||
<section class="auth__intro">
|
||||
<div class="auth__mark" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:user-add" size="28" />
|
||||
</div>
|
||||
<h2 class="auth__title">ساخت حساب کاربری</h2>
|
||||
<p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p>
|
||||
</section>
|
||||
|
||||
<section class="auth__card">
|
||||
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
|
||||
<FormItem name="fname" label="نام" :rules="required('نام')">
|
||||
<Input v-model:value="formModel.fname" />
|
||||
</FormItem>
|
||||
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
|
||||
<Input v-model:value="formModel.lname" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="mobile"
|
||||
label="شماره موبایل"
|
||||
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
|
||||
>
|
||||
<Input v-model:value="formModel.mobile" type="number" />
|
||||
</FormItem>
|
||||
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
|
||||
<Input v-model:value="formModel.store_name" />
|
||||
</FormItem>
|
||||
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
|
||||
<Input v-model:value="formModel.phone" type="number" />
|
||||
</FormItem>
|
||||
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')">
|
||||
<Textarea v-model:value="formModel.address" :rows="3" />
|
||||
</FormItem>
|
||||
<FormItem name="city" label="شهر" :rules="required('شهر')">
|
||||
<Input v-model:value="formModel.city" />
|
||||
</FormItem>
|
||||
|
||||
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<p class="auth__switch">
|
||||
اگر حساب کاربری دارید
|
||||
<button type="button" @click="emit('register', false)">وارد شوید</button>
|
||||
</p>
|
||||
</container>
|
||||
</Spinner>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false">
|
||||
<template v-slot:img>
|
||||
<img :src="registerDelayImage" alt="" class="opacity-80" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-5">
|
||||
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
|
||||
import registerDelayImage from '../../assets/img/registerDelay.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 { required, mobile as mobileRule } from '../../utils/validator'
|
||||
|
||||
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
|
||||
const router = useRouter()
|
||||
|
||||
const profileData = reactive({ loading: false })
|
||||
const registeredInfo = ref(false)
|
||||
|
||||
const formModel = reactive({
|
||||
fname: undefined as string | undefined,
|
||||
lname: undefined as string | undefined,
|
||||
mobile: undefined as string | undefined,
|
||||
store_name: undefined as string | undefined,
|
||||
phone: undefined as string | undefined,
|
||||
address: undefined as string | undefined,
|
||||
city: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
profileData.loading = true
|
||||
// Stubbed — no create-customer endpoint exists in apps/api yet.
|
||||
registeredInfo.value = true
|
||||
} finally {
|
||||
profileData.loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.auth {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.auth__body {
|
||||
padding-top: 86px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.auth__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.auth__title {
|
||||
margin: 14px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.auth__sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.auth__card {
|
||||
padding: 18px 16px 20px;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 20px;
|
||||
background: @canvasSoft;
|
||||
}
|
||||
|
||||
.auth__form :deep(.ant-form-item-label > label) {
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.auth__cta {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
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;
|
||||
}
|
||||
|
||||
.auth__cta:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.auth__switch {
|
||||
margin: 20px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.auth__switch button {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0 3px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
|
||||
("اسناد" — financial documents), restyled into the redesigned design
|
||||
language (see components/home/* and HomeView).
|
||||
|
||||
Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new
|
||||
system's equivalent is LedgerEntry, already exposed by
|
||||
GET /customers/:id/ledger-entries, so the list is real data.
|
||||
|
||||
Legacy's colour/icon logic is preserved in meaning: `hasFactor(item)` was
|
||||
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → a green
|
||||
"in" treatment for everything else, red "out" for orders. That maps 1:1 onto
|
||||
our CREDIT/DEBIT direction, since an ORDER_STOCK entry is exactly the DEBIT
|
||||
case. The palette is now the design system's own rather than hardcoded hexes.
|
||||
|
||||
DROPPED, unchanged from the previous version: the "دریافت فایل" per-document
|
||||
download — legacy's getFile() hit the old Laravel host; there is no
|
||||
document-file endpoint in the new API.
|
||||
-->
|
||||
<template>
|
||||
<div class="tx">
|
||||
<PageBar title="اسناد" back-to="userPage::hasSubRoute" />
|
||||
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
|
||||
<container class="tx__body">
|
||||
<template v-if="entries.length">
|
||||
<SectionHead title="گردش حساب" :subtitle="`${entries.length} سند`" />
|
||||
|
||||
<div class="tx__list">
|
||||
<article v-for="item in entries" :key="item.id" class="tx__row" @click="openEntry(item)">
|
||||
<span class="tx__mark" :class="isCredit(item) ? 'is-in' : 'is-out'" aria-hidden="true">
|
||||
<Icon :icon="isCredit(item) ? 'vuesax-linear:arrow-down' : 'vuesax-linear:arrow-up'" size="16" />
|
||||
</span>
|
||||
|
||||
<div class="tx__main">
|
||||
<div class="tx__head">
|
||||
<h3 class="tx__kind">{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}</h3>
|
||||
<span class="tx__amount" :class="isCredit(item) ? 'is-in' : 'is-out'">
|
||||
{{ formatAmount(item) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="item.note" class="tx__note">{{ item.note }}</p>
|
||||
|
||||
<div class="tx__foot">
|
||||
<span class="tx__date">{{ jdate(item.createdAt) }}</span>
|
||||
<span class="tx__state" :class="{ 'is-pending': !item.confirmed }">
|
||||
{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="!loading" class="tx__empty">
|
||||
<Icon icon="vuesax-linear:receipt-text" size="26" />
|
||||
<p>سندی برای حساب شما ثبت نشده است.</p>
|
||||
</section>
|
||||
</container>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
v-model:visible="showPopUp"
|
||||
placement="bottom"
|
||||
v-if="activeEntry"
|
||||
:closable="false"
|
||||
height="420px"
|
||||
class="rtl"
|
||||
:bodyStyle="{ padding: 0 }"
|
||||
>
|
||||
<div class="sheet">
|
||||
<h3 class="sheet__title">جزئیات سند</h3>
|
||||
<dl class="sheet__specs">
|
||||
<div class="sheet__spec">
|
||||
<dt>نوع سند</dt>
|
||||
<dd>{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</dd>
|
||||
</div>
|
||||
<div class="sheet__spec">
|
||||
<dt>مبلغ</dt>
|
||||
<dd :class="isCredit(activeEntry) ? 'is-in' : 'is-out'">{{ formatAmount(activeEntry) }}</dd>
|
||||
</div>
|
||||
<div class="sheet__spec">
|
||||
<dt>وضعیت</dt>
|
||||
<dd>{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</dd>
|
||||
</div>
|
||||
<div class="sheet__spec">
|
||||
<dt>تاریخ</dt>
|
||||
<dd>{{ jdate(activeEntry.createdAt) }}</dd>
|
||||
</div>
|
||||
<div class="sheet__spec">
|
||||
<dt>توضیحات</dt>
|
||||
<dd>{{ activeEntry.note || '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
import { jdate, price } from '../../utils/filters'
|
||||
import { useSessionStore } from '../../stores/session'
|
||||
import { api, type LedgerEntrySummary } from '../../api/client'
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
ORDER_STOCK: 'سفارش',
|
||||
ORDER_PRE_ORDER: 'سفارش ساخت',
|
||||
REPAYMENT_MELTED_GOLD: 'طلای آبشده',
|
||||
REPAYMENT_TRANSFER: 'حواله',
|
||||
REPAYMENT_CHEQUE: 'چک',
|
||||
REPAYMENT_CASH: 'پولی',
|
||||
WALLET_BONUS: 'پاداش',
|
||||
ADJUSTMENT: 'اصلاحیه',
|
||||
}
|
||||
|
||||
const session = useSessionStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const entries = ref<LedgerEntrySummary[]>([])
|
||||
const showPopUp = ref(false)
|
||||
const activeEntry = ref<LedgerEntrySummary | null>(null)
|
||||
|
||||
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
|
||||
// entries are exactly that case.
|
||||
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT'
|
||||
|
||||
const formatAmount = (item: LedgerEntrySummary) =>
|
||||
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
|
||||
|
||||
const openEntry = (item: LedgerEntrySummary) => {
|
||||
activeEntry.value = item
|
||||
showPopUp.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!session.customerId) return
|
||||
loading.value = true
|
||||
try {
|
||||
entries.value = await api.getLedgerEntries(session.customerId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tx {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.tx__body {
|
||||
padding-top: 76px;
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.tx__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tx__row {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 18px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.tx__row:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.tx__mark {
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tx__mark.is-in {
|
||||
color: @green;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.tx__mark.is-out {
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
|
||||
.tx__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tx__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tx__kind {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tx__amount {
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tx__amount.is-in {
|
||||
color: @green;
|
||||
}
|
||||
|
||||
.tx__amount.is-out {
|
||||
color: @wine;
|
||||
}
|
||||
|
||||
.tx__note {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
color: @inkMuted;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tx__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tx__date {
|
||||
font-size: 11px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.tx__state {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.tx__state.is-pending {
|
||||
color: @notify2;
|
||||
}
|
||||
|
||||
.tx__empty {
|
||||
margin-top: 8px;
|
||||
padding: 34px 20px;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.tx__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
padding: 18px 16px 22px;
|
||||
}
|
||||
|
||||
.sheet__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.sheet__specs {
|
||||
margin: 0;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sheet__spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid @hairlineSoft;
|
||||
}
|
||||
|
||||
.sheet__spec:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.sheet__spec dt {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.sheet__spec dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sheet__spec dd.is-in {
|
||||
color: @green;
|
||||
}
|
||||
|
||||
.sheet__spec dd.is-out {
|
||||
color: @wine;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user