user-app: extend the redesign across the account screens

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-15 01:34:30 +03:30
co-authored by Claude Opus 5
parent cc62975dd0
commit 0e5f9ccd7b
11 changed files with 1853 additions and 714 deletions
@@ -1,58 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/appHeader.vue.
NOTE (existing issue, ported as-is per instructions — not fixed here):
the header's id is "" (empty) but onEnter() looks up getElementById('appHeader').
That lookup always returns null, so onEnter() never actually sets the header
height from this code path in practice. Preserved exactly; flagged separately.
-->
<template>
<transition name="shutter">
<header
id=""
class="overflow-hidden bg-[#1C2D56] rounded-bl-[32px] p-[20px] pl-[30px] w-full mb-[0px] z-10"
v-if="transition.transitionFlag"
>
<slot />
</header>
</transition>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useTransitionStore } from '../stores/transitionStore'
const transition = useTransitionStore()
function onEnter() {
const element = document.getElementById('appHeader')
if (element && element.offsetHeight) {
element.style.position = 'fixed'
element.style.top = '0'
transition.setHeaderHeight(element.offsetHeight + 16)
}
}
function onBeforeLeave() {
if (transition.headerHeight > 0) transition.setHeaderHeight(0)
}
onBeforeUnmount(() => onBeforeLeave())
onMounted(() => {
setTimeout(() => onEnter(), 1000)
})
</script>
<style lang="less">
.shutter-enter-active {
animation: shutter 1s cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes shutter {
0% {
max-height: 0;
opacity: 0;
color: transparent !important;
}
100% {
max-height: 120px;
opacity: 1;
}
}
</style>
@@ -1,49 +1,165 @@
<!-- Ported from legacy henza_pwa/src/components/product/order.vue -->
<!--
Order summary row, restyled into the redesigned design language (see
components/home/* and HomeView). Ported originally from legacy
henza_pwa/src/components/product/order.vue.
Status colours still come from utils/orderStatus.ts, which is the single
source of truth shared with the detail view — not re-declared here.
-->
<template>
<div
@click="emit('view', value)"
class="w-full cursor-pointer relative rounded-[10px] mb-4 p-[10px] text-primary border border-solid border-[#E5E7EB] bg-[#fff]"
>
<div class="grid grid-cols-2 p-2">
<div>
<div class="text-[18px] font-sans font-semibold mb-2">
<span class="text-[#656f87]">سفارش :</span>
{{ value.orderNumber }}
</div>
<div>
<span class="text-[16px] font-bold pl-2">{{ value.items.length }}</span>
<span class="text-[14px] font-medium">آیتم</span>
</div>
<article class="oc" @click="emit('view', value)">
<div class="oc__head">
<div class="oc__id">
<span class="oc__idLabel">سفارش</span>
<span class="oc__idValue">{{ value.orderNumber }}</span>
</div>
<div class="grid justify-end">
<div class="rounded-[5px] px-[20px] py-[4px] w-fit text-[14px] mb-2" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</div>
<div>
<span class="text-[16px] font-bold pl-2">{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }} </span>
<span class="text-[14px] font-medium">گرم</span>
</div>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<div class="oc__figures">
<div class="oc__figure">
<span class="oc__figureLabel">تعداد</span>
<span class="oc__figureValue">
{{ value.items.length }}
<i>آیتم</i>
</span>
</div>
<div class="oc__figure">
<span class="oc__figureLabel">وزن</span>
<span class="oc__figureValue">
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i>
</span>
</div>
</div>
<a-divider style="margin: 8px 0" />
<div class="flex justify-between p-2">
<div class="text-[16px] text-[#656f87]">{{ jdate(value.createdAt) }}</div>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-left" size="24" />
<div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Divider as ADivider } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = withDefaults(defineProps<{ value: OrderDetail }>(), {})
const props = defineProps<{ value: OrderDetail }>()
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
</script>
<style scoped lang="less">
.oc {
cursor: pointer;
padding: 13px 14px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.18s ease;
}
.oc:active {
transform: scale(0.99);
}
.oc__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.oc__id {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.oc__idLabel {
font-size: 11px;
color: @inkFaint;
}
.oc__idValue {
font-size: 13.5px;
font-weight: 800;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oc__status {
flex: 0 0 auto;
padding: 4px 11px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.oc__figures {
display: flex;
gap: 26px;
padding: 12px 0 11px;
}
.oc__figure {
display: flex;
flex-direction: column;
gap: 3px;
}
.oc__figureLabel {
font-size: 10.5px;
color: @inkFaint;
}
.oc__figureValue {
font-size: 14px;
font-weight: 800;
color: @ink;
}
.oc__figureValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 3px;
}
.oc__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
border-top: 1px solid @hairlineSoft;
}
.oc__date {
font-size: 11px;
color: @inkFaint;
}
.oc__more {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
</style>
@@ -1,42 +1,57 @@
<!--
Ported from legacy henza_pwa/src/components/product/orderItem.vue.
One line of an order's contents, restyled into the redesigned design
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
Adapted: product/variant title comes from a client-side lookup (see
useOrderItemProduct.ts) since the real order-items response only carries
productVariantId. `note` always shows '-' — our OrderItem schema has no
note field (legacy's per-item note was a real column there; ours isn't).
productVariantId.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
ours isn't), so it could only ever print a dash. An always-empty row is
noise, not information.
-->
<template>
<div class="bg-white/10 rounded-[10px] p-[12px] flex items-center">
<img v-lazy="lookup?.imageUrl" class="ml-[8px] w-[120px]" />
<div class="flex-1" style="width: calc(100% - 82px)">
<div class="flex items-center">
<div class="font-semibold text-[16px] ml-2">{{ lookup?.productTitle ?? '—' }}</div>
<div class="text-[12px] text-secondary">({{ lookup?.variantTitle }})</div>
</div>
<div class="grid grid-cols-2">
<div class="mt-2">
<div>تعداد کالاها :</div>
<div class="pt-1">وزن :</div>
<div class="pt-1">اجرت :</div>
<div class="pt-1">توضیحات :</div>
</div>
<div class="text-left mt-2 text-black font-semibold">
<div>{{ value.quantity }} <span>عدد</span></div>
<div class="pt-1">{{ value.computedWeightGrams }} <span>گرم</span></div>
<div class="pt-1">{{ value.wagePercentSnapshot }} <span>درصد</span></div>
<div class="pt-1">-</div>
</div>
<article class="oi">
<div class="oi__art">
<img v-if="lookup?.imageUrl" v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<div v-else class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
</div>
</div>
</div>
<div class="oi__main">
<div class="oi__head">
<h4 class="oi__title">{{ lookup?.productTitle ?? '—' }}</h4>
<span v-if="lookup?.variantTitle" class="oi__variant">{{ lookup.variantTitle }}</span>
</div>
<div class="oi__figures">
<span class="oi__figure">
{{ value.quantity }}
<i>عدد</i>
</span>
<span class="oi__figure">
{{ value.computedWeightGrams }}
<i>گرم</i>
</span>
<span class="oi__figure">
{{ value.wagePercentSnapshot }}
<i>٪ اجرت</i>
</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
const props = withDefaults(defineProps<{ value: OrderItemDetail }>(), {})
const props = defineProps<{ value: OrderItemDetail }>()
const { lookup: lookupVariant } = useOrderItemProduct()
const lookup = ref<VariantLookup | undefined>()
@@ -44,3 +59,86 @@ onMounted(async () => {
lookup.value = await lookupVariant(props.value.productVariantId)
})
</script>
<style scoped lang="less">
.oi {
display: flex;
gap: 11px;
padding: 11px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 16px;
}
.oi__art {
flex: 0 0 62px;
width: 62px;
height: 62px;
border-radius: 12px;
overflow: hidden;
background: @hairlineSoft;
}
.oi__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.oi__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.oi__main {
flex: 1;
min-width: 0;
}
.oi__head {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oi__variant {
flex: 0 0 auto;
font-size: 10.5px;
color: @inkFaint;
}
.oi__figures {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding-top: 9px;
}
.oi__figure {
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
}
</style>
@@ -1,10 +1,12 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchSearch.vue.
EXISTING ISSUE, ported as-is: both the isFav and non-isFav empty states use
the same `favoritEmpty` image (legacy imported cartEmpty/emp_search/
emp_favorite too, but never actually used them in the template — those
unused imports are dropped here as genuinely dead code; the duplicate-image
*behavior* itself is preserved).
Legacy rendered two separate empty states, gated on an `isFav` prop, that
both used the SAME favoritEmpty image — a distinction without a difference,
preserved verbatim while this was a faithful port. The only caller that ever
passed `is-fav` was FavoritesView, which now renders its own empty state in
the redesigned language, so the prop had no remaining callers and the two
identical branches collapse to one.
-->
<template>
<main
@@ -14,8 +16,7 @@
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !isFav" :image="favoritEmpty" />
<EmptyState v-if="flag && isFav" :image="favoritEmpty" />
<EmptyState v-if="flag" :image="favoritEmpty" />
</main>
</template>
@@ -32,7 +33,6 @@ withDefaults(
loading: boolean
flag: boolean
padding?: string
isFav?: boolean
}>(),
{},
)