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:
s.esmaeili
2026-09-18 12:54:45 +03:30
parent 05fe780c89
commit 53a304b5d5
355 changed files with 22865 additions and 14 deletions
@@ -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>