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> <template>
<div <article class="oc" @click="emit('view', value)">
@click="emit('view', value)" <div class="oc__head">
class="w-full cursor-pointer relative rounded-[10px] mb-4 p-[10px] text-primary border border-solid border-[#E5E7EB] bg-[#fff]" <div class="oc__id">
> <span class="oc__idLabel">سفارش</span>
<div class="grid grid-cols-2 p-2"> <span class="oc__idValue">{{ value.orderNumber }}</span>
<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>
</div> </div>
<div class="grid justify-end"> <span class="oc__status" :style="{ background: color?.background, color: color?.font }">
<div class="rounded-[5px] px-[20px] py-[4px] w-fit text-[14px] mb-2" :style="{ background: color?.background, color: color?.font }"> {{ statusLabel }}
{{ statusLabel }} </span>
</div> </div>
<div>
<span class="text-[16px] font-bold pl-2">{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }} </span> <div class="oc__figures">
<span class="text-[14px] font-medium">گرم</span> <div class="oc__figure">
</div> <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>
</div> </div>
<a-divider style="margin: 8px 0" />
<div class="flex justify-between p-2"> <div class="oc__foot">
<div class="text-[16px] text-[#656f87]">{{ jdate(value.createdAt) }}</div> <span class="oc__date">{{ jdate(value.createdAt) }}</span>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-left" size="24" /> <span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div> </div>
</div> </article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { Divider as ADivider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue' import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus' import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters' import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client' 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 emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status) const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status]) const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
</script> </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 Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries useOrderItemProduct.ts) since the real order-items response only carries
productVariantId. `note` always shows '-' — our OrderItem schema has no productVariantId.
note field (legacy's per-item note was a real column there; ours isn't).
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> <template>
<div class="bg-white/10 rounded-[10px] p-[12px] flex items-center"> <article class="oi">
<img v-lazy="lookup?.imageUrl" class="ml-[8px] w-[120px]" /> <div class="oi__art">
<div class="flex-1" style="width: calc(100% - 82px)"> <img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div class="flex items-center"> <div v-else class="oi__artEmpty" aria-hidden="true">
<div class="font-semibold text-[16px] ml-2">{{ lookup?.productTitle ?? '—' }}</div> <Icon icon="vuesax-linear:diamonds" size="18" />
<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>
</div> </div>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct' import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client' import type { OrderItemDetail } from '../../api/client'
const props = withDefaults(defineProps<{ value: OrderItemDetail }>(), {}) const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct() const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>() const lookup = ref<VariantLookup | undefined>()
@@ -44,3 +59,86 @@ onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId) lookup.value = await lookupVariant(props.value.productVariantId)
}) })
</script> </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. 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/ Legacy rendered two separate empty states, gated on an `isFav` prop, that
emp_favorite too, but never actually used them in the template — those both used the SAME favoritEmpty image — a distinction without a difference,
unused imports are dropped here as genuinely dead code; the duplicate-image preserved verbatim while this was a faithful port. The only caller that ever
*behavior* itself is preserved). 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> <template>
<main <main
@@ -14,8 +16,7 @@
> >
<slot></slot> <slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" /> <Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !isFav" :image="favoritEmpty" /> <EmptyState v-if="flag" :image="favoritEmpty" />
<EmptyState v-if="flag && isFav" :image="favoritEmpty" />
</main> </main>
</template> </template>
@@ -32,7 +33,6 @@ withDefaults(
loading: boolean loading: boolean
flag: boolean flag: boolean
padding?: string padding?: string
isFav?: boolean
}>(), }>(),
{}, {},
) )
+12 -6
View File
@@ -64,12 +64,18 @@ router.beforeEach((to) => {
else nav.setPgaTransition(undefined); else nav.setPgaTransition(undefined);
}); });
// Ported from legacy router/index.ts:273-286. This hook is what actually // Ported from legacy router/index.ts:273-286.
// drives two animations, and its absence was why neither ran: //
// 1. cycling transitionFlag false->true replays AppHeader's "shutter" enter // setActiveLink moves the bottom-nav GSAP notch + bubble to the current tab,
// animation on every navigation; // and this hook is its ONLY caller in the whole app — that part is live.
// 2. setActiveLink moves the bottom-nav GSAP notch + bubble to the current //
// tab (it is the ONLY caller of setActiveLink in the whole app). // 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) => { router.afterEach((to) => {
const nav = useNavStore(); const nav = useNavStore();
const transition = useTransitionStore(); const transition = useTransitionStore();
+379 -98
View File
@@ -1,110 +1,122 @@
<!-- <!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue — 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 restyled into the redesigned design language (see components/home/* and
lines) is a multi-role workbench mixing plain-customer, sales-agent HomeView). The live rate block now reuses components/home/RateStrip.vue
("reagent"), and "banker" (type=1, sub-account switching) views. Kept: the rather than re-implementing the same strip a second time.
real balance card, the nav grid to Orders/Documents/Profile/Favorites, and
the logout/login-nudge dialogs — all backed by real data. 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 DROPPED as architecturally incompatible, not merely deferred: everything
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری" tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders), and (toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
customerChoice. In the redesigned schema (see the original migration customerChoice. Sales agents are AdminUser records with no customer-facing
audit), sales agents are AdminUser records with no customer-facing session session at all, and there is no `type` field on our Customer model, so the
at all — there is no "type" field on our Customer model, so there is data model this UI assumed no longer exists.
nothing to adapt here; the underlying data model this UI assumed no longer
exists.
DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing) DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing)
— PreOrder has a DB table but no controller anywhere in apps/api. Shown as — PreOrder has a DB table but no controller anywhere in apps/api.
a disabled tile rather than a broken link.
REPLACED, not dropped: legacy's "گزارش حساب" van-action-sheet called out to REPLACED, not dropped: legacy's "گزارش حساب" called out to the external
the external Tahesab accounting service (hesabMe()) that the very first Tahesab accounting service, which the first migration audit retired in
migration audit explicitly decided to retire in favor of this system's own favour of this system's own internal ledger — the balance card below shows
internal ledger — this dashboard's balance card already shows that real, that real internal position directly.
internal balance directly, so there's no separate popup needed.
DROPPED: the multi-row world gold/currency price ticker (legacy's GET DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
/price, ~14 rows). The new API has one single-karat quote endpoint rows). The new API has one single-karat quote endpoint, shown as one strip
(GET /pricing/latest) — shown as one line instead of fabricating rows that rather than fabricating rows that don't exist.
don't exist.
--> -->
<template> <template>
<div class="fixed top-0 z-10 w-full"> <div class="dash">
<AppHeader> <header class="dash__bar">
<div class="flex justify-between items-center"> <div class="dash__barInner">
<div></div> <span class="dash__barSpacer" aria-hidden="true" />
<h3 class="text-white bold mb-0 text-center w-full">میز کار</h3> <h1 class="dash__barTitle">میز کار</h1>
<Icon v-if="session.customerId" icon="vuesax-linear:logout" @click="data.notify = true" class="cursor-pointer text-white absolute left-5" size="25" /> <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> </div>
</AppHeader> </header>
</div>
<div class="h-16"></div>
<container class="overflow-y-scroll pt-4"> <container class="dash__body">
<div v-if="session.customerId"> <!-- Signed in: real position from GET /customers/:id -->
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid"> <template v-if="customer">
<div class="grid grid-cols-5 items-center pb-2 mb-2 border-b-2 border-solid border-[#E5E7EB]"> <section class="dash__balance">
<h5 class="font-bold col-span-2 mb-0">{{ customer?.fullName }}</h5> <div class="dash__balanceHead">
</div> <div>
<div class="flex justify-between"> <div class="dash__eyebrow">{{ remainText }}</div>
<div>شماره تلفن همراه</div> <div class="dash__figure">
<div>{{ session.mobile }}</div> {{ grams }}
</div> <span class="dash__unit">گرم</span>
</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" />
</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>
<div class="text-[#1C2D56] text-[18px] font-semibold flex items-center">
{{ price(goldPrice.pricePerGramRial) }} <div class="dash__balanceRow">
<div class="text-xs text-[#8D6A46] pr-0.5 font-medium">ریال</div> <span class="dash__rowLabel">بدهی ریالی</span>
<span class="dash__rowValue">{{ price(customer.fiatDebtRial) }} <i>ریال</i></span>
</div> </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">
</div> <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"> <DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
@@ -116,32 +128,35 @@
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div> <div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
</template> </template>
</DialogVant> </DialogVant>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" /> <img :src="loginDialogImage" alt="" class="opacity-80" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div> <div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template> </template>
<template v-slot:button> <template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div> <div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div> <div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template> </template>
</DialogVant> </DialogVant>
</container> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.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 DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg' import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg' import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import { price, priceUnit } from '../../utils/filters' import { price } from '../../utils/filters'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client' import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
@@ -151,7 +166,14 @@ const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null) const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | 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 }) const data = reactive({ notify: false, loginNotify: false })
@@ -190,3 +212,262 @@ onMounted(async () => {
} }
}) })
</script> </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 Per the earlier decision: OTP-based flow ported structurally, but the
is stubbed — no SMS provider exists. What's real vs. stubbed: 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 - Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. If it matches a real seeded customer, that customer's endpoint invented. A non-match shows legacy's own
exact legacy-notify message path is preserved: a non-match shows "اطلاعات کاربری در سیستم وجود ندارد" text, not a new message.
legacy's own "اطلاعات کاربری در سیستم وجود ندارد" text (legacy's actual
404 handling), not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since - 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 there is no real code to check against. On "verify", the session store
logs in as the matched real customer (session.login), so downstream logs in as the matched real customer, so downstream features (balance,
features (balance, ledger, favorites-as-logged-in) work against real ledger, favourites-as-logged-in) work against real seeded data.
seeded data. This is the "session wiring" — apps/api itself is untouched. - `token.fireBaseToken` doesn't exist in this port (Firebase deferred).
- `token.fireBaseToken` (Firebase push token) doesn't exist in this port - `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
(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.
--> -->
<template> <template>
<div> <div class="auth">
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" /> <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"> <PageBar title="ورود" back-to="home" />
<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"> <container class="auth__body">
<div> <section class="auth__intro">
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]"> <div class="auth__mark" aria-hidden="true">
<img :src="editImg" class="p-6" alt="henza" /> <Icon icon="vuesax-linear:security-user" size="28" />
</div>
</div>
</div>
</div> </div>
</div> <h2 class="auth__title">ورود به هنزاگلد</h2>
<div class="w-full text-center font-bold text-black text-lg">ورود به اپلیکیشن هنزا گلد</div> <p class="auth__sub">
<div class="px-8 custom-form"> {{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
<OtpInput </p>
class="w-full mb-[10px] text-black mt-5" </section>
v-show="body.step == 2 && timer > 0"
input-classes="otp-input" <section class="auth__card">
inputType="tel" <!-- Step 2: OTP -->
:num-inputs="4" <template v-if="body.step === 2">
:should-auto-focus="true" <OtpInput
:defaultFocus="body.defaultFocus" class="auth__otp"
@on-complete="sendToken" input-classes="otp-input"
/> inputType="tel"
<div v-show="body.step == 2 && timer === 0" class="text-center mb-[10px] mt-5">برای دریافت رمز عبور مجدد تلاش کنید</div> :num-inputs="4"
<div v-show="body.step == 2 && timer !== 0" class="text-center text-xl mb-[10px] mt-5"> :should-auto-focus="true"
امکان دریافت مجدد رمز در &nbsp;{{ ' ' + counterZeroShow(timer) + ' ' }} ثانیه دیگر :defaultFocus="body.defaultFocus"
</div> @on-complete="sendToken"
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="w-full"> />
<FormItem name="username" label="تلفن همراه" v-show="body.step == 1" :rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]">
<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" /> <Input type="number" v-model:value="formModel.username" />
</FormItem> </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 }} {{ btnText }}
</Button> </button>
</Form> </Form>
<div class="flex justify-center mt-5"> </section>
<div>اگر ثبت نام نکرده اید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', true)">ثبت نام کنید.</div> <p class="auth__switch">
</div> اگر ثبت نام نکرده‌اید
</div> <button type="button" @click="emit('register', true)">ثبت نام کنید</button>
</div> </p>
</container>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue' import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input } from 'ant-design-vue' import { 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 Icon from '../../components/icon/Icon.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 NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue' import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator' 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 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور')) const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script> </script>
<style lang="less"> <style scoped lang="less">
.shadowHeader { .auth {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); background: @canvas;
min-height: 100vh;
} }
.custom-form { .auth__body {
.otp-input { padding-top: 26px;
width: 50px; padding-bottom: 90px;
height: 50px; }
padding: 5px;
margin: 0;
font-size: 20px;
background: transparent;
border: 0 solid @black;
border-bottom-width: 2px;
text-align: center;
&.error { .auth__intro {
border: 1px solid @danger !important; display: flex;
} flex-direction: column;
} align-items: center;
text-align: center;
padding-bottom: 24px;
}
.otp-input::-webkit-inner-spin-button, .auth__mark {
.otp-input::-webkit-outer-spin-button { width: 64px;
-webkit-appearance: none; height: 64px;
margin: 0; 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> </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: Adapted (unchanged from the previous version):
- Status filter tabs use our real OrderStatus enum values directly (see - Status filter chips use our real OrderStatus enum values directly (see
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels. 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 - 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 endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched). client-side filtering is reasonable, and apps/api stays untouched).
- Search matches against `orderNumber` (our real user-facing identifier) - Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`. instead of legacy's numeric `id`.
DROPPED: "دریافت فاکتور" (get invoice) button — legacy's handler opened a DROPPED, unchanged: "دریافت فاکتور" — legacy's handler opened a hardcoded
hardcoded link to the real legacy production domain link to the real legacy production domain. Not merely unimplemented; an
(https://henzagold.com/oapi/...). Not just unimplemented — an actual
external production URL that must never be carried into this port. No external production URL that must never be carried into this port. No
invoice-generation endpoint exists in the new API either. 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> <template>
<div class="fixed top-0 z-10 w-full bg-white"> <div class="ord">
<AppHeader> <PageBar title="سفارشات" back-to="home">
<div class="flex justify-between items-center"> <template #below>
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'home' })" /> <div class="ord__barExtra">
<h3 class="text-white bold mb-0">سفارشات</h3> <SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
<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>
<container class="bg-[#F9FAFB] px-2 orders"> <div class="ord__chips no-scrollbar">
<Tabs v-model:activeKey="activeTab" style="padding-right: 0.2rem !important; font-weight: bold"> <button
<TabPane v-for="item in tabs" :key="item.key" :tab="item.title" force-render> v-for="item in tabs"
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-y-scroll no-scrollbar"> :key="item.key"
<OrderCard @view="openModal" v-for="item in filteredOrders" :key="item.id" :value="item" /> type="button"
<EmptyState v-if="filteredOrders.length === 0" :image="ordersEmpty" /> class="ord__chip"
</Spinner> :class="{ 'is-on': activeTab === item.key }"
</TabPane> @click="activeTab = item.key"
</Tabs> >
{{ item.title }}
<Modal v-model:visible="viewData.model" :mask="false" :footer="false" :closable="false" class="henza-custom-modal" v-if="viewData.item"> </button>
<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> </div>
</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> </container>
</Modal> </Spinner>
</container>
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { Drawer } from 'ant-design-vue'
import { Modal, TabPane, Tabs } from 'ant-design-vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import Icon from '../../components/icon/Icon.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 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 SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue' import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.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 { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client' import { api, type OrderDetail } from '../../api/client'
const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const tabs = [ const tabs = [
@@ -162,11 +202,13 @@ async function loadOrders() {
} }
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null }) 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 modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0)) const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
function openModal(item: OrderDetail) { function openDetail(item: OrderDetail) {
viewData.item = item viewData.item = item
viewData.model = true viewData.model = true
} }
@@ -174,109 +216,262 @@ function openModal(item: OrderDetail) {
onMounted(loadOrders) onMounted(loadOrders)
</script> </script>
<style lang="less"> <style scoped lang="less">
.henza-custom-modal, .ord {
.henza-custom-modal .ant-modal-body, background: @canvas;
.henza-custom-modal .ant-modal-content { min-height: 100vh;
border-radius: 0;
width: 100vw !important;
max-width: 100vw;
margin: 0;
height: 100vh;
top: 0;
padding: 0;
direction: rtl;
} }
.orders { .ord__barExtra {
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%;
max-width: 768px; max-width: 768px;
margin: auto; margin: 0 auto;
left: 0; padding: 0 16px 11px;
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);
} }
.responsive-steps { .ord__chips {
width: 100%; display: flex;
padding: 10px; gap: 8px;
overflow-x: auto;
.steps-container { margin: 11px -16px 0;
display: flex; padding: 0 16px;
justify-content: space-between; scroll-snap-type: x proximity;
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;
}
}
} }
@media (max-width: 600px) { .ord__chip {
.step-title { scroll-snap-align: start;
font-size: 11px; flex: 0 0 auto;
} padding: 7px 14px;
.step-circle { border-radius: 999px;
width: 24px; cursor: pointer;
height: 24px; font-family: inherit;
font-size: 10px; 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> </style>
+183 -60
View File
@@ -1,70 +1,74 @@
<!-- <!--
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue, Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
heavily simplified — legacy's original (971 lines, the largest view in the restyled into the redesigned design language (see components/home/* and
app) is a full edit form (bank info, KYC document uploads, sub-user HomeView).
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.
DROPPED: "تکمیل پروفایل" (complete profile) button, "افزودن کاربر" (add Heavily simplified when first ported, and still is — legacy's original (971
sub-user) FAB, the "کارشناس فروش" (sales agent) contact card — that last lines, the largest view in the app) is a full edit form (bank info, KYC
one needs the referring AdminUser's name/mobile, which the current document uploads, sub-user management for banker accounts). None of that has
GET /customers/:id response doesn't expose (only referredByAgentId, a bare a backend to back it: there's no update-customer endpoint, no
id) — not resolved here to keep apps/api untouched this batch. 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> <template>
<div class="fixed top-0 z-10 w-full"> <div class="prof">
<AppHeader class="bg-white shadowHeader"> <PageBar title="اطلاعات کاربری" back-to="userPage::hasSubRoute" />
<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>
<container> <container class="prof__body">
<div class="flex justify-center mb-8"> <!-- Identity -->
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50"> <section class="prof__identity">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full"> <div class="prof__avatar" aria-hidden="true">
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]"> <Icon icon="vuesax-linear:user" size="30" />
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div> </div>
</div> <h2 class="prof__name">{{ customer?.fullName ?? '—' }}</h2>
</div> <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]"> <!-- Real, read-only fields -->
<div class="flex justify-between"> <section v-if="customer" class="prof__block">
<div class="text-[#9CA3AF] font-medium">نام و نام خانوادگی :</div> <SectionHead title="مشخصات" subtitle="اطلاعات ثبت‌شده حساب شما" />
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.fullName }}</div> <dl class="prof__specs">
</div> <div class="prof__spec">
<div class="flex justify-between mt-8"> <dt>نام و نام خانوادگی</dt>
<div class="text-[#9CA3AF] font-medium">تلفن همراه :</div> <dd>{{ customer.fullName }}</dd>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.mobile }}</div> </div>
</div> <div class="prof__spec">
<div class="flex justify-between mt-8"> <dt>تلفن همراه</dt>
<div class="text-[#9CA3AF] font-medium">کد ملی :</div> <dd>{{ customer.mobile }}</dd>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.nationalCode }}</div> </div>
</div> <div class="prof__spec">
<div class="flex justify-between mt-8"> <dt>کد ملی</dt>
<div class="text-[#9CA3AF] font-medium">وضعیت احراز هویت :</div> <dd>{{ customer.nationalCode }}</dd>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ VERIFICATION_LABELS[customer.verificationStep] }}</div> </div>
</div> <div class="prof__spec">
</div> <dt>وضعیت احراز هویت</dt>
</container> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import editImg from '../../assets/img/henza.svg'
import Icon from '../../components/icon/Icon.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 { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail } from '../../api/client' import { api, type CustomerDetail } from '../../api/client'
@@ -75,19 +79,138 @@ const VERIFICATION_LABELS: Record<string, string> = {
VERIFIED: 'تایید شده', VERIFIED: 'تایید شده',
} }
const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null) const customer = ref<CustomerDetail | null>(null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
onMounted(async () => { onMounted(async () => {
if (session.customerId) { if (!session.customerId) return
loading.value = true
try {
customer.value = await api.getCustomer(session.customerId) customer.value = await api.getCustomer(session.customerId)
} finally {
loading.value = false
} }
}) })
</script> </script>
<style scoped> <style scoped lang="less">
.shadowHeader { .prof {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); 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> </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): DROPPED as confirmed-dead code (verified unreachable, not merely "possibly
legacy rendered a whole cascading country->province->city picker dead"): legacy rendered a whole cascading country->province->city picker
(WorkActionSheet + selectCityArrays + getCities() against POST /cities/list) (WorkActionSheet + selectCityArrays + getCities() against POST /cities/list)
alongside a plain "شهر" text <Input>. The only element that ever opened the alongside a plain "شهر" text <Input>. The only element that ever opened the
picker (`#citybtn`) was itself commented out in legacy's own template, so picker (`#citybtn`) was itself commented out in legacy's own template, so the
the picker was unreachable UI even in the original app; the visible form picker was unreachable UI even in the original app; the visible form uses the
uses the plain text input. That whole complex, and the parallel unused plain text input. That whole complex, and the parallel unused
`userFormModel`/`defaultUserFormModel` objects (the template binds `userFormModel`/`defaultUserFormModel` objects, are not ported.
`formModel.*`, a *different*, separately-declared empty reactive object —
`userFormModel` is never referenced anywhere), are not ported.
STUBBED, not real: POST /major/register — apps/api has no create-customer STUBBED, not real, and unchanged from the previous version: legacy's
endpoint. Submitting shows legacy's exact success dialog without actually POST /major/register — apps/api has no create-customer endpoint. Submitting
persisting anything. 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> <template>
<div> <div class="auth">
<div class="fixed w-full"> <PageBar title="ثبت نام" back-to="home" />
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" /> <Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
</div> <container class="auth__body">
<div class="fixed top-0 z-10 w-full"> <section class="auth__intro">
<AppHeader class="bg-white shadowHeader"> <div class="auth__mark" aria-hidden="true">
<div class="flex justify-between items-center"> <Icon icon="vuesax-linear:user-add" size="28" />
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" /> </div>
<h3 class="t bold mb-0">ثبت نام</h3> <h2 class="auth__title">ساخت حساب کاربری</h2>
<div></div> <p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p>
</div> </section>
</AppHeader>
</div> <section class="auth__card">
<div class="relative w-full py-16 px-5"> <Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
<div class="mt-14"> <FormItem name="fname" label="نام" :rules="required('نام')">
<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('نام')">
<Input v-model:value="formModel.fname" /> <Input v-model:value="formModel.fname" />
</FormItem> </FormItem>
<FormItem style="font-size: 16px" name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')"> <FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" /> <Input v-model:value="formModel.lname" />
</FormItem> </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" /> <Input v-model:value="formModel.mobile" type="number" />
</FormItem> </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" /> <Input v-model:value="formModel.store_name" />
</FormItem> </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" /> <Input v-model:value="formModel.phone" type="number" />
</FormItem> </FormItem>
<FormItem style="font-size: 16px" name="address" label="آدرس پستی" :rules="required('آدرس')"> <FormItem name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" /> <Textarea v-model:value="formModel.address" :rows="3" />
</FormItem> </FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')"> <FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" /> <Input v-model:value="formModel.city" />
</FormItem> </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 type="submit" class="auth__cta">ثبت اطلاعات</button>
</Button>
</Form> </Form>
</Spinner> </section>
<div class="flex justify-center mt-10">
<div>اگر حساب کاربری دارید از این قسمت</div> <p class="auth__switch">
<div class="pr-1 text-primary font-bold" @click="emit('register', false)">وارد شوید.</div> اگر حساب کاربری دارید
</div> <button type="button" @click="emit('register', false)">وارد شوید</button>
</div> </p>
</div> </container>
</Spinner>
<DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false"> <DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false">
<template v-slot:img> <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80" /> <img :src="registerDelayImage" alt="" class="opacity-80" />
</template> </template>
<template v-slot:title> <template v-slot:title>
<div class="p-5">ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.</div> <div class="p-5">
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
</div>
</template> </template>
<template v-slot:button> <template v-slot:button>
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div> <div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
@@ -84,12 +90,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input, Textarea } from 'ant-design-vue' import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import registerDelayImage from '../../assets/img/registerDelay.svg' import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.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 Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue' import DialogVant from '../../components/DialogVant.vue'
import { required, mobile as mobileRule } from '../../utils/validator' import { required, mobile as mobileRule } from '../../utils/validator'
@@ -121,8 +125,94 @@ const register = async () => {
} }
</script> </script>
<style scoped> <style scoped lang="less">
.shadowHeader { .auth {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); 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> </style>
+263 -89
View File
@@ -1,74 +1,63 @@
<!-- <!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents). Replaces the English AccountView scaffold ("اسناد" — financial documents), restyled into the redesigned design
that had no legacy counterpart. language (see components/home/* and HomeView).
Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new
system's equivalent is LedgerEntry, already exposed by system's equivalent is LedgerEntry, already exposed by
GET /customers/:id/ledger-entries, so the list is real data. GET /customers/:id/ledger-entries, so the list is real data.
Legacy's colour/icon logic is preserved exactly: `hasFactor(item)` was Legacy's colour/icon logic is preserved in meaning: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → green `item.type != 9` (type 9 = سفارش, the debt-creating order entry) → a green
#F0FDF4/#22C55E + add-square for everything else, red #FEF2F2/#F44336 + "in" treatment for everything else, red "out" for orders. That maps 1:1 onto
minus-square for orders. That maps 1:1 onto our CREDIT/DEBIT direction, our CREDIT/DEBIT direction, since an ORDER_STOCK entry is exactly the DEBIT
since an ORDER_STOCK entry is exactly the DEBIT case. case. The palette is now the design system's own rather than hardcoded hexes.
DROPPED: the "دریافت فایل" per-document download — legacy's getFile() hit the DROPPED, unchanged from the previous version: the "دریافت فایل" per-document
old Laravel host; there is no document-file endpoint in the new API. download — legacy's getFile() hit the old Laravel host; there is no
document-file endpoint in the new API.
--> -->
<template> <template>
<div class="fixed top-0 z-10 w-full"> <div class="tx">
<AppHeader> <PageBar title="اسناد" back-to="userPage::hasSubRoute" />
<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>
<container class="overflow-y-auto bg-[#F9FAFB]"> <Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="no-scrollbar overflow-auto"> <container class="tx__body">
<div <template v-if="entries.length">
v-if="entries.length" <SectionHead title="گردش حساب" :subtitle="`${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>
<div class="col-span-7 pr-1 text-primary mt-2"> <div class="tx__list">
<div class="flex justify-between items-center"> <article v-for="item in entries" :key="item.id" class="tx__row" @click="openEntry(item)">
<div class="font-bold mb-2">{{ item.note || '- -' }}</div> <span class="tx__mark" :class="isCredit(item) ? 'is-in' : 'is-out'" aria-hidden="true">
<div <Icon :icon="isCredit(item) ? 'vuesax-linear:arrow-down' : 'vuesax-linear:arrow-up'" size="16" />
class="rounded-[5px] px-[20px] py-[4px] w-fit text-[16px] mb-2 font-semibold" </span>
: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>
<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> </Spinner>
<Drawer <Drawer
@@ -76,48 +65,46 @@
placement="bottom" placement="bottom"
v-if="activeEntry" v-if="activeEntry"
:closable="false" :closable="false"
height="450px" height="420px"
:class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']" class="rtl"
:bodyStyle="{ display: 'flex', flexDirection: 'column' }" :bodyStyle="{ padding: 0 }"
> >
<div class="w-full h-full flex flex-col"> <div class="sheet">
<div class="pb-[10px] border-b border-solid border-[#E5E7EB] flex text-[16px] font-bold">جزئیات سند</div> <h3 class="sheet__title">جزئیات سند</h3>
<div class="flex-1 py-[10px] overflow-auto no-scrollbar"> <dl class="sheet__specs">
<div class="flex justify-between mb-4"> <div class="sheet__spec">
<div class="text-grey">نوع سند :</div> <dt>نوع سند</dt>
<div class="font-semibold">{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</div> <dd>{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</dd>
</div> </div>
<div class="flex justify-between mb-4"> <div class="sheet__spec">
<div class="text-grey">مبلغ :</div> <dt>مبلغ</dt>
<div class="font-semibold">{{ formatAmount(activeEntry) }}</div> <dd :class="isCredit(activeEntry) ? 'is-in' : 'is-out'">{{ formatAmount(activeEntry) }}</dd>
</div> </div>
<div class="flex justify-between mb-4"> <div class="sheet__spec">
<div class="text-grey">وضعیت :</div> <dt>وضعیت</dt>
<div class="font-semibold">{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div> <dd>{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</dd>
</div> </div>
<div class="flex justify-between mb-4"> <div class="sheet__spec">
<div class="text-grey">تاریخ :</div> <dt>تاریخ</dt>
<div class="font-semibold">{{ jdate(activeEntry.createdAt) }}</div> <dd>{{ jdate(activeEntry.createdAt) }}</dd>
</div> </div>
<div class="flex justify-between mb-4"> <div class="sheet__spec">
<div class="text-grey">توضیحات :</div> <dt>توضیحات</dt>
<div class="font-semibold">{{ activeEntry.note || '- -' }}</div> <dd>{{ activeEntry.note || '—' }}</dd>
</div> </div>
</div> </dl>
</div> </div>
</Drawer> </Drawer>
</container> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue' import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.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 Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue' import SectionHead from '../../components/home/SectionHead.vue'
import transactionEmpty from '../../assets/img/empty/transactionEmpty.svg'
import { jdate, price } from '../../utils/filters' import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session' import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client' import { api, type LedgerEntrySummary } from '../../api/client'
@@ -133,7 +120,6 @@ const SOURCE_LABELS: Record<string, string> = {
ADJUSTMENT: 'اصلاحیه', ADJUSTMENT: 'اصلاحیه',
} }
const router = useRouter()
const session = useSessionStore() const session = useSessionStore()
const loading = ref(false) const loading = ref(false)
@@ -144,9 +130,6 @@ const activeEntry = ref<LedgerEntrySummary | null>(null)
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT // legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
// entries are exactly that case. // entries are exactly that case.
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT' 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) => const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال` item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
@@ -166,3 +149,194 @@ onMounted(async () => {
} }
}) })
</script> </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>