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
+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>