user-app: extend the redesign across the account screens

Phase 2, completing the app-wide rollout of the home/product-detail design
language (LESS colour tokens, BEM class names, hairline-bordered cards,
SectionHead, dashed empty states) onto the account side: dashboard, orders,
transactions, profile, login and register.

Reuse over re-implementation: the dashboard's live rate block is now
components/home/RateStrip.vue rather than a second hand-built strip, and
every screen uses components/ui/PageBar.vue for its chrome.

OrderCard and OrderItemCard are restyled to match. OrderItemCard drops its
always-empty note row: our OrderItem schema has no note field (legacy's
per-item note was a real column there; ours is not), so it could only ever
print a dash.

OrdersView swaps ant Tabs for the same scrolling chip rail the rest of the
redesign uses, and its full-screen Modal with hand-written CSS overrides for
a bottom Drawer, matching how every other detail surface opens.

Deleted AppHeader.vue, orphaned once every screen moved to PageBar. Its
shutter animation was driven by transitionFlag, which nothing reads any
more — the router afterEach comment claiming otherwise is corrected rather
than left asserting something untrue, and the now-inert half is flagged in
place. ScrollFetchSearch loses its isFav prop: it gated two empty-state
branches that rendered the identical image, and its only caller
(FavoritesView) now renders its own.

Behaviour is preserved throughout. Orders still reads GET
/customers/:id/orders and filters client-side; transactions still reads
GET /customers/:id/ledger-entries with legacy's CREDIT/DEBIT colour meaning
intact; profile stays read-only over real fields; login keeps the real
customer lookup plus the documented OTP stub; register stays stubbed with
copy that does not claim an account was created.

Verified in a real browser against the running API and native database:
dashboard 4 tiles with a real balance, orders 1 real order card, profile 4
real spec rows, transactions the correct empty state for a customer with no
ledger rows, and login/register confirmed with the session cleared (the
guard bounces a signed-in user away from /login, which is why they needed a
separate pass). No console errors, no horizontal overflow. vue-tsc and vite
build both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-15 01:34:30 +03:30
co-authored by Claude Opus 5
parent cc62975dd0
commit 0e5f9ccd7b
11 changed files with 1853 additions and 714 deletions
@@ -1,58 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/appHeader.vue.
NOTE (existing issue, ported as-is per instructions — not fixed here):
the header's id is "" (empty) but onEnter() looks up getElementById('appHeader').
That lookup always returns null, so onEnter() never actually sets the header
height from this code path in practice. Preserved exactly; flagged separately.
-->
<template>
<transition name="shutter">
<header
id=""
class="overflow-hidden bg-[#1C2D56] rounded-bl-[32px] p-[20px] pl-[30px] w-full mb-[0px] z-10"
v-if="transition.transitionFlag"
>
<slot />
</header>
</transition>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useTransitionStore } from '../stores/transitionStore'
const transition = useTransitionStore()
function onEnter() {
const element = document.getElementById('appHeader')
if (element && element.offsetHeight) {
element.style.position = 'fixed'
element.style.top = '0'
transition.setHeaderHeight(element.offsetHeight + 16)
}
}
function onBeforeLeave() {
if (transition.headerHeight > 0) transition.setHeaderHeight(0)
}
onBeforeUnmount(() => onBeforeLeave())
onMounted(() => {
setTimeout(() => onEnter(), 1000)
})
</script>
<style lang="less">
.shutter-enter-active {
animation: shutter 1s cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes shutter {
0% {
max-height: 0;
opacity: 0;
color: transparent !important;
}
100% {
max-height: 120px;
opacity: 1;
}
}
</style>
@@ -1,49 +1,165 @@
<!-- Ported from legacy henza_pwa/src/components/product/order.vue -->
<!--
Order summary row, restyled into the redesigned design language (see
components/home/* and HomeView). Ported originally from legacy
henza_pwa/src/components/product/order.vue.
Status colours still come from utils/orderStatus.ts, which is the single
source of truth shared with the detail view — not re-declared here.
-->
<template>
<div
@click="emit('view', value)"
class="w-full cursor-pointer relative rounded-[10px] mb-4 p-[10px] text-primary border border-solid border-[#E5E7EB] bg-[#fff]"
>
<div class="grid grid-cols-2 p-2">
<div>
<div class="text-[18px] font-sans font-semibold mb-2">
<span class="text-[#656f87]">سفارش :</span>
{{ value.orderNumber }}
</div>
<div>
<span class="text-[16px] font-bold pl-2">{{ value.items.length }}</span>
<span class="text-[14px] font-medium">آیتم</span>
</div>
<article class="oc" @click="emit('view', value)">
<div class="oc__head">
<div class="oc__id">
<span class="oc__idLabel">سفارش</span>
<span class="oc__idValue">{{ value.orderNumber }}</span>
</div>
<div class="grid justify-end">
<div class="rounded-[5px] px-[20px] py-[4px] w-fit text-[14px] mb-2" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</div>
<div>
<span class="text-[16px] font-bold pl-2">{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }} </span>
<span class="text-[14px] font-medium">گرم</span>
</div>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<div class="oc__figures">
<div class="oc__figure">
<span class="oc__figureLabel">تعداد</span>
<span class="oc__figureValue">
{{ value.items.length }}
<i>آیتم</i>
</span>
</div>
<div class="oc__figure">
<span class="oc__figureLabel">وزن</span>
<span class="oc__figureValue">
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i>
</span>
</div>
</div>
<a-divider style="margin: 8px 0" />
<div class="flex justify-between p-2">
<div class="text-[16px] text-[#656f87]">{{ jdate(value.createdAt) }}</div>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-left" size="24" />
<div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Divider as ADivider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = withDefaults(defineProps<{ value: OrderDetail }>(), {})
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
</script>
<style scoped lang="less">
.oc {
cursor: pointer;
padding: 13px 14px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.18s ease;
}
.oc:active {
transform: scale(0.99);
}
.oc__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.oc__id {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.oc__idLabel {
font-size: 11px;
color: @inkFaint;
}
.oc__idValue {
font-size: 13.5px;
font-weight: 800;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oc__status {
flex: 0 0 auto;
padding: 4px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.oc__figures {
display: flex;
gap: 26px;
padding: 12px 0 11px;
}
.oc__figure {
display: flex;
flex-direction: column;
gap: 3px;
}
.oc__figureLabel {
font-size: 10.5px;
color: @inkFaint;
}
.oc__figureValue {
font-size: 14px;
font-weight: 800;
color: @ink;
}
.oc__figureValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 3px;
}
.oc__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
border-top: 1px solid @hairlineSoft;
}
.oc__date {
font-size: 11px;
color: @inkFaint;
}
.oc__more {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
</style>
@@ -1,42 +1,57 @@
<!--
Ported from legacy henza_pwa/src/components/product/orderItem.vue.
One line of an order's contents, restyled into the redesigned design
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries
productVariantId. `note` always shows '-' — our OrderItem schema has no
note field (legacy's per-item note was a real column there; ours isn't).
productVariantId.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
ours isn't), so it could only ever print a dash. An always-empty row is
noise, not information.
-->
<template>
<div class="bg-white/10 rounded-[10px] p-[12px] flex items-center">
<img v-lazy="lookup?.imageUrl" class="ml-[8px] w-[120px]" />
<div class="flex-1" style="width: calc(100% - 82px)">
<div class="flex items-center">
<div class="font-semibold text-[16px] ml-2">{{ lookup?.productTitle ?? '—' }}</div>
<div class="text-[12px] text-secondary">({{ lookup?.variantTitle }})</div>
</div>
<div class="grid grid-cols-2">
<div class="mt-2">
<div>تعداد کالاها :</div>
<div class="pt-1">وزن :</div>
<div class="pt-1">اجرت :</div>
<div class="pt-1">توضیحات :</div>
</div>
<div class="text-left mt-2 text-black font-semibold">
<div>{{ value.quantity }} <span>عدد</span></div>
<div class="pt-1">{{ value.computedWeightGrams }} <span>گرم</span></div>
<div class="pt-1">{{ value.wagePercentSnapshot }} <span>درصد</span></div>
<div class="pt-1">-</div>
</div>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
</div>
</div>
<div class="oi__main">
<div class="oi__head">
<h4 class="oi__title">{{ lookup?.productTitle ?? '—' }}</h4>
<span v-if="lookup?.variantTitle" class="oi__variant">{{ lookup.variantTitle }}</span>
</div>
<div class="oi__figures">
<span class="oi__figure">
{{ value.quantity }}
<i>عدد</i>
</span>
<span class="oi__figure">
{{ value.computedWeightGrams }}
<i>گرم</i>
</span>
<span class="oi__figure">
{{ value.wagePercentSnapshot }}
<i>٪ اجرت</i>
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
const props = withDefaults(defineProps<{ value: OrderItemDetail }>(), {})
const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>()
@@ -44,3 +59,86 @@ onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId)
})
</script>
<style scoped lang="less">
.oi {
display: flex;
gap: 11px;
padding: 11px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 16px;
}
.oi__art {
flex: 0 0 62px;
width: 62px;
height: 62px;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
}
.oi__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
}
.oi__head {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oi__variant {
flex: 0 0 auto;
font-size: 10.5px;
color: @inkFaint;
}
.oi__figures {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding-top: 9px;
}
.oi__figure {
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
@@ -1,10 +1,12 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchSearch.vue.
EXISTING ISSUE, ported as-is: both the isFav and non-isFav empty states use
the same `favoritEmpty` image (legacy imported cartEmpty/emp_search/
emp_favorite too, but never actually used them in the template — those
unused imports are dropped here as genuinely dead code; the duplicate-image
*behavior* itself is preserved).
Legacy rendered two separate empty states, gated on an `isFav` prop, that
both used the SAME favoritEmpty image — a distinction without a difference,
preserved verbatim while this was a faithful port. The only caller that ever
passed `is-fav` was FavoritesView, which now renders its own empty state in
the redesigned language, so the prop had no remaining callers and the two
identical branches collapse to one.
-->
<template>
<main
@@ -14,8 +16,7 @@
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !isFav" :image="favoritEmpty" />
<EmptyState v-if="flag && isFav" :image="favoritEmpty" />
<EmptyState v-if="flag" :image="favoritEmpty" />
</main>
</template>
@@ -32,7 +33,6 @@ withDefaults(
loading: boolean
flag: boolean
padding?: string
isFav?: boolean
}>(),
{},
)
+12 -6
View File
@@ -64,12 +64,18 @@ router.beforeEach((to) => {
else nav.setPgaTransition(undefined);
});
// Ported from legacy router/index.ts:273-286. This hook is what actually
// drives two animations, and its absence was why neither ran:
// 1. cycling transitionFlag false->true replays AppHeader's "shutter" enter
// animation on every navigation;
// 2. setActiveLink moves the bottom-nav GSAP notch + bubble to the current
// tab (it is the ONLY caller of setActiveLink in the whole app).
// Ported from legacy router/index.ts:273-286.
//
// setActiveLink moves the bottom-nav GSAP notch + bubble to the current tab,
// and this hook is its ONLY caller in the whole app — that part is live.
//
// The transitionFlag cycling below used to replay AppHeader's "shutter" enter
// animation. AppHeader was deleted when every screen moved to the redesigned
// components/ui/PageBar.vue, and nothing reads transitionFlag any more, so
// that half is currently inert. It is left in place rather than ripped out
// because transitionStore is still the shared home for this kind of
// page-transition state; flagged here so the next reader doesn't assume it
// still drives a visible animation.
router.afterEach((to) => {
const nav = useNavStore();
const transition = useTransitionStore();
+379 -98
View File
@@ -1,110 +1,122 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue —
the most heavily adapted view in this port so far. 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.
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue,
restyled into the redesigned design language (see components/home/* and
HomeView). The live rate block now reuses components/home/RateStrip.vue
rather than re-implementing the same strip a second time.
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. In the redesigned schema (see the original migration
audit), sales agents are AdminUser records with no customer-facing session
at all — there is no "type" field on our Customer model, so there is
nothing to adapt here; the underlying data model this UI assumed no longer
exists.
(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. Shown as
a disabled tile rather than a broken link.
— PreOrder has a DB table but no controller anywhere in apps/api.
REPLACED, not dropped: legacy's "گزارش حساب" van-action-sheet called out to
the external Tahesab accounting service (hesabMe()) that the very first
migration audit explicitly decided to retire in favor of this system's own
internal ledger — this dashboard's balance card already shows that real,
internal balance directly, so there's no separate popup needed.
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 price ticker (legacy's GET
/price, ~14 rows). The new API has one single-karat quote endpoint
(GET /pricing/latest) — shown as one line instead of fabricating rows that
don't exist.
DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
rows). The new API has one single-karat quote endpoint, shown as one strip
rather than fabricating rows that don't exist.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<div></div>
<h3 class="text-white bold mb-0 text-center w-full">میز کار</h3>
<Icon v-if="session.customerId" icon="vuesax-linear:logout" @click="data.notify = true" class="cursor-pointer text-white absolute left-5" size="25" />
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
<button
v-if="session.customerId"
type="button"
class="dash__barBtn"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="19" />
</button>
<span v-else class="dash__barSpacer" aria-hidden="true" />
</div>
</AppHeader>
</div>
<div class="h-16"></div>
</header>
<container class="overflow-y-scroll pt-4">
<div v-if="session.customerId">
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="grid grid-cols-5 items-center pb-2 mb-2 border-b-2 border-solid border-[#E5E7EB]">
<h5 class="font-bold col-span-2 mb-0">{{ customer?.fullName }}</h5>
</div>
<div class="flex justify-between">
<div>شماره تلفن همراه</div>
<div>{{ session.mobile }}</div>
</div>
</div>
</div>
<div v-else>
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="text-center font-bold">برای استفاده از امکانات نرم افزار ابتدا وارد شوید</div>
<div class="flex w-full justify-center mt-2" @click="router.push({ name: 'login' })">
<Button type="primary" class="w-[17rem]">ورود/ثبت نام</Button>
</div>
</div>
</div>
<div class="rounded-[10px] bg-light p-[16px] w-full mb-[16px] bg-[#F9F9F9]" v-if="customer">
<div class="flex justify-between">
<div>
<div class="text-[12px] mb-2">{{ remainText }}</div>
<div class="font-bold">{{ Math.abs(parseFloat(customer.goldDebtGrams)) }} گرم</div>
</div>
<div>
<div class="text-[12px] mb-2">بدهی ریالی</div>
<div class="font-bold">{{ priceUnit(customer.fiatDebtRial) }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-6 my-4">
<div v-for="item in list" :key="item.id" class="grid col-span-1 cursor-pointer" @click="goToDetails(item.id)">
<div class="border border-solid border-[#E5E7EB] bg-[#F9F9F9] rounded-[5px] flex justify-center items-center py-4" :class="{ 'opacity-40': item.disabled }">
<Icon :icon="item.icon" />
</div>
<div class="text-center text-[14px] font-medium pt-2">{{ item.title }}</div>
</div>
</div>
<div class="rounded-[10px] text-secondary col-span-6 mb-24" v-if="goldPrice">
<div class="flex items-center justify-between bg-[#1C2D56] text-[#ffffff] p-3 rounded">
<div class="font-bold text-[16px]">نرخ طلای ۱۸ عیار</div>
<div>{{ jdate(goldPrice.quotedAt) }}</div>
</div>
<div class="grid grid-cols-1 mt-2">
<div class="bg-[#F9F9F9] rounded flex justify-between items-center p-2">
<div class="flex items-center">
<div class="bg-white rounded w-9 h-9 flex justify-center items-center">
<Icon class="text-primary" icon="vuesax-linear:number-18" />
<container class="dash__body">
<!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer">
<section class="dash__balance">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
<div class="dash__figure">
{{ grams }}
<span class="dash__unit">گرم</span>
</div>
</div>
<div class="text-[#1C2D56] text-[15px] font-semibold mr-3">هر گرم طلای ۱۸ عیار</div>
<span class="dash__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="text-[#1C2D56] text-[18px] font-semibold flex items-center">
{{ price(goldPrice.pricePerGramRial) }}
<div class="text-xs text-[#8D6A46] pr-0.5 font-medium">ریال</div>
<div class="dash__balanceRow">
<span class="dash__rowLabel">بدهی ریالی</span>
<span class="dash__rowValue">{{ price(customer.fiatDebtRial) }} <i>ریال</i></span>
</div>
<div class="dash__identity">
<span class="dash__name">{{ customer.fullName }}</span>
<span class="dash__mobile">{{ session.mobile }}</span>
</div>
</section>
</template>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
</div>
</div>
<div class="dash__inviteCopy">
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
</div>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Nav grid -->
<section class="dash__section">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<button
v-for="item in list"
:key="item.id"
type="button"
class="dash__tile"
:class="{ 'is-disabled': item.disabled }"
@click="goToDetails(item.id)"
>
<span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" />
</span>
<span class="dash__tileTitle">{{ item.title }}</span>
</button>
</div>
</section>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
</container>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
@@ -116,32 +128,35 @@
<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>
<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>
</container>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import SectionHead from '../../components/home/SectionHead.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, priceUnit } from '../../utils/filters'
import { jdate } from '../../utils/filters'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
@@ -151,7 +166,14 @@ const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const remainText = computed(() => (customer.value && parseFloat(customer.value.goldDebtGrams) > 0 ? 'بدهکار گرمی' : 'بستانکار گرمی'))
// 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 })
@@ -190,3 +212,262 @@ onMounted(async () => {
}
})
</script>
<style scoped lang="less">
.dash {
background: @canvas;
min-height: 100vh;
}
.dash__bar {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__barTitle {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__barBtn {
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__barBtn:active {
transform: scale(0.94);
}
.dash__barSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 16px;
padding-bottom: 90px;
}
.dash__balance {
padding: 16px;
border-radius: 20px;
background: @ink;
color: #fff;
box-shadow: 0 14px 30px -22px rgba(20, 22, 26, 0.95);
}
.dash__balanceHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__eyebrow {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__figure {
padding-top: 5px;
font-size: 25px;
font-weight: 800;
letter-spacing: -0.3px;
}
.dash__unit {
font-size: 11.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 4px;
}
.dash__seal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__seal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__seal.is-creditor {
color: @ink;
background: rgba(255, 255, 255, 0.9);
}
.dash__balanceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__rowLabel {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__rowValue {
font-size: 13.5px;
font-weight: 700;
}
.dash__rowValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
.dash__identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 13px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__name {
font-size: 12.5px;
font-weight: 700;
}
.dash__mobile {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
direction: ltr;
}
.dash__invite {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.dash__inviteIcon {
flex: 0 0 42px;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__inviteCopy {
flex: 1;
min-width: 0;
}
.dash__inviteCopy h3 {
margin: 0;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.dash__inviteCopy p {
margin: 4px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.dash__inviteCta {
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.dash__section {
margin-top: 26px;
}
.dash__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
}
.dash__tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 6px;
border: 1px solid @hairline;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
transition: transform 0.18s ease;
}
.dash__tile:active {
transform: scale(0.97);
}
.dash__tile.is-disabled {
opacity: 0.4;
}
.dash__tileIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @inkSoft;
background: @hairlineSoft;
}
.dash__tileTitle {
font-size: 11px;
font-weight: 700;
color: @inkSoft;
}
</style>
+207 -93
View File
@@ -1,94 +1,93 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue.
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Per your decision: OTP-based flow ported structurally, but the SMS-send step
is stubbed — no SMS provider exists. What's real vs. stubbed:
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. If it matches a real seeded customer, that customer's
exact legacy-notify message path is preserved: a non-match shows
legacy's own "اطلاعات کاربری در سیستم وجود ندارد" text (legacy's actual
404 handling), not a new message.
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 (session.login), so downstream
features (balance, ledger, favorites-as-logged-in) work against real
seeded data. This is the "session wiring" — apps/api itself is untouched.
- `token.fireBaseToken` (Firebase push token) doesn't exist in this port
(Firebase was already deferred) — omitted from the stub verify call.
- `useOtpToken()` (WebOTP browser autofill) was already dead in legacy
itself (its call was commented out in App.vue, and even active, nothing
ever read its output into the OTP input) — not ported.
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>
<div class="auth">
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ورود</h3>
<div></div>
</div>
</AppHeader>
<div class="flex justify-center mb-8 mt-5">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div>
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div>
</div>
<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>
</div>
<div class="w-full text-center font-bold text-black text-lg">ورود به اپلیکیشن هنزا گلد</div>
<div class="px-8 custom-form">
<OtpInput
class="w-full mb-[10px] text-black mt-5"
v-show="body.step == 2 && timer > 0"
input-classes="otp-input"
inputType="tel"
:num-inputs="4"
:should-auto-focus="true"
:defaultFocus="body.defaultFocus"
@on-complete="sendToken"
/>
<div v-show="body.step == 2 && timer === 0" class="text-center mb-[10px] mt-5">برای دریافت رمز عبور مجدد تلاش کنید</div>
<div v-show="body.step == 2 && timer !== 0" class="text-center text-xl mb-[10px] mt-5">
امکان دریافت مجدد رمز در &nbsp;{{ ' ' + counterZeroShow(timer) + ' ' }} ثانیه دیگر
</div>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="w-full">
<FormItem name="username" label="تلفن همراه" v-show="body.step == 1" :rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]">
<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 class="btn-px-double" type="primary" html-type="submit" size="large" block :loading="body.loading" :disabled="timer > 0 && body.step === 2">
<button
type="submit"
class="auth__cta"
:class="{ 'is-loading': body.loading }"
:disabled="body.loading || (timer > 0 && body.step === 2)"
>
{{ btnText }}
</Button>
</button>
</Form>
<div class="flex justify-center mt-5">
<div>اگر ثبت نام نکرده اید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', true)">ثبت نام کنید.</div>
</div>
</div>
</div>
</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 { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import editImg from '../../assets/img/henza.svg'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.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'
@@ -166,32 +165,147 @@ const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style lang="less">
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
<style scoped lang="less">
.auth {
background: @canvas;
min-height: 100vh;
}
.custom-form {
.otp-input {
width: 50px;
height: 50px;
padding: 5px;
margin: 0;
font-size: 20px;
background: transparent;
border: 0 solid @black;
border-bottom-width: 2px;
text-align: center;
.auth__body {
padding-top: 26px;
padding-bottom: 90px;
}
&.error {
border: 1px solid @danger !important;
}
}
.auth__intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.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>
+387 -192
View File
@@ -1,126 +1,166 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue.
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Adapted:
- Status filter tabs use our real OrderStatus enum values directly (see
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 tab (that
/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: "دریافت فاکتور" (get invoice) button — legacy's handler opened a
hardcoded link to the real legacy production domain
(https://henzagold.com/oapi/...). Not just unimplemented — an actual
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="fixed top-0 z-10 w-full bg-white">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'home' })" />
<h3 class="text-white bold mb-0">سفارشات</h3>
<div></div>
</div>
</AppHeader>
<div class="pt-3 px-2 pb-3 mb-3 border-b-solid border-[#E5E7EB] w-full">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</div>
<div class="h-36"></div>
<div class="ord">
<PageBar title="سفارشات" back-to="home">
<template #below>
<div class="ord__barExtra">
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
<container class="bg-[#F9FAFB] px-2 orders">
<Tabs v-model:activeKey="activeTab" style="padding-right: 0.2rem !important; font-weight: bold">
<TabPane v-for="item in tabs" :key="item.key" :tab="item.title" force-render>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-y-scroll no-scrollbar">
<OrderCard @view="openModal" v-for="item in filteredOrders" :key="item.id" :value="item" />
<EmptyState v-if="filteredOrders.length === 0" :image="ordersEmpty" />
</Spinner>
</TabPane>
</Tabs>
<Modal v-model:visible="viewData.model" :mask="false" :footer="false" :closable="false" class="henza-custom-modal" v-if="viewData.item">
<div class="flex justify-between items-center p-4">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="viewData.model = false" />
<h3 class="font-semibold mb-0">جزئیات سفارش</h3>
<div></div>
</div>
<container>
<div class="overflow-y-auto h-screen">
<div class="flex justify-between items-center pt-4">
<span class="font-semibold text-[14px]">اطلاعات سفارش</span>
</div>
<div class="responsive-steps">
<div class="steps-container">
<div v-for="(step, index) in ORDER_STEPS" :key="index" class="step" :class="{ active: currentStep === index }">
<div class="step-circle">{{ index + 1 }}</div>
<div class="step-title">{{ step }}</div>
<div v-if="index !== 0" class="step-line"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 border-[1.5px] border-[#E5E7EB] border-solid mt-4 rounded-[6px]">
<div class="grid col-span-1 p-4 text-[#9CA3AF] text-[12px] font-medium">
<div>شماره سفارش :</div>
<div class="pt-4">وضعیت سفارش :</div>
<div class="pt-4">تعداد کالاها :</div>
<div class="pt-4">وزن سفارش :</div>
<div class="pt-4">تاریخ سفارش :</div>
</div>
<div class="grid col-span-1 text-left p-4">
<div class="font-bold text-[18px] text-[#23335B]">{{ viewData.item.orderNumber }}</div>
<div class="pt-4 text-center flex justify-end">
<div class="rounded-[5px] py-[4px] px-[12px] text-[14px] font-semibold w-fit" :style="{ background: modalColor?.background, color: modalColor?.font }">
{{ modalStatusLabel }}
</div>
</div>
<div class="pt-4">
<span class="text-[16px] font-bold pl-2 text-black">{{ viewData.item.items.length }}</span>
<span class="text-[14px] font-medium">آیتم</span>
</div>
<div class="pt-4 font-medium">
<span class="text-[16px] font-bold pl-1 text-black">{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }}</span>
<span class="pr-2">گرم</span>
</div>
<div class="pt-4 font-semibold text-[#1E2229]">{{ jdate(viewData.item.createdAt) }}</div>
</div>
</div>
<div>
<div class="font-semibold mt-4">آیتم های سفارش</div>
<div class="w-full max-w-screen-md m-auto h-full flex flex-col">
<div class="flex-1 py-[10px] pb-[70px]">
<OrderItemCard v-for="item in viewData.item.items" :key="item.id" :value="item" class="border-[#E5E7EB] border-[1.5px] border-solid text-primary bg-[#F9F9F9] rounded-[6px] mb-4" />
</div>
</div>
<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="درحال دریافت اطلاعات">
<container class="ord__body">
<template v-if="filteredOrders.length">
<SectionHead title="سفارش‌های شما" :subtitle="`${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>
</Modal>
</container>
</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 { useRouter } from 'vue-router'
import { Modal, TabPane, Tabs } from 'ant-design-vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import SectionHead from '../../components/home/SectionHead.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 {
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 router = useRouter()
const session = useSessionStore()
const tabs = [
@@ -162,11 +202,13 @@ async function loadOrders() {
}
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 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 openModal(item: OrderDetail) {
function openDetail(item: OrderDetail) {
viewData.item = item
viewData.model = true
}
@@ -174,109 +216,262 @@ function openModal(item: OrderDetail) {
onMounted(loadOrders)
</script>
<style lang="less">
.henza-custom-modal,
.henza-custom-modal .ant-modal-body,
.henza-custom-modal .ant-modal-content {
border-radius: 0;
width: 100vw !important;
max-width: 100vw;
margin: 0;
height: 100vh;
top: 0;
padding: 0;
direction: rtl;
<style scoped lang="less">
.ord {
background: @canvas;
min-height: 100vh;
}
.orders {
min-height: calc(100vh - 150px);
height: fit-content;
}
.orders .ant-tabs > .ant-tabs-nav,
.ant-tabs > div > .ant-tabs-nav {
background-color: white;
position: fixed;
z-index: 10;
overflow-x: auto;
width: 100%;
.ord__barExtra {
max-width: 768px;
margin: auto;
left: 0;
right: 0;
padding-right: 10px;
padding-left: 10px;
border-bottom-style: solid;
border-bottom-color: #e5e7eb;
border-bottom-width: 1px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
margin: 0 auto;
padding: 0 16px 11px;
}
.responsive-steps {
width: 100%;
padding: 10px;
.steps-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
text-align: center;
position: relative;
.step-circle {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #9ca3af;
z-index: 1;
}
.step-title {
margin-top: 6px;
font-size: 12px;
white-space: normal;
word-break: break-word;
}
.step-line {
position: absolute;
top: 15px;
left: 50%;
width: 100%;
height: 2px;
background-color: #e5e7eb;
z-index: 0;
transform: translateX(0);
}
&.active .step-circle {
background-color: @primary-color;
color: white;
}
}
.ord__chips {
display: flex;
gap: 8px;
overflow-x: auto;
margin: 11px -16px 0;
padding: 0 16px;
scroll-snap-type: x proximity;
}
@media (max-width: 600px) {
.step-title {
font-size: 11px;
}
.step-circle {
width: 24px;
height: 24px;
font-size: 10px;
}
.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: 16px;
padding-bottom: 90px;
}
.ord__list {
display: flex;
flex-direction: column;
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>
+183 -60
View File
@@ -1,70 +1,74 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
heavily simplified — 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, faithfully styled: the avatar circle and the real read-only
info card (name/mobile/national code/verification status/city), all backed
by real fields the API already returns.
restyled into the redesigned design language (see components/home/* and
HomeView).
DROPPED: "تکمیل پروفایل" (complete profile) button, "افزودن کاربر" (add
sub-user) FAB, the "کارشناس فروش" (sales agent) contact card — that last
one needs the referring AdminUser's name/mobile, which the current
GET /customers/:id response doesn't expose (only referredByAgentId, a bare
id) — not resolved here to keep apps/api untouched this batch.
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="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="t bold mb-0">اطلاعات کاربری</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-20"></div>
<div class="prof">
<PageBar title="اطلاعات کاربری" back-to="userPage::hasSubRoute" />
<container>
<div class="flex justify-center mb-8">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
<container class="prof__body">
<!-- Identity -->
<section class="prof__identity">
<div class="prof__avatar" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="30" />
</div>
</div>
</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>
<div v-if="customer" class="border-solid border-[1px] border-[#F3F4F6] rounded-[6px] p-8 mt-2 bg-[#FEFEFE]">
<div class="flex justify-between">
<div class="text-[#9CA3AF] font-medium">نام و نام خانوادگی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.fullName }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">تلفن همراه :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.mobile }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">کد ملی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.nationalCode }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">وضعیت احراز هویت :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ VERIFICATION_LABELS[customer.verificationStep] }}</div>
</div>
</div>
</container>
<!-- 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 { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import editImg from '../../assets/img/henza.svg'
import { computed, onMounted, ref } from 'vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.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'
@@ -75,19 +79,138 @@ const VERIFICATION_LABELS: Record<string, string> = {
VERIFIED: 'تایید شده',
}
const router = useRouter()
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) {
if (!session.customerId) return
loading.value = true
try {
customer.value = await api.getCustomer(session.customerId)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
<style scoped lang="less">
.prof {
background: @canvas;
min-height: 100vh;
}
.prof__body {
padding-top: 20px;
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>
+145 -55
View File
@@ -1,78 +1,84 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue.
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 (not "possibly dead" — verified unreachable):
legacy rendered a whole cascading country->province->city picker
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 (the template binds
`formModel.*`, a *different*, separately-declared empty reactive object —
`userFormModel` is never referenced anywhere), are not ported.
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: POST /major/register — apps/api has no create-customer
endpoint. Submitting shows legacy's exact success dialog without actually
persisting anything.
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>
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ثبت نام</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="relative w-full py-16 px-5">
<div class="mt-14">
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<Form :model="formModel" layout="vertical" @finish="register" class="w-full">
<FormItem style="font-size: 16px" name="fname" label="نام" :rules="required('نام')">
<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 style="font-size: 16px" name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
</FormItem>
<FormItem style="font-size: 16px" name="mobile" label="شماره موبایل" :rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]">
<FormItem
name="mobile"
label="شماره موبایل"
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
>
<Input v-model:value="formModel.mobile" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" />
</FormItem>
<FormItem style="font-size: 16px" name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input v-model:value="formModel.phone" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" />
<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="primary" class="w-full max-w-[768px] m-auto" style="height: 42px" html-type="submit">
<span style="font-size: 18px">ثبت اطلاعات</span>
</Button>
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
</Form>
</Spinner>
<div class="flex justify-center mt-10">
<div>اگر حساب کاربری دارید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', false)">وارد شوید.</div>
</div>
</div>
</div>
</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>
<div class="p-5">
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
</div>
</template>
<template v-slot:button>
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
@@ -84,12 +90,10 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input, Textarea } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.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'
@@ -121,8 +125,94 @@ const register = async () => {
}
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
<style scoped lang="less">
.auth {
background: @canvas;
min-height: 100vh;
}
.auth__body {
padding-top: 26px;
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>
+263 -89
View File
@@ -1,74 +1,63 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents). Replaces the English AccountView scaffold
that had no legacy counterpart.
("اسناد" — 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 exactly: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → green
#F0FDF4/#22C55E + add-square for everything else, red #FEF2F2/#F44336 +
minus-square for orders. That maps 1:1 onto our CREDIT/DEBIT direction,
since an ORDER_STOCK entry is exactly the DEBIT case.
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: the "دریافت فایل" per-document download — legacy's getFile() hit the
old Laravel host; there is no document-file endpoint in the new API.
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="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">اسناد</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-24 bg-[#F9FAFB]"></div>
<div class="tx">
<PageBar title="اسناد" back-to="userPage::hasSubRoute" />
<container class="overflow-y-auto bg-[#F9FAFB]">
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="no-scrollbar overflow-auto">
<div
v-if="entries.length"
v-for="item in entries"
:key="item.id"
@click="openEntry(item)"
class="rounded-[10px] border-[#E8EAEE] py-[16px] px-[14px] w-full mb-[16px] grid grid-cols-8 items-center cursor-pointer bg-white"
>
<div class="col-span-1 items-end h-full">
<Icon
:icon="getIcon(item)"
:style="{ background: getColor(item), color: getColor2(item) }"
class="rounded-full p-[10px] ml-2 h-[40px] w-[40px]"
size="20"
/>
</div>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<container class="tx__body">
<template v-if="entries.length">
<SectionHead title="گردش حساب" :subtitle="`${entries.length} سند`" />
<div class="col-span-7 pr-1 text-primary mt-2">
<div class="flex justify-between items-center">
<div class="font-bold mb-2">{{ item.note || '- -' }}</div>
<div
class="rounded-[5px] px-[20px] py-[4px] w-fit text-[16px] mb-2 font-semibold"
:style="{ background: getColor(item), color: getColor2(item) }"
>
{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-[14px] font-semibold mb-2">{{ formatAmount(item) }}</div>
<div class="h-10"></div>
</div>
<div class="mb-2 text-[14px] text-grey font-semibold">{{ jdate(item.createdAt) }}</div>
<div class="flex justify-between pt-1">
<div class="text-[12px] font-bold">{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="20" />
</div>
</div>
</div>
<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>
<EmptyState v-else :image="transactionEmpty" />
<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
@@ -76,48 +65,46 @@
placement="bottom"
v-if="activeEntry"
:closable="false"
height="450px"
:class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']"
:bodyStyle="{ display: 'flex', flexDirection: 'column' }"
height="420px"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="w-full h-full flex flex-col">
<div class="pb-[10px] border-b border-solid border-[#E5E7EB] flex text-[16px] font-bold">جزئیات سند</div>
<div class="flex-1 py-[10px] overflow-auto no-scrollbar">
<div class="flex justify-between mb-4">
<div class="text-grey">نوع سند :</div>
<div class="font-semibold">{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</div>
<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="flex justify-between mb-4">
<div class="text-grey">مبلغ :</div>
<div class="font-semibold">{{ formatAmount(activeEntry) }}</div>
<div class="sheet__spec">
<dt>مبلغ</dt>
<dd :class="isCredit(activeEntry) ? 'is-in' : 'is-out'">{{ formatAmount(activeEntry) }}</dd>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">وضعیت :</div>
<div class="font-semibold">{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
<div class="sheet__spec">
<dt>وضعیت</dt>
<dd>{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</dd>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">تاریخ :</div>
<div class="font-semibold">{{ jdate(activeEntry.createdAt) }}</div>
<div class="sheet__spec">
<dt>تاریخ</dt>
<dd>{{ jdate(activeEntry.createdAt) }}</dd>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">توضیحات :</div>
<div class="font-semibold">{{ activeEntry.note || '- -' }}</div>
<div class="sheet__spec">
<dt>توضیحات</dt>
<dd>{{ activeEntry.note || '—' }}</dd>
</div>
</div>
</dl>
</div>
</Drawer>
</container>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import transactionEmpty from '../../assets/img/empty/transactionEmpty.svg'
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'
@@ -133,7 +120,6 @@ const SOURCE_LABELS: Record<string, string> = {
ADJUSTMENT: 'اصلاحیه',
}
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
@@ -144,9 +130,6 @@ 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 getIcon = (item: LedgerEntrySummary) => (isCredit(item) ? 'vuesax-linear:add-square' : 'vuesax-linear:minus-square')
const getColor = (item: LedgerEntrySummary) => (isCredit(item) ? '#F0FDF4' : '#FEF2F2')
const getColor2 = (item: LedgerEntrySummary) => (isCredit(item) ? '#22C55E' : '#F44336')
const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
@@ -166,3 +149,194 @@ onMounted(async () => {
}
})
</script>
<style scoped lang="less">
.tx {
background: @canvas;
min-height: 100vh;
}
.tx__body {
padding-top: 16px;
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>