SearchView: Removed the dynamic collection-tile grid (e.g. "پرفروش‌های این هفته") and the "نتایج / N محصول" section header from the UI. Underlying data/filtering logic untouched.

DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):

Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.

Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.

LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
This commit is contained in:
s.esmaeili
2026-09-18 12:54:45 +03:30
parent 05fe780c89
commit 53a304b5d5
355 changed files with 22865 additions and 14 deletions
+490
View File
@@ -0,0 +1,490 @@
<!--
Ported from legacy henza_pwa/src/views/cart.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
EVERY REAL BEHAVIOUR IS CARRIED OVER UNCHANGED — only the chrome changes:
- "ثبت نهایی سفارش" still posts to the real POST /orders endpoint. Legacy's
own `/set-order` payload had two hardcoded placeholder numbers with the
original author's comment "how to get?!"; the real Orders API computes
gold weight server-side from productVariantId + wagePercent, so
wagePercent is taken from the cart item's pinned variant.
- submitting requires being logged in for real (the Orders API requires a
real customerId). Legacy's router didn't gate the cart route, so this
gate is an adaptation, kept from the previous version.
- the per-item note drawer, the confirm dialog and the success step are
all unchanged in behaviour.
No rial total is printed, for the same reason the rest of the redesign
prints no price: the figure is the live gold rate x weight plus wage,
computed at order time. The honest total the cart can show is total weight,
which is what it shows.
DROPPED: the old unscoped `.rtl { direction: ltr }` rule, which was dead —
the global `.rtl` in style.less carries !important, so a non-!important rule
could never override it. It was previously kept "for fidelity" while the
file was a faithful port; now that the file is deliberately redesigned,
carrying a rule that provably cannot apply is just noise.
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
v-for="(item, i) in cart.items"
:key="item.pid + item.vid"
:value="item"
class="cart__row"
:style="revealDelay(i)"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید شما خالی است.</p>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</PullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
<div class="cart__doneIcon" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="30" />
</div>
<h2 class="cart__doneTitle">سفارش شما ثبت شد</h2>
<p class="cart__doneSub">
شماره سفارش
<b>{{ cartData.orderID }}</b>
</p>
<p class="cart__doneHint">برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید.</p>
<button type="button" class="cart__doneCta" @click="goOrders">فهرست سفارش‌ها</button>
</section>
</container>
</Spinner>
<!-- Sticky summary + submit -->
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<div class="cart__barInner">
<div class="cart__total">
<span class="cart__totalLabel">جمع وزن</span>
<span class="cart__totalValue">
{{ cart.total() }}
<i>گرم</i>
</span>
</div>
<button type="button" class="cart__submit" @click="cartData.notify = true">ثبت نهایی سفارش</button>
</div>
</div>
<!-- Per-item note -->
<Drawer
v-model:visible="cartData.showPopUp"
placement="bottom"
v-if="cartData.tempRow?.product"
:closable="false"
height="300px"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="sheet">
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote">
<FormItem has-feedback name="note">
<Textarea v-model:value="formModel.note" :rows="4" placeholder="مثلاً سایز یا جزئیات سفارش" />
</FormItem>
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
</Form>
</div>
</Drawer>
<DialogVant :timeOut="-1" v-model:show="cartData.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="dialogEmpty4" alt="" class="opacity-70" />
</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="submitOrder">بله</div>
<div class="text-center text-[#6b7280]" @click="cartData.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
// Module scope, so it survives this view being unmounted and remounted as the
// user moves between tabs, and resets only on a full app reload — the same
// approach HomeView's first-paint reveal uses, and for the same reason: the
// choreography is a first-run flourish, not something to replay on every visit
// to the cart.
let hasRevealed = false
const router = useRouter()
const cart = useCartStore()
const session = useSessionStore()
const loginNotify = ref(false)
const refreshing = ref(false)
// First-paint entrance for the basket rows. Flipped once, after the cart has
// loaded; pull-to-refresh does not replay it.
const revealed = ref(hasRevealed)
// Each row starts a beat after the one above it. Kept small — a full basket
// finishes well under a second, so it reads as the list arriving rather than
// as something the user has to wait through.
const REVEAL_STEP_MS = 60
function revealDelay(i: number) {
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
}
const cartData = reactive({
loadingPage: false,
showPopUp: false,
step: 1,
notify: false,
orderID: '',
tempRowIndex: -1,
tempRow: { product: undefined } as Partial<CartItemType>,
})
const formModel = reactive<{ note: string | undefined }>({ note: undefined })
const setNote = () => {
cart.updateFeild(formModel.note, cartData.tempRowIndex, cartData.tempRow as CartItemType, 'note')
cartData.showPopUp = false
formModel.note = undefined
cartData.tempRow = { product: undefined }
}
// legacy routed to `home` because this port had no orders list yet; it exists
// now, so the success step links to the real one.
const goOrders = async () => {
await router.push({ name: 'ordersList' })
cartData.step = 1
}
const submitOrder = async () => {
cartData.notify = false
if (!session.customerId) {
loginNotify.value = true
return
}
try {
cartData.loadingPage = true
const order = await api.createOrder({
customerId: session.customerId,
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
productVariantId: item.vid,
quantity: item.qty,
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
}
}),
})
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
} catch (e) {
console.log(e)
} finally {
cartData.loadingPage = false
}
}
async function onRefresh() {
await cart.init()
refreshing.value = false
}
onMounted(async () => {
await cart.init()
// Wait for the rows the load just populated to be in the DOM, then let one
// frame paint them in their "before" state — otherwise the browser coalesces
// both states into one and there is nothing to animate.
if (revealed.value) return
await nextTick()
requestAnimationFrame(() => {
revealed.value = true
hasRevealed = true
})
})
</script>
<style scoped lang="less">
.cart {
background: @canvas;
min-height: 100vh;
}
.cart__body {
padding-top: 76px;
padding-bottom: 150px;
}
// One continuous list rather than a stack of boxes: the rows are borderless
// and a hairline divider separates each from the next. No gap — the rows carry
// their own vertical padding, so the divider sits centred between them.
.cart__items {
display: flex;
flex-direction: column;
padding-top: 8px;
}
.cart__row + .cart__row {
border-top: 1px solid @hairline;
}
// First-paint entrance, the same choreography HomeView uses: each row rises a
// little, scales up from just under full size and fades in, one beat after the
// one above it.
//
// The curve is a long decelerate with no overshoot — it starts fast, settles
// slowly, and never bounces back. The travel is deliberately small (12px, 2%
// of scale): the list should look like it was always there and simply came
// into focus, not like it flew in.
.cart__row {
opacity: 0;
transform: translate3d(0, 12px, 0) scale(0.98);
will-change: transform, opacity;
}
.is-revealed .cart__row {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
}
// Respect a reduced-motion preference: show the list, skip the choreography.
@media (prefers-reduced-motion: reduce) {
.cart__row {
opacity: 1;
transform: none;
}
.is-revealed .cart__row {
transition: none;
}
}
.cart__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.cart__empty p {
margin: 0;
font-size: 12.5px;
}
.cart__emptyCta {
margin-top: 4px;
padding: 9px 18px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.cart__done {
margin-top: 20px;
padding: 34px 22px;
border: 1px solid @hairline;
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.cart__doneIcon {
width: 56px;
height: 56px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.cart__doneTitle {
margin: 14px 0 0;
font-size: 15px;
font-weight: 800;
color: @ink;
}
.cart__doneSub {
margin: 8px 0 0;
font-size: 12.5px;
color: @inkMuted;
}
.cart__doneSub b {
color: @ink;
}
.cart__doneHint {
margin: 5px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.cart__doneCta {
margin-top: 18px;
padding: 11px 22px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.cart__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 66px;
}
.cart__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.cart__total {
display: flex;
flex-direction: column;
gap: 3px;
}
.cart__totalLabel {
font-size: 11px;
color: @inkFaint;
}
.cart__totalValue {
font-size: 16px;
font-weight: 800;
color: @ink;
}
.cart__totalValue i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 3px;
}
.cart__submit {
flex: 1;
max-width: 200px;
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;
}
.cart__submit:active {
transform: scale(0.98);
}
.sheet {
padding: 18px 16px 22px;
}
.sheet__title {
margin: 0 0 14px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.sheet__cta {
width: 100%;
padding: 12px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<!--
Home screen, built to the supplied design.
Layout, top to bottom:
1. a slim brand bar: the logo, and the live 18k rate strip beside it
(real: GET /pricing/latest). Search and cart are reachable from the
bottom navigation, so the bar carries the rate instead.
2. the coverflow hero (real categories, decorative artwork)
3. "دسته بندی ها" — the category rail
4. "محصولات جدید" — a peeking horizontal rail, then the vertical list
Data honesty, carried over from the previous version deliberately:
- the API exposes no curated home payload, no promotions and no per-product
price, so nothing on this page prints a rial price for a piece. Weight and
making-charge percent are real per-variant fields; everything else shown
is a label, not invented data. In particular the design's struck-through
"اجرت ۱۵٪" is not rendered — see ProductPanel's header.
- categories come from the catalogue itself, so an empty catalogue renders
an empty state instead of placeholder tiles.
-->
<template>
<div class="home">
<header class="home__bar">
<div class="home__barInner">
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
<RateStrip :quote="goldPrice" compact class="home__rate" />
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="home__body" :class="{ 'is-revealed': revealed }">
<HeroCarousel
v-if="slides.length"
:slides="slides"
class="home__hero home__reveal"
:style="revealDelay(0)"
@select="onSlide"
/>
<section v-if="memory.cats.length" class="home__section home__reveal" :style="revealDelay(1)">
<h2 class="home__heading">دسته بندی ها</h2>
<CategoryGrid :categories="memory.cats" :active-id="null" @select="onCategory" />
</section>
<section v-if="newestProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
<h2 class="home__heading">محصولات جدید</h2>
<!--
The design's top row: a looping carousel whose centre card is
larger than the two peeking neighbours, ending in a "see all" card.
-->
<ProductCarousel :products="reelProducts" @open="openProduct" @all="goNewest" />
<!-- The vertical list below the rail, as in the design. "See all"
lives on the carousel's last card, so no trailing button here. -->
<div class="home__list">
<ProductPanel
v-for="(item, i) in listProducts"
:key="item.id"
:value="item"
class="home__reveal"
:style="revealDelay(3 + i)"
@open="openProduct"
/>
</div>
</section>
<section v-if="!loading && !products.length" class="home__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
</container>
</PullRefresh>
<Drawer
v-model:visible="productDrawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="productDrawer = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
import SingleProduct from '../components/product/SingleProduct.vue'
import RateStrip from '../components/home/RateStrip.vue'
import HeroCarousel from '../components/home/HeroCarousel.vue'
import type { HeroSlide } from '../components/home/HeroCarousel.vue'
import CategoryGrid from '../components/home/CategoryGrid.vue'
import ProductPanel from '../components/home/ProductPanel.vue'
import ProductCarousel from '../components/home/ProductCarousel.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import { api } from '../api/client'
import type { GoldPriceQuote, ProductSummary } from '../api/client'
// Module scope, so it survives this view being unmounted and remounted as the
// user moves between tabs, and resets only on a full app reload.
let hasRevealed = false
const router = useRouter()
const memory = useConfigStore()
const filters = useFiltersStore()
const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
// First-paint entrance. Flipped once, after the first data load, so the
// sections settle into place instead of appearing all at once; pull-to-refresh
// does not replay it.
//
// MasterLayout renders its route without keep-alive, so this view remounts on
// every return to the Home tab. The flag therefore lives outside the component
// — the choreography is a first-run flourish, and replaying it on each tab
// switch would fight the page transition rather than add to it.
const revealed = ref(hasRevealed)
// Each section starts a beat after the one above it. Kept small — the whole
// sequence finishes well under a second, so it reads as the page arriving
// rather than as something the user has to wait through.
const REVEAL_STEP_MS = 70
function revealDelay(i: number) {
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
}
// How many of the newest pieces the home page shows before handing off to the
// dedicated "محصولات جدید" route.
const REEL_LIMIT = 6
const LIST_LIMIT = 4
const newestProducts = computed(() => products.value)
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
// The hero is built from REAL categories, not a curated banner payload (none
// exists). Each slide deep-links into search filtered by that category, so the
// artwork is decorative but the destination is genuine.
const slides = computed<HeroSlide[]>(() =>
memory.cats.slice(0, 5).map((cat) => ({
key: cat.id,
title: cat.title,
image: productImageForCategory(cat.id),
categoryId: cat.id,
})),
)
function productImageForCategory(categoryId: string): string | undefined {
const match = products.value.find((p) => p.category?.id === categoryId)
return match?.images?.[0]?.url
}
function openProduct(product: ProductSummary) {
activeProductId.value = product.id
productDrawer.value = true
}
function onCategory(cat: HomeCategory) {
filters.query.cats = [cat.id]
router.push({ name: 'search' })
}
function onSlide(slide: HeroSlide) {
if (slide.categoryId) {
filters.query.cats = [slide.categoryId]
}
router.push({ name: 'search' })
}
function goNewest() {
router.push({ name: 'newestProduct' })
}
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
}
async function onRefresh() {
await loadData()
refreshing.value = false
}
onMounted(async () => {
await loadData()
loading.value = false
// Wait for the sections the load just populated to be in the DOM, then let
// one frame paint them in their "before" state — otherwise the browser
// coalesces both states into one and there is nothing to animate.
if (revealed.value) return
await nextTick()
requestAnimationFrame(() => {
revealed.value = true
hasRevealed = true
})
})
</script>
<style scoped lang="less">
.home {
background: @canvas;
min-height: 100vh;
}
.home__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(18px) saturate(140%);
// A hairline rule cut a hard line across the page. A very soft fade under
// the bar separates it from the content that scrolls beneath without
// drawing an edge.
border-bottom: 1px solid fade(@primary, 5%);
box-shadow: 0 8px 20px -18px fade(@primary, 55%);
}
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.home__logo {
// Sized to match the pill's height so the two sides of the bar carry equal
// optical weight instead of the logo looking stranded next to it.
height: 25px;
object-fit: contain;
}
.home__body {
// Clears the fixed bar (~50px) plus a little breathing room.
padding-top: 58px;
padding-bottom: 90px;
}
// First-paint entrance: each section rises a little, scales up from just under
// full size and fades in, one beat after the one above it.
//
// The curve is a long decelerate with no overshoot — the thing that makes this
// read as "Apple" rather than as a web page animating is that it starts fast,
// settles slowly, and never bounces back. The travel is deliberately small
// (14px, 2% of scale): at first paint the content should look like it was
// always there and simply came into focus, not like it flew in.
.home__reveal {
opacity: 0;
transform: translate3d(0, 14px, 0) scale(0.98);
will-change: transform, opacity;
}
.is-revealed .home__reveal {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
}
// Respect a reduced-motion preference: show the page, skip the choreography.
@media (prefers-reduced-motion: reduce) {
.home__reveal {
opacity: 1;
transform: none;
}
.is-revealed .home__reveal {
transition: none;
}
}
.home__hero {
margin-top: 18px;
// Bleed past the container's 16px gutter so the side cards reach the screen
// edge and run off it, rather than stopping inside the text column.
margin-inline: -16px;
}
.home__section {
margin-top: 30px;
}
// Section titles in the design sit right-aligned with no "see all" affordance.
.home__heading {
margin: 0 0 14px;
font-size: 16px;
font-weight: 500;
color: @ink;
text-align: right;
}
.home__list {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 14px;
}
.home__empty {
margin-top: 28px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.home__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<!--
Ported from legacy henza_pwa/src/views/newest.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
See useProducts.ts for the pagination-simplification note.
Data wiring is untouched: the same useProducts() composable, the same
pull-to-fetch pagination, and the same ScrollFetchSearch infinite scroll.
Only the chrome and the card change — AppHeader -> PageBar, and the
Tailwind-utility product rows -> the shared ProductCard grid.
-->
<template>
<div class="newest">
<PageBar title="جدیدترین محصولات" back-to="home" />
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<ScrollFetchSearch
class="newest__scroll"
:loading="loadingPull"
:flag="productsData.flag"
padding="0px"
@fetch="pull"
>
<container class="newest__body">
<SectionHead title="تازه‌ها" subtitle="آخرین افزوده‌های مجموعه" />
<div v-if="productsData.productList.length" class="newest__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<!-- Previously this view rendered its heading over blank space when
the catalogue came back empty. Same empty state the other
catalogue screens use, chained to the results above it. -->
<section v-else-if="!loadingPage" class="newest__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
</container>
</ScrollFetchSearch>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import SectionHead from '../components/home/SectionHead.vue'
import ProductCard from '../components/product/ProductCard.vue'
import ScrollFetchSearch from '../components/ui/ScrollFetchSearch.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
// ── Incremental rendering ────────────────────────────────────────────────
// useProducts() accumulates the fetched list, and this port fetches the whole
// (small) catalogue rather than truly paging the server. Mounting every card
// at once puts hundreds of images on screen, so render a window and grow it
// each time ScrollFetchSearch reports the user reached the end — which is the
// same @fetch signal that used to drive pagination.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => productsData.productList.slice(0, visibleCount.value))
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
// Reveal more of what is already loaded first; only go back to the server
// once the whole fetched list is on screen.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
await getProducts(loadingPull, true)
}
</script>
<style scoped lang="less">
.newest {
background: @canvas;
min-height: 100vh;
}
.newest__scroll {
height: calc(100vh - 61px);
}
.newest__body {
padding-top: 76px;
padding-bottom: 90px;
}
.newest__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
// Same empty state the other catalogue screens draw.
.newest__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;
}
.newest__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
@@ -0,0 +1,100 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue, restyled
into the redesigned design language (see components/home/* and HomeView).
Filters the one flat product list client-side by collection id (route
param) — no server-side ciny_id query param exists; consistent with the
rest of this port's "small catalog, client-side filtering" approach. That
logic is carried over unchanged.
The view no longer owns a Drawer: ProductCard opens the product detail
itself, the same way it does on every other catalogue screen, so the
detail behaviour is identical everywhere instead of re-implemented here.
-->
<template>
<div class="coll">
<PageBar :title="collectionTitle" back-to="search" />
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<container class="coll__body">
<SectionHead title="محصولات این مجموعه" subtitle="برای دیدن جزئیات، روی محصول ضربه بزنید" />
<div v-if="collectionProducts.length" class="coll__grid">
<ProductCard v-for="item in collectionProducts" :key="item.id" :value="item" />
</div>
<section v-else-if="!loadingPage" class="coll__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>در این مجموعه محصولی ثبت نشده است.</p>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { api, type ProductSummary } from '../../api/client'
const route = useRoute()
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
// legacy passed the collection's title through as ?page=<title>; kept, with a
// neutral fallback so the bar is never blank on a direct link.
const collectionTitle = computed(() => (route.query.page as string) || 'مجموعه')
const collectionProducts = computed(() =>
allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)),
)
onMounted(async () => {
loadingPage.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loadingPage.value = false
}
})
</script>
<style scoped lang="less">
.coll {
background: @canvas;
min-height: 100vh;
}
.coll__body {
padding-top: 76px;
padding-bottom: 90px;
}
.coll__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.coll__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;
}
.coll__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
@@ -0,0 +1,372 @@
<!--
Ported from legacy henza_pwa/src/views/search/searchPage.vue, restyled into
the redesigned design language (see components/home/* and HomeView).
All filtering still happens client-side against the one flat product list
(no server-side query params exist for any of this — consistent with
NewestView's own "single fetch" adaptation from an earlier batch). The
filter logic, the Filters sheet, the category chips and the collection
tiles are all carried over unchanged; only the chrome and the cards change.
The collection tiles keep their original destination (/collection/:id with
the title in ?page=), and are shown above the results exactly as the legacy
ProductList did on this route.
-->
<template>
<div class="search">
<PageBar :title="showFilter ? 'فیلترها' : 'جستجو'" :back="showFilter" @click.capture="onBarClick">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
<div v-if="!showFilter" class="search__chips no-scrollbar">
<button
type="button"
class="search__chip search__chip--filter"
:class="{ 'is-on': filterKeys.length > 0 }"
@click="showFilters"
>
<Icon icon="vuesax-linear:filter" size="15" />
{{ filterKeys.length > 0 ? `${filterKeys.length} فیلتر` : 'فیلتر' }}
</button>
<button
type="button"
class="search__chip"
:class="{ 'is-on': !filters.query.cats }"
@click="setCats(undefined)"
>
همه
</button>
<button
v-for="item in availableCategories"
:key="item.id"
type="button"
class="search__chip"
:class="{ 'is-on': filters.query.cats && filters.query.cats[0] === item.id }"
@click="setCats(item.id)"
>
{{ item.title }}
</button>
</div>
</div>
</template>
</PageBar>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<Filters
v-model:filterKeys="filterKeys"
v-show="showFilter"
v-model="filters.query"
:products="allProducts"
@change="hideFilters"
/>
<PullRefresh v-show="!showFilter" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="search__body">
<div v-if="filteredProducts.length" class="search__grid">
<ProductCard v-for="item in visibleProducts" :key="item.id" :value="item" />
</div>
<!-- The catalogue is fetched in one request (no server-side paging
exists), but rendering all of it at once mounts hundreds of cards
and images on a phone. Render a window and grow it as the user
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<!-- Keyed off the result count, NOT chained to the sentinel above it.
As a `v-else-if` on `hasMore` this rendered whenever the window had
caught up with the list — i.e. at the end of every fully-rendered
result set — so "nothing found" sat under a screen full of
products. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { useNavStore } from '../../stores/navStore'
import { api, type ProductSummary } from '../../api/client'
const filters = useFiltersStore()
const nav = useNavStore()
const loadingPage = ref(false)
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([])
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
const q = filters.query
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
if (q.limit_gram.length === 2) {
list = list.filter((p) =>
p.variants.some(
(v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1],
),
)
}
return list
})
// ── Incremental rendering ────────────────────────────────────────────────
// The whole catalogue arrives in one request (no server-side paging exists),
// so the filtering above is instant — but mounting every matching card at once
// means hundreds of cards and images on a phone. Render a window instead and
// grow it when the sentinel at the end scrolls into view.
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const visibleProducts = computed(() => filteredProducts.value.slice(0, visibleCount.value))
const hasMore = computed(() => visibleCount.value < filteredProducts.value.length)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | undefined
function mountObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting) && hasMore.value) {
visibleCount.value += PAGE_SIZE
}
},
{ rootMargin: '400px' },
)
observer.observe(sentinel.value)
}
// Any change to the filters restarts the window from the top.
watch(filteredProducts, () => {
visibleCount.value = PAGE_SIZE
})
// The sentinel is v-if'd, so re-attach whenever it appears or is replaced.
watch(sentinel, mountObserver)
onBeforeUnmount(() => observer?.disconnect())
async function loadProducts() {
loadingPage.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts()
refreshing.value = false
}
function search(value: string | undefined) {
filters.query.text = value
}
function setCats(id: string | undefined) {
filters.query.cats = id ? [id] : undefined
}
function showFilters() {
showFilter.value = true
nav.setNavVisibility(false)
}
function hideFilters(event?: { resetFilters: boolean }) {
showFilter.value = false
nav.setNavVisibility(true)
if (event?.resetFilters) {
filters.reset()
filterKeys.value = []
}
}
// While the filter sheet is open the bar's back button closes the sheet
// rather than leaving the route, matching the legacy "بازگشت" affordance.
function onBarClick(e: MouseEvent) {
if (!showFilter.value) return
const btn = (e.target as HTMLElement)?.closest('button')
if (btn) {
e.stopPropagation()
hideFilters()
}
}
onMounted(loadProducts)
</script>
<style scoped lang="less">
.search {
background: @canvas;
min-height: 100vh;
}
.search__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 0;
}
.search__chips {
display: flex;
gap: 8px;
overflow-x: auto;
margin: 12px 0px 10px -16px;
// `padding` alone on an RTL flex scroller can get its edge padding
// collapsed by the browser once content overflows — explicit spacer
// elements hold the same 16px gap reliably at both scroll extremes.
scroll-snap-type: x proximity;
}
.search__chips::before,
.search__chips::after {
content: '';
flex: 0 0 16px;
}
// The mock's filter row: tall outlined pills, the active one filled solid
// @ink. Roomier than the previous chips so they read as the page's primary
// navigation rather than as dense metadata.
.search__chip {
scroll-snap-align: start;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 18px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.search__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.search__chip--filter.is-on {
background: @wine;
border-color: @wine;
}
.search__chip:active {
transform: scale(0.97);
}
// Clears the fixed bar: its own row, plus the search field and the chip row
// the `below` slot adds. Grew with them — the field is 48px and the chips 40px
// now, where they were 44 and ~32.
.search__body {
padding-top: 176px;
padding-bottom: 90px;
}
.search__body > *:first-child {
margin-top: 16px;
}
.search__collections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
margin-bottom: 22px;
}
.search__collection {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 10px;
border-radius: 14px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: @inkSoft;
background: @hairlineSoft;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.search__collection:active {
transform: scale(0.98);
}
.search__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.search__more {
display: flex;
justify-content: center;
padding: 18px 0 4px;
}
.search__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;
}
.search__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
@@ -0,0 +1,568 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue.
Legacy's original (820 lines) is a multi-role workbench mixing
plain-customer, sales-agent ("reagent") and "banker" (type=1, sub-account
switching) views. Kept: the real balance card, the nav grid to
Orders/Documents/Profile/Favorites, and the logout/login-nudge dialogs —
all backed by real data.
DROPPED as architecturally incompatible, not merely deferred: everything
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
customerChoice. Sales agents are AdminUser records with no customer-facing
session at all, and there is no `type` field on our Customer model, so the
data model this UI assumed no longer exists.
DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing)
— PreOrder has a DB table but no controller anywhere in apps/api.
REPLACED, not dropped: legacy's "گزارش حساب" called out to the external
Tahesab accounting service, which the first migration audit retired in
favour of this system's own internal ledger — the balance card below shows
that real internal position directly.
DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
rows). The new API has one single-karat quote endpoint, folded into the
bottom rate row rather than fabricating rows that don't exist.
Second visual pass — a deliberately different structure from the previous
"card + 4-cell grid" layout, while staying in the same visual language as
HomeView (same bar treatment, same reveal choreography, same @ink/@wine
palette — no gold/bronze; this app's identity is black + burgundy only):
1. a bare, logo-height bar (no title chrome), matching Home's bar rhythm
2. one full hero card: balance figure + identity, with quick actions
built INTO the card itself as an icon row on its own dark surface —
replacing the old "card, then a separate white 4-cell grid below it"
split
3. "اسناد" / "علاقمندی" as wide settings-style rows with a chevron,
replacing the grid tiles those two also occupied
4. the live rate folded into a single slim inline row at the foot of the
page instead of a titled section
Data, routes, dialogs and auth behaviour are all unchanged from before —
only markup structure and styling moved.
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__brand">میز کار</span>
<button
v-if="session.customerId"
type="button"
class="dash__exit"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="18" />
</button>
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="dash__body" :class="{ 'is-revealed': revealed }">
<!-- Signed in: real position from GET /customers/:id, quick actions
built into the same dark surface as the balance itself. -->
<section v-if="customer" class="dash__hero dash__reveal" :style="revealDelay(0)">
<div class="dash__heroTop">
<div class="dash__heroIdentity">
<span class="dash__heroName">{{ customer.fullName }}</span>
<span class="dash__heroMobile">{{ session.mobile }}</span>
</div>
<span class="dash__heroSeal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="dash__heroFigure">
<span class="dash__heroNum">{{ grams }}</span>
<span class="dash__heroUnit">گرم {{ remainText }}</span>
</div>
<div class="dash__heroDebt">
<span>بدهی ریالی</span>
<b>{{ price(customer.fiatDebtRial) }} <i>ریال</i></b>
</div>
</section>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite dash__reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="22" color="#fff" />
</div>
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Settings-style rows: every destination the hero no longer carries
as its own quick-action row. -->
<section class="dash__rows dash__reveal" :style="revealDelay(1)">
<button type="button" class="dash__row" @click="goToDetails(0)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:box" size="18" />
</span>
<span class="dash__rowText">
<b>سفارشات</b>
<small>پیگیری سفارش‌های شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
</button>
<button type="button" class="dash__row" @click="goToDetails(1)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="18" />
</span>
<span class="dash__rowText">
<b>اسناد</b>
<small>تراکنش‌ها و رسیدهای حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="18" />
</span>
<span class="dash__rowText">
<b>پروفایل</b>
<small>اطلاعات و تنظیمات حساب</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
</button>
<button type="button" class="dash__row" @click="goToDetails(3)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:heart" size="18" />
</span>
<span class="dash__rowText">
<b>علاقمندی</b>
<small>محصولات ذخیره‌شده شما</small>
</span>
<Icon icon="vuesax-linear:arrow-left-2" size="16" color="#8A909C" />
</button>
</section>
<!-- Live 18k rate — a single inline row, not a titled section. -->
<RateStrip :quote="goldPrice" class="dash__rate dash__reveal" :style="revealDelay(2)" />
</container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
// screen so the two never disagree.
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
const remainText = computed(() => (isDebtor.value ? 'بدهکار' : 'بستانکار'))
const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
const data = reactive({ notify: false, loginNotify: false })
function goToDetails(id: number) {
if (!session.customerId) {
data.loginNotify = true
return
}
if (id === 0) router.push({ name: 'ordersList' })
if (id === 1) router.push({ name: 'transactionsList' })
if (id === 2) router.push({ name: 'profileData' })
if (id === 3) router.push({ name: 'favoritesList' })
}
function logOut() {
session.logout()
data.notify = false
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
// First-paint entrance, mirroring HomeView's choreography exactly (module-scope
// flag so it only replays on a full app reload, not on every tab switch).
let hasRevealed = false
const revealed = ref(hasRevealed)
const REVEAL_STEP_MS = 70
function revealDelay(i: number) {
return { '--reveal-delay': `${i * REVEAL_STEP_MS}ms` }
}
onMounted(async () => {
await loadDashboard()
if (revealed.value) return
await nextTick()
requestAnimationFrame(() => {
revealed.value = true
hasRevealed = true
})
})
</script>
<style scoped lang="less">
.dash {
background: @canvas;
min-height: 100vh;
}
// A bare bar, sized like Home's, but with no border/title-bar chrome — the
// hero card below carries the page's identity instead of a header label.
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid fade(@primary, 5%);
box-shadow: 0 8px 20px -18px fade(@primary, 55%);
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__brand {
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__exit {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: 0;
cursor: pointer;
border-radius: 11px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
transition: transform 0.18s ease;
}
.dash__exit:active {
transform: scale(0.92);
}
.dash__body {
padding-top: 58px;
padding-bottom: 90px;
}
.dash__reveal {
opacity: 0;
transform: translate3d(0, 14px, 0) scale(0.98);
will-change: transform, opacity;
}
.is-revealed .dash__reveal {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms),
transform 0.62s cubic-bezier(0.32, 0.72, 0, 1) var(--reveal-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.dash__reveal {
opacity: 1;
transform: none;
}
.is-revealed .dash__reveal {
transition: none;
}
}
// ── Hero ────────────────────────────────────────────────────────────────
// One full surface: identity, balance and quick actions all on the same
// ink → wine gradient, instead of a dark card handing off to a separate
// white grid underneath it.
.dash__hero {
margin-top: 16px;
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @ink 0%, @ink 55%, @wineDeep 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
}
.dash__heroTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__heroIdentity {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.dash__heroName {
font-size: 13.5px;
font-weight: 800;
}
.dash__heroMobile {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
direction: ltr;
text-align: right;
}
.dash__heroSeal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__heroSeal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__heroSeal.is-creditor {
color: @ink;
background: rgba(255, 255, 255, 0.9);
}
.dash__heroFigure {
margin-top: 20px;
display: flex;
align-items: baseline;
gap: 8px;
}
.dash__heroNum {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.5px;
}
.dash__heroUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebt {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__heroDebt b {
font-size: 13px;
font-weight: 700;
color: #fff;
}
.dash__heroDebt i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
// ── Signed-out invite ──────────────────────────────────────────────────
.dash__invite {
margin-top: 16px;
padding: 26px 20px;
border-radius: 26px;
background: @ink;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
}
.dash__inviteIcon {
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.1);
margin-bottom: 2px;
}
.dash__invite h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.dash__invite p {
margin: 0;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.6);
max-width: 240px;
}
.dash__inviteCta {
margin-top: 6px;
padding: 10px 22px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @ink;
background: #fff;
transition: transform 0.18s ease;
}
.dash__inviteCta:active {
transform: scale(0.96);
}
// ── Settings-style rows ────────────────────────────────────────────────
.dash__rows {
margin-top: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.dash__row {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 14px;
border: 1px solid @hairline;
border-radius: 17px;
cursor: pointer;
background: @canvas;
font-family: inherit;
text-align: right;
transition: transform 0.18s ease, border-color 0.18s ease;
}
.dash__row:active {
transform: scale(0.985);
border-color: fade(@wine, 20%);
}
.dash__rowIcon {
flex: 0 0 auto;
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.dash__rowText b {
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.dash__rowText small {
font-size: 10.5px;
color: @inkFaint;
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 14px;
}
</style>
@@ -0,0 +1,110 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Real, not stubbed: favorites are the local favoritesStore (per-device, no
backend persistence — see favoritesStore.ts from an earlier batch), filtered
against the real product catalog client-side. No pagination — the whole
catalog is fetched once (same "small catalog" approach as everywhere else).
That logic is carried over unchanged; only the chrome and the card change.
-->
<template>
<div class="favs">
<PageBar title="علاقه‌مندی" back-to="userPage::hasSubRoute" />
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
<container class="favs__body">
<template v-if="favoriteProducts.length">
<SectionHead title="ذخیره‌شده‌ها" :subtitle="`${favoriteProducts.length} محصول`" />
<div class="favs__grid">
<ProductCard v-for="item in favoriteProducts" :key="item.id" :value="item" />
</div>
</template>
<section v-else-if="!loading" class="favs__empty">
<Icon icon="vuesax-linear:heart" size="26" />
<p>هنوز محصولی را به علاقه‌مندی‌ها اضافه نکرده‌اید.</p>
<button type="button" class="favs__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import ProductCard from '../../components/product/ProductCard.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
const router = useRouter()
const favorites = useFavoritesStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.favs {
background: @canvas;
min-height: 100vh;
}
.favs__body {
padding-top: 76px;
padding-bottom: 90px;
}
.favs__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.favs__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.favs__empty p {
margin: 0;
font-size: 12.5px;
}
.favs__emptyCta {
margin-top: 4px;
padding: 9px 18px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
</style>
@@ -0,0 +1,14 @@
<!-- Ported from legacy henza_pwa/src/views/user/LoginRegister/index.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import LoginView from './LoginView.vue'
import RegisterView from './RegisterView.vue'
const showRegister = ref(false)
const register = (state: boolean) => (showRegister.value = state)
</script>
<template>
<LoginView @register="register" v-if="!showRegister" />
<RegisterView @register="register" v-if="showRegister" />
</template>
@@ -0,0 +1,313 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Per the earlier decision: OTP-based flow ported structurally, but the
SMS-send step is stubbed — no SMS provider exists. What's real vs stubbed,
all carried over unchanged from the previous version:
- Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. A non-match shows legacy's own
"اطلاعات کاربری در سیستم وجود ندارد" text, not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
there is no real code to check against. On "verify", the session store
logs in as the matched real customer, so downstream features (balance,
ledger, favourites-as-logged-in) work against real seeded data.
- `token.fireBaseToken` doesn't exist in this port (Firebase deferred).
- `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
-->
<template>
<div class="auth">
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
<PageBar title="ورود" back-to="home" />
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<h2 class="auth__title">ورود به منتزر</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
</p>
</section>
<section class="auth__card">
<!-- Step 2: OTP -->
<template v-if="body.step === 2">
<OtpInput
class="auth__otp"
input-classes="otp-input"
inputType="tel"
:num-inputs="4"
:should-auto-focus="true"
:defaultFocus="body.defaultFocus"
@on-complete="sendToken"
/>
<p v-if="timer > 0" class="auth__timer">
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
</p>
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
</template>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
<FormItem
v-show="body.step === 1"
name="username"
label="تلفن همراه"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
>
<Input type="number" v-model:value="formModel.username" />
</FormItem>
<button
type="submit"
class="auth__cta"
:class="{ 'is-loading': body.loading }"
:disabled="body.loading || (timer > 0 && body.step === 2)"
>
{{ btnText }}
</button>
</Form>
</section>
<p class="auth__switch">
اگر ثبت نام نکرده‌اید
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
</p>
</container>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerSummary } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
const body = reactive({
step: 1,
loading: false,
defaultFocus: false,
})
let matchedCustomer: CustomerSummary | null = null
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
setTimeout(() => {
if (timer.value > 0) {
timer.value -= 1
countDownTimer()
}
}, 1000)
}
}
const sendPhone = async () => {
try {
body.loading = true
const customers = await api.listCustomers()
matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null
if (!matchedCustomer) {
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
notify.show = true
return
}
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e) {
console.log(e)
} finally {
body.loading = false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sendToken = async (_otpCode: string) => {
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
// check it against). `matchedCustomer` was already confirmed real in
// sendPhone(), so any 4-digit code here logs in as that customer.
try {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} finally {
body.loading = false
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style scoped lang="less">
.auth {
background: @canvas;
min-height: 100vh;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
}
.auth__intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
font-weight: 800;
color: @ink;
}
.auth__sub {
margin: 6px 0 0;
font-size: 12.5px;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.auth__otp {
display: flex;
justify-content: center;
gap: 10px;
direction: ltr;
padding-bottom: 6px;
}
.auth__timer {
margin: 12px 0 16px;
text-align: center;
font-size: 11.5px;
color: @inkMuted;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
}
.auth__cta {
width: 100%;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.auth__cta:active {
transform: scale(0.99);
}
.auth__cta:disabled {
opacity: 0.45;
cursor: default;
}
.auth__switch {
margin: 20px 0 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
.auth__switch button {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @wine;
}
</style>
<style lang="less">
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
be scoped to this component. */
.otp-input {
width: 48px;
height: 52px;
padding: 5px;
margin: 0;
font-size: 19px;
font-weight: 700;
color: @ink;
background: @canvas;
border: 1px solid @hairline;
border-radius: 13px;
text-align: center;
transition: border-color 0.18s ease;
}
.otp-input:focus {
outline: none;
border-color: @ink;
}
.otp-input.error {
border-color: @danger !important;
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
@@ -0,0 +1,51 @@
<!--
New nav destination (bottom nav redesign) — no legacy equivalent and no
notifications backend exists in apps/api. Renders the same dashed
empty-state pattern used elsewhere (see FavoritesView) rather than
fabricating fake notification data.
-->
<template>
<div class="notif">
<PageBar title="اعلان‌ها" back-to="home" />
<container class="notif__body">
<section class="notif__empty">
<Icon icon="vuesax-linear:notification" size="26" />
<p>اعلانی برای نمایش وجود ندارد.</p>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
</script>
<style scoped lang="less">
.notif {
background: @canvas;
min-height: 100vh;
}
.notif__body {
padding-top: 76px;
padding-bottom: 90px;
}
.notif__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.notif__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
@@ -0,0 +1,484 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Adapted (unchanged from the previous version):
- Status filter chips use our real OrderStatus enum values directly (see
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
They are chips now instead of ant Tabs, which lets them scroll
horizontally like every other filter rail in the redesign.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by chip (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
DROPPED, unchanged: "دریافت فاکتور" — legacy's handler opened a hardcoded
link to the real legacy production domain. Not merely unimplemented; an
external production URL that must never be carried into this port. No
invoice-generation endpoint exists in the new API either.
The detail view is a bottom Drawer rather than a full-screen ant Modal with
hand-written overrides, matching how every other detail surface in the
redesign opens (product detail, cart note, ledger entry).
-->
<template>
<div class="ord">
<PageBar title="سفارشات" back-to="home">
<template #below>
<div class="ord__barExtra">
<SearchInput @search="readSearch" place-holder="جستجوی شماره سفارش" />
<div class="ord__chips no-scrollbar">
<button
v-for="item in tabs"
:key="item.key"
type="button"
class="ord__chip"
:class="{ 'is-on': activeTab === item.key }"
@click="activeTab = item.key"
>
{{ item.title }}
</button>
</div>
</div>
</template>
</PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="filteredOrders.length">
<div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
</div>
</template>
<section v-else-if="!loading" class="ord__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</Spinner>
<Drawer
v-model:visible="viewData.model"
placement="bottom"
v-if="viewData.item"
:closable="false"
height="100vh"
class="rtl"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<div class="detail">
<header class="detail__bar">
<button type="button" class="detail__close" aria-label="بستن" @click="viewData.model = false">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<h2 class="detail__title">جزئیات سفارش</h2>
<span class="detail__spacer" aria-hidden="true" />
</header>
<div class="detail__scroll">
<!-- Progress -->
<section class="detail__block">
<div class="detail__steps">
<div
v-for="(step, index) in ORDER_STEPS"
:key="step"
class="detail__step"
:class="{ 'is-done': index <= currentStep, 'is-active': index === currentStep }"
>
<span class="detail__stepDot">{{ index + 1 }}</span>
<span class="detail__stepTitle">{{ step }}</span>
</div>
</div>
</section>
<!-- Headline facts -->
<section class="detail__block">
<div class="detail__headline">
<div>
<div class="detail__eyebrow">شماره سفارش</div>
<div class="detail__number">{{ viewData.item.orderNumber }}</div>
</div>
<span
class="detail__status"
:style="{ background: modalColor?.background, color: modalColor?.font }"
>
{{ modalStatusLabel }}
</span>
</div>
<dl class="detail__specs">
<div class="detail__spec">
<dt>تعداد کالاها</dt>
<dd>{{ viewData.item.items.length }} آیتم</dd>
</div>
<div class="detail__spec">
<dt>وزن سفارش</dt>
<dd>{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }} گرم</dd>
</div>
<div class="detail__spec">
<dt>تاریخ سفارش</dt>
<dd>{{ jdate(viewData.item.createdAt) }}</dd>
</div>
</dl>
</section>
<!-- Items -->
<section class="detail__block">
<h3 class="detail__blockTitle">آیتم‌های سفارش</h3>
<div class="detail__items">
<OrderItemCard v-for="item in viewData.item.items" :key="item.id" :value="item" />
</div>
</section>
</div>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import {
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
ORDER_STEPS,
orderStepIndex,
} from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
const session = useSessionStore()
const tabs = [
{ title: 'همه', key: 'ALL' },
{ title: ORDER_STATUS_LABELS.SUSPENDED, key: 'SUSPENDED' },
{ title: ORDER_STATUS_LABELS.PLACED, key: 'PLACED' },
{ title: ORDER_STATUS_LABELS.ADMIN_APPROVED, key: 'ADMIN_APPROVED' },
{ title: ORDER_STATUS_LABELS.SOURCING, key: 'SOURCING' },
{ title: ORDER_STATUS_LABELS.PREPARING, key: 'PREPARING' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_AGENT, key: 'DELIVERED_TO_AGENT' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_CUSTOMER, key: 'DELIVERED_TO_CUSTOMER' },
{ title: ORDER_STATUS_LABELS.CANCELLED, key: 'CANCELLED' },
]
const activeTab = ref('ALL')
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.customerId) return
loading.value = true
try {
allOrders.value = await api.getCustomerOrders(session.customerId)
} finally {
loading.value = false
}
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const modalStatusLabel = computed(() =>
viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : '',
)
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
function openDetail(item: OrderDetail) {
viewData.item = item
viewData.model = true
}
async function onRefresh() {
await loadOrders()
refreshing.value = false
}
onMounted(loadOrders)
</script>
<style scoped lang="less">
.ord {
background: @canvas;
min-height: 100vh;
}
.ord__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 0;
}
.ord__chips {
display: flex;
gap: 8px;
overflow-x: auto;
margin: 8px 0px 8px -16px;
scroll-snap-type: x proximity;
}
.ord__chip {
scroll-snap-align: start;
flex: 0 0 auto;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.ord__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.ord__chip:active {
transform: scale(0.97);
}
.ord__body {
padding-top: 160px;
padding-bottom: 90px;
}
.ord__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
.ord__empty {
margin-top: 8px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.ord__empty p {
margin: 0;
font-size: 12.5px;
}
/* ── Detail drawer ─────────────────────────────────────────────────── */
.detail {
height: 100vh;
display: flex;
flex-direction: column;
background: @canvas;
}
.detail__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 16px;
border-bottom: 1px solid @hairline;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
}
.detail__title {
margin: 0;
font-size: 14px;
font-weight: 800;
color: @ink;
}
.detail__close {
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
}
.detail__spacer {
display: block;
width: 38px;
height: 38px;
}
.detail__scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
padding-bottom: 40px;
}
.detail__block {
margin-bottom: 22px;
}
.detail__blockTitle {
margin: 0 0 11px;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.detail__steps {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 4px;
}
.detail__step {
flex: 1 0 74px;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
text-align: center;
position: relative;
}
.detail__stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11.5px;
font-weight: 700;
color: @inkFaint;
background: @hairlineSoft;
border: 1px solid @hairline;
z-index: 1;
}
.detail__step.is-done .detail__stepDot {
color: #fff;
background: @ink;
border-color: @ink;
}
.detail__step.is-active .detail__stepDot {
color: #fff;
background: @wine;
border-color: @wine;
}
.detail__stepTitle {
font-size: 10px;
line-height: 1.5;
color: @inkFaint;
}
.detail__step.is-done .detail__stepTitle {
color: @inkSoft;
font-weight: 600;
}
.detail__headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
}
.detail__eyebrow {
font-size: 11px;
color: @inkFaint;
}
.detail__number {
padding-top: 4px;
font-size: 18px;
font-weight: 800;
color: @ink;
}
.detail__status {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
}
.detail__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.detail__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.detail__spec:last-child {
border-bottom: 0;
}
.detail__spec dt {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.detail__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.detail__items {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>
@@ -0,0 +1,216 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Heavily simplified when first ported, and still is — legacy's original (971
lines, the largest view in the app) is a full edit form (bank info, KYC
document uploads, sub-user management for banker accounts). None of that has
a backend to back it: there's no update-customer endpoint, no
document-upload endpoint, and (as in DashboardView) no banker/sub-account
concept in the new Customer model at all. Kept: the real read-only info
card, all backed by fields the API genuinely returns.
DROPPED, unchanged from the previous version: "تکمیل پروفایل", the
"افزودن کاربر" sub-user FAB, and the "کارشناس فروش" contact card — that last
one needs the referring AdminUser's name/mobile, which GET /customers/:id
does not expose (only referredByAgentId, a bare id).
-->
<template>
<div class="prof">
<PageBar title="اطلاعات کاربری" back-to="userPage::hasSubRoute" />
<container class="prof__body">
<!-- Identity -->
<section class="prof__identity">
<div class="prof__avatar" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="30" />
</div>
<h2 class="prof__name">{{ customer?.fullName ?? '—' }}</h2>
<p v-if="customer" class="prof__mobile">{{ customer.mobile }}</p>
<span v-if="customer" class="prof__seal" :class="isVerified ? 'is-ok' : 'is-pending'">
{{ VERIFICATION_LABELS[customer.verificationStep] ?? customer.verificationStep }}
</span>
</section>
<!-- Real, read-only fields -->
<section v-if="customer" class="prof__block">
<SectionHead title="مشخصات" subtitle="اطلاعات ثبت‌شده حساب شما" />
<dl class="prof__specs">
<div class="prof__spec">
<dt>نام و نام خانوادگی</dt>
<dd>{{ customer.fullName }}</dd>
</div>
<div class="prof__spec">
<dt>تلفن همراه</dt>
<dd>{{ customer.mobile }}</dd>
</div>
<div class="prof__spec">
<dt>کد ملی</dt>
<dd>{{ customer.nationalCode }}</dd>
</div>
<div class="prof__spec">
<dt>وضعیت احراز هویت</dt>
<dd>{{ VERIFICATION_LABELS[customer.verificationStep] ?? customer.verificationStep }}</dd>
</div>
</dl>
</section>
<section v-else-if="!loading" class="prof__empty">
<Icon icon="vuesax-linear:user" size="26" />
<p>اطلاعات حساب در دسترس نیست.</p>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail } from '../../api/client'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
STEP_ONE_SUBMITTED: 'مرحله اول ثبت شده',
STEP_TWO_SUBMITTED: 'مرحله دوم ثبت شده',
VERIFIED: 'تایید شده',
}
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
onMounted(async () => {
if (!session.customerId) return
loading.value = true
try {
customer.value = await api.getCustomer(session.customerId)
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.prof {
background: @canvas;
min-height: 100vh;
}
.prof__body {
padding-top: 80px;
padding-bottom: 90px;
}
.prof__identity {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 26px 20px 28px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.prof__avatar {
width: 72px;
height: 72px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.prof__name {
margin: 14px 0 0;
font-size: 15.5px;
font-weight: 800;
color: @ink;
}
.prof__mobile {
margin: 6px 0 0;
font-size: 12.5px;
color: @inkMuted;
direction: ltr;
}
.prof__seal {
margin-top: 12px;
padding: 5px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.prof__seal.is-ok {
color: @green;
background: #f0fdf4;
}
.prof__seal.is-pending {
color: @wine;
background: @wineWash;
}
.prof__block {
margin-top: 26px;
}
.prof__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
}
.prof__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 13px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.prof__spec:last-child {
border-bottom: 0;
}
.prof__spec dt {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.prof__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
text-align: left;
}
.prof__empty {
margin-top: 26px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.prof__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
@@ -0,0 +1,218 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
DROPPED as confirmed-dead code (verified unreachable, not merely "possibly
dead"): legacy rendered a whole cascading country->province->city picker
(WorkActionSheet + selectCityArrays + getCities() against POST /cities/list)
alongside a plain "شهر" text <Input>. The only element that ever opened the
picker (`#citybtn`) was itself commented out in legacy's own template, so the
picker was unreachable UI even in the original app; the visible form uses the
plain text input. That whole complex, and the parallel unused
`userFormModel`/`defaultUserFormModel` objects, are not ported.
STUBBED, not real, and unchanged from the previous version: legacy's
POST /major/register — apps/api has no create-customer endpoint. Submitting
shows legacy's exact success message without persisting anything. The copy
says the account awaits review, which is what legacy said too, so the screen
does not claim an account exists when none was created.
-->
<template>
<div class="auth">
<PageBar title="ثبت نام" back-to="home" />
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:user-add" size="28" />
</div>
<h2 class="auth__title">ساخت حساب کاربری</h2>
<p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p>
</section>
<section class="auth__card">
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
<FormItem name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" />
</FormItem>
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
</FormItem>
<FormItem
name="mobile"
label="شماره موبایل"
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
>
<Input v-model:value="formModel.mobile" type="number" />
</FormItem>
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" />
</FormItem>
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input v-model:value="formModel.phone" type="number" />
</FormItem>
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" :rows="3" />
</FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" />
</FormItem>
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
</Form>
</section>
<p class="auth__switch">
اگر حساب کاربری دارید
<button type="button" @click="emit('register', false)">وارد شوید</button>
</p>
</container>
</Spinner>
<DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false">
<template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-5">
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
</div>
</template>
<template v-slot:button>
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const profileData = reactive({ loading: false })
const registeredInfo = ref(false)
const formModel = reactive({
fname: undefined as string | undefined,
lname: undefined as string | undefined,
mobile: undefined as string | undefined,
store_name: undefined as string | undefined,
phone: undefined as string | undefined,
address: undefined as string | undefined,
city: undefined as string | undefined,
})
const register = async () => {
try {
profileData.loading = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
registeredInfo.value = true
} finally {
profileData.loading = false
}
}
</script>
<style scoped lang="less">
.auth {
background: @canvas;
min-height: 100vh;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
}
.auth__intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
font-weight: 800;
color: @ink;
}
.auth__sub {
margin: 6px 0 0;
font-size: 12.5px;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px 20px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
}
.auth__cta {
width: 100%;
margin-top: 4px;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
transition: transform 0.15s ease;
}
.auth__cta:active {
transform: scale(0.99);
}
.auth__switch {
margin: 20px 0 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
.auth__switch button {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @wine;
}
</style>
@@ -0,0 +1,342 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents), restyled into the redesigned design
language (see components/home/* and HomeView).
Data source: legacy listed `Cash` rows from POST /get-my-findoc. The new
system's equivalent is LedgerEntry, already exposed by
GET /customers/:id/ledger-entries, so the list is real data.
Legacy's colour/icon logic is preserved in meaning: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → a green
"in" treatment for everything else, red "out" for orders. That maps 1:1 onto
our CREDIT/DEBIT direction, since an ORDER_STOCK entry is exactly the DEBIT
case. The palette is now the design system's own rather than hardcoded hexes.
DROPPED, unchanged from the previous version: the "دریافت فایل" per-document
download — legacy's getFile() hit the old Laravel host; there is no
document-file endpoint in the new API.
-->
<template>
<div class="tx">
<PageBar title="اسناد" back-to="userPage::hasSubRoute" />
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<container class="tx__body">
<template v-if="entries.length">
<SectionHead title="گردش حساب" :subtitle="`${entries.length} سند`" />
<div class="tx__list">
<article v-for="item in entries" :key="item.id" class="tx__row" @click="openEntry(item)">
<span class="tx__mark" :class="isCredit(item) ? 'is-in' : 'is-out'" aria-hidden="true">
<Icon :icon="isCredit(item) ? 'vuesax-linear:arrow-down' : 'vuesax-linear:arrow-up'" size="16" />
</span>
<div class="tx__main">
<div class="tx__head">
<h3 class="tx__kind">{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}</h3>
<span class="tx__amount" :class="isCredit(item) ? 'is-in' : 'is-out'">
{{ formatAmount(item) }}
</span>
</div>
<p v-if="item.note" class="tx__note">{{ item.note }}</p>
<div class="tx__foot">
<span class="tx__date">{{ jdate(item.createdAt) }}</span>
<span class="tx__state" :class="{ 'is-pending': !item.confirmed }">
{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}
</span>
</div>
</div>
</article>
</div>
</template>
<section v-else-if="!loading" class="tx__empty">
<Icon icon="vuesax-linear:receipt-text" size="26" />
<p>سندی برای حساب شما ثبت نشده است.</p>
</section>
</container>
</Spinner>
<Drawer
v-model:visible="showPopUp"
placement="bottom"
v-if="activeEntry"
:closable="false"
height="420px"
class="rtl"
:bodyStyle="{ padding: 0 }"
>
<div class="sheet">
<h3 class="sheet__title">جزئیات سند</h3>
<dl class="sheet__specs">
<div class="sheet__spec">
<dt>نوع سند</dt>
<dd>{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</dd>
</div>
<div class="sheet__spec">
<dt>مبلغ</dt>
<dd :class="isCredit(activeEntry) ? 'is-in' : 'is-out'">{{ formatAmount(activeEntry) }}</dd>
</div>
<div class="sheet__spec">
<dt>وضعیت</dt>
<dd>{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</dd>
</div>
<div class="sheet__spec">
<dt>تاریخ</dt>
<dd>{{ jdate(activeEntry.createdAt) }}</dd>
</div>
<div class="sheet__spec">
<dt>توضیحات</dt>
<dd>{{ activeEntry.note || '—' }}</dd>
</div>
</dl>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساخت',
REPAYMENT_MELTED_GOLD: 'طلای آب‌شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'پاداش',
ADJUSTMENT: 'اصلاحیه',
}
const session = useSessionStore()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
// entries are exactly that case.
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT'
const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
const openEntry = (item: LedgerEntrySummary) => {
activeEntry.value = item
showPopUp.value = true
}
onMounted(async () => {
if (!session.customerId) return
loading.value = true
try {
entries.value = await api.getLedgerEntries(session.customerId)
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.tx {
background: @canvas;
min-height: 100vh;
}
.tx__body {
padding-top: 76px;
padding-bottom: 90px;
}
.tx__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tx__row {
display: flex;
gap: 11px;
padding: 12px;
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
transition: transform 0.18s ease;
}
.tx__row:active {
transform: scale(0.99);
}
.tx__mark {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
}
.tx__mark.is-in {
color: @green;
background: #f0fdf4;
}
.tx__mark.is-out {
color: @wine;
background: @wineWash;
}
.tx__main {
flex: 1;
min-width: 0;
}
.tx__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.tx__kind {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx__amount {
font-size: 12.5px;
font-weight: 800;
white-space: nowrap;
}
.tx__amount.is-in {
color: @green;
}
.tx__amount.is-out {
color: @wine;
}
.tx__note {
margin: 6px 0 0;
font-size: 11.5px;
line-height: 1.7;
color: @inkMuted;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tx__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 8px;
}
.tx__date {
font-size: 11px;
color: @inkFaint;
}
.tx__state {
font-size: 10.5px;
font-weight: 700;
color: @inkMuted;
}
.tx__state.is-pending {
color: @notify2;
}
.tx__empty {
margin-top: 8px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.tx__empty p {
margin: 0;
font-size: 12.5px;
}
.sheet {
padding: 18px 16px 22px;
}
.sheet__title {
margin: 0 0 14px;
font-size: 13.5px;
font-weight: 800;
color: @ink;
}
.sheet__specs {
margin: 0;
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.sheet__spec {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid @hairlineSoft;
}
.sheet__spec:last-child {
border-bottom: 0;
}
.sheet__spec dt {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.sheet__spec dd {
margin: 0;
font-size: 12.5px;
font-weight: 700;
color: @ink;
text-align: left;
}
.sheet__spec dd.is-in {
color: @green;
}
.sheet__spec dd.is-out {
color: @wine;
}
</style>