Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
+439
View File
@@ -0,0 +1,439 @@
<!--
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="reveal"
: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-[color:var(--c-primary)]" @click="submitOrder">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @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-[color:var(--c-primary)]" @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 { 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 { useReveal } from '../composables/useReveal'
import { api } from '../api/client'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
const router = useRouter()
const cart = useCartStore()
const session = useSessionStore()
const loginNotify = ref(false)
const refreshing = ref(false)
// First-paint entrance for the basket rows, the same choreography HomeView
// uses with a slightly shorter beat, since a basket can hold many rows.
const { revealed, revealDelay, reveal } = useReveal('cart', 60)
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
// Gate on the token, not on a remembered id: the id alone is no longer a
// credential, and POST /orders now requires a customer JWT (SEC-02).
if (!session.isLoggedIn) {
loginNotify.value = true
return
}
try {
cartData.loadingPage = true
const order = await api.createOrder({
// Ignored by the server, which takes the customer from the token; still
// sent so older builds keep validating. Removed in Phase 2.
customerId: session.customerId ?? undefined,
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
productVariantId: item.vid,
quantity: item.qty,
// Sent for older API builds only: the server prices app orders itself
// (MON-08) and ignores this figure.
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
}
}),
})
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
// The order shows up in the list and the balance, and may have taken stock.
useQueryCache().invalidate(KEYS.orders, KEYS.balance, KEYS.profile, KEYS.ledger)
useCatalogStore().invalidate()
} catch (e) {
console.log(e)
} finally {
cartData.loadingPage = false
}
}
async function onRefresh() {
await cart.init(true)
refreshing.value = false
}
onMounted(async () => {
await cart.init()
reveal()
})
</script>
<style scoped lang="less">
.cart {
background: @page;
min-height: 100vh;
}
.cart__body {
padding-top: 76px;
padding-bottom: 150px;
}
.cart__items {
display: flex;
flex-direction: column;
gap: 11px;
padding-top: 16px;
}
.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: @primary;
}
.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: @primary;
}
.cart__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: alpha(@canvas, 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: @primary;
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: @primary;
}
</style>
+396
View File
@@ -0,0 +1,396 @@
<!--
Redesigned home screen (replaces the previous port of legacy home/index.vue).
Layout intent — shop first, account second:
1. a slim brand bar with search + cart
2. the live 18k rate strip (real: GET /pricing/latest)
3. an editorial showcase hero built from real categories
4. the category rail (real: derived from GET /catalog/products)
5. "برگزیده" — a wide feature card + a two-column grid
6. "تازه‌ها" — a horizontal rail
7. the account block (real: GET /customers/:id when signed in)
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.
- 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">
<router-link :to="{ name: 'cart' }" class="home__iconBtn" aria-label="سبد خرید">
<Icon icon="vuesax-linear:bag-2" size="21" />
<span v-if="cart.items.length" class="home__badge">{{ cart.items.length }}</span>
</router-link>
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
<router-link :to="{ name: 'search' }" class="home__iconBtn" aria-label="جستجو">
<Icon icon="vuesax-linear:search-normal-1" size="21" />
</router-link>
</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 }">
<RateStrip :quote="goldPrice" class="home__rate reveal" :style="revealDelay(0)" />
<ShowcaseSlider
v-if="slides.length"
:slides="slides"
class="home__hero reveal"
:style="revealDelay(1)"
/>
<CategoryRail
v-if="memory.cats.length"
:categories="memory.cats"
:active-id="null"
class="home__rail reveal"
:style="revealDelay(2)"
@select="onCategory"
/>
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
<SectionHead
:title="`برگزیده ${brand.name}`"
subtitle="انتخاب‌شده از میان مجموعه‌های موجود"
action-label="همه محصولات"
@action="goSearch"
/>
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" />
<div v-if="gridProducts.length" class="home__grid">
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" />
</div>
</section>
<section v-if="newestProducts.length" class="home__section reveal" :style="revealDelay(4)">
<SectionHead
title="تازه‌ها"
subtitle="آخرین افزوده‌های مجموعه"
action-label="مشاهده همه"
@action="goNewest"
/>
<div class="home__reel no-scrollbar">
<ShowcaseCard
v-for="item in newestProducts"
:key="item.id"
:value="item"
class="home__reelItem"
@open="openProduct"
/>
</div>
</section>
<section v-if="!loading && !products.length" class="home__empty reveal" :style="revealDelay(3)">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section>
<div class="home__trust reveal" :style="revealDelay(5)">
<div class="home__trustItem">
<Icon icon="vuesax-linear:shield-tick" size="18" />
<span>خرید امن</span>
</div>
<div class="home__trustItem">
<Icon icon="vuesax-linear:verify" size="18" />
<span>اصالت کالا</span>
</div>
<div class="home__trustItem">
<Icon icon="vuesax-linear:box-tick" size="18" />
<span>ارسال بیمه‌شده</span>
</div>
</div>
</container>
</PullRefresh>
</div>
</template>
<script setup lang="ts">
import { brand } from '../brand'
import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
import { openProduct as openProductMorph } from '../composables/useProductMorph'
import RateStrip from '../components/home/RateStrip.vue'
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
import CategoryRail from '../components/home/CategoryRail.vue'
import ShowcaseCard from '../components/home/ShowcaseCard.vue'
import SectionHead from '../components/home/SectionHead.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useReveal } from '../composables/useReveal'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const catalog = useCatalogStore()
const reads = useCachedReads()
const loading = ref(!catalog.loaded)
const refreshing = ref(false)
const products = computed(() => catalog.products)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeSlider) ?? null)
// First-paint entrance: played once, after the first data load, so the
// sections settle into place instead of popping in piecemeal. Pull-to-refresh
// and later returns to this tab do not replay it.
const { revealed, revealDelay, reveal } = useReveal('home')
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
const gridProducts = computed(() => products.value.slice(1, 5))
const newestProducts = computed(() => products.value.slice(5, 13))
// The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
// from categories and filled with product photos, so a newly added product
// could land in the hero. Slides are display-only: tapping one does nothing.
const slides = computed<ShowcaseSlide[]>(() =>
(adminSlides.value ?? [])
.filter((s) => s.image)
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
)
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
// App.vue) — the card is found from the tap, so the emit chain stays as is.
function openProduct(product: ProductSummary) {
openProductMorph(product)
}
function onCategory(cat: HomeCategory) {
filters.query.cats = [cat.id]
router.push({ name: 'search' })
}
function goSearch() {
router.push({ name: 'search' })
}
function goNewest() {
router.push({ name: 'newestProduct' })
}
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
// The home rails read `catalog.products`, so the catalogue is loaded
// explicitly. It used to come in as a side effect of `memory.init()`, which
// now reads the small facets endpoint instead of the whole catalogue — so
// without this call the home screen would render empty rails.
//
// Still the unpaged list: the rails slice it locally («جدیدترین», per
// category). Moving them onto paged reads is step 4 of
// docs/CATALOG-PAGINATION-PLAN.md.
catalog.load(force),
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
])
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
if (catsResult.status === 'rejected') console.log(catsResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
// No admin slides configured, or the endpoint failed — the category-derived
// hero below covers this silently, same as before this existed.
adminSlides.value = slidesResult.status === 'fulfilled' ? slidesResult.value : null
}
async function onRefresh() {
await loadData(true)
refreshing.value = false
}
onMounted(async () => {
await loadData()
loading.value = false
reveal()
})
/**
* Owner ruling 2026-10-12: re-read on entering a screen rather than caching
* with a lifetime. This view is kept alive, so without this the gold price was
* fetched once per app launch and then never again until pull-to-refresh — on
* the screen that shows it most prominently.
*
* Only the PRICE is re-read, not the catalogue or the slides: the catalogue is
* the whole active product list in one response, and re-fetching it on every
* tab switch is exactly the needless load the ruling is meant to avoid. It
* changes when an admin edits it, not minute to minute, and pull-to-refresh and
* placing an order both already invalidate it.
*/
let activatedOnce = false
onActivated(async () => {
if (!activatedOnce) {
activatedOnce = true
return
}
try {
goldPrice.value = await reads.goldPrice({ force: true })
} catch (e) {
// Keep the figure already on screen rather than blanking it.
console.log(e)
}
})
</script>
<style scoped lang="less">
.home {
background: @page;
min-height: 100vh;
}
.home__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.home__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.home__logo {
height: 21px;
object-fit: contain;
}
.home__iconBtn {
position: relative;
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
transition: background 0.18s ease;
}
.home__badge {
position: absolute;
top: -3px;
left: -3px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @wine;
border: 2px solid @canvas;
}
.home__body {
padding-top: 62px;
padding-bottom: 90px;
}
.home__rate {
margin-top: 14px;
}
.home__hero {
margin-top: 16px;
}
.home__rail {
margin-top: 18px;
}
.home__section {
margin-top: 28px;
}
.home__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
margin-top: 11px;
}
.home__reel {
display: flex;
gap: 11px;
overflow-x: auto;
margin: 0 -16px;
padding: 0 16px;
scroll-snap-type: x proximity;
}
.home__reelItem {
flex: 0 0 154px;
scroll-snap-align: start;
}
.home__empty {
margin-top: 28px;
padding: 34px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.home__empty p {
margin: 0;
font-size: 12.5px;
}
.home__trust {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
margin-top: 26px;
}
.home__trustItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 14px 6px;
border-radius: 14px;
background: @hairlineSoft;
color: @inkMuted;
font-size: 11px;
font-weight: 600;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<!--
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>
</container>
</ScrollFetchSearch>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from '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. Reaching the end is how this
// page asks for new products: the cached catalogue is refreshed.
if (visibleCount.value < productsData.productList.length) {
visibleCount.value += PAGE_SIZE
return
}
if (loadingPull.value) return
await getProducts(loadingPull, false, true)
}
</script>
<style scoped lang="less">
.newest {
background: @page;
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: 11px;
}
</style>
@@ -0,0 +1,419 @@
<!--
"سفارش مشتریان" — the sales expert's referred customers' orders.
Ported from legacy henza_pwa/src/views/user/src/components/ChildOrders.vue,
restyled into this app's design language (PageBar + SearchInput + cards, as
OrdersView uses). The LOGIC is the reference's and is identical in
only the styling differs from the reference.
Kept from the reference:
- the title "سفارشات مشتریان" (ChildOrders.vue:18)
- the search box over the list (:26)
- infinite paging: one page per scroll to the bottom (:327-338)
- per card: "سفارش : ORD-{id}", the item count, the total weight in grams,
the customer name, and the fixed status chip «تحویل مسئول فروش» — the
reference hardcodes that label (:48) because the endpoint only ever
returns orders at that status (`where('sts', 5)`, ApiMobileController:766)
- the card is tappable, as the reference's was (:29)
Deliberately NOT ported: the reference's edit-order drawer behind that tap
(toEditOrder -> PUT /order/child/{id}) recalculated an order's weights and
prices from values the CLIENT sent. Bringing that across during a delivery
would put a money path in the app with no server-side recomputation; the tap
opens a read-only summary instead. Recorded in D21/DELIVERY.md.
Scope is the server's: every row belongs to a customer this expert referred.
No id travels from the client.
-->
<template>
<div class="cho">
<PageBar title="سفارشات مشتریان" back-to="userPage::hasSubRoute">
<template #below>
<div class="cho__barExtra">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<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="cho__body">
<template v-if="rows.length">
<div class="cho__list">
<article
v-for="item in rows"
:key="item.id"
class="cho__card"
@click="openDetail(item)"
>
<div class="cho__top">
<div class="cho__left">
<div class="cho__num">
<span class="cho__muted">سفارش :</span>
{{ item.orderNumber }}
</div>
<div class="cho__meta">
<span class="cho__strong">{{ item.items?.length ?? 0 }}</span>
<span class="cho__unit">آیتم</span>
</div>
</div>
<div class="cho__right">
<span class="cho__status">تحویل مسئول فروش</span>
<div class="cho__meta">
<span class="cho__strong">{{ grams(item.w) }}</span>
<span class="cho__unit">گرم</span>
</div>
</div>
</div>
<div class="cho__divider" />
<div class="cho__bottom">
<div class="cho__customer">
نام مشتری : {{ item.customer?.name ?? '—' }}
</div>
<Icon icon="vuesax-linear:arrow-left-1" size="20" />
</div>
</article>
</div>
<div v-if="loading" class="cho__more">در حال دریافت…</div>
</template>
<section v-else-if="!loading" class="cho__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>{{ search ? 'سفارشی با این مشخصات پیدا نشد.' : 'هنوز سفارشی ثبت نشده است.' }}</p>
</section>
<section v-if="failed" class="cho__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="cho__retry" @click="reload">تلاش دوباره</button>
</section>
<!-- Sentinel for the infinite scroll, as the reference used (:216). -->
<div ref="sentinel" class="cho__sentinel" />
</container>
</PullRefresh>
</Spinner>
<Drawer
v-model:open="detailOpen"
placement="bottom"
height="auto"
:closable="false"
:body-style="{ padding: '0' }"
>
<div v-if="active" class="cho__detail">
<h2 class="cho__detailTitle">{{ active.orderNumber }}</h2>
<dl class="cho__rows">
<div class="cho__row">
<dt>نام مشتری</dt>
<dd>{{ active.customer?.name ?? '—' }}</dd>
</div>
<div class="cho__row">
<dt>شماره تماس</dt>
<dd dir="ltr">{{ active.customer?.mobile ?? '—' }}</dd>
</div>
<div class="cho__row">
<dt>وزن کل</dt>
<dd dir="ltr">{{ grams(active.w) }} گرم</dd>
</div>
<div class="cho__row">
<dt>تعداد آیتم‌ها</dt>
<dd>{{ active.items?.length ?? 0 }}</dd>
</div>
<div class="cho__row">
<dt>تاریخ ثبت</dt>
<dd>{{ jdate(active.createdAt) }}</dd>
</div>
<div class="cho__row">
<dt>وضعیت</dt>
<dd>تحویل مسئول فروش</dd>
</div>
</dl>
<button type="button" class="cho__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, 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 { jdate } from '../../utils/filters'
import { api, type ExpertChildOrder } from '../../api/client'
const rows = ref<ExpertChildOrder[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const search = ref<string | null>(null)
const page = ref(1)
/** Cleared when a page comes back short, so scrolling stops asking. */
const hasMore = ref(true)
const detailOpen = ref(false)
const active = ref<ExpertChildOrder | null>(null)
/** Weights arrive as decimal strings; shown to 3 places, as the reference did. */
function grams(value: string | number | null | undefined): string {
const n = Number(value ?? 0)
return Number.isFinite(n) ? n.toFixed(3) : '0.000'
}
async function fetchPage() {
if (loading.value || !hasMore.value) return
loading.value = true
failed.value = false
try {
const data = await api.getExpertChildOrders(page.value, search.value)
const incoming = data.rows ?? []
rows.value = [...rows.value, ...incoming]
if (incoming.length < (data.limit ?? 20)) hasMore.value = false
else page.value += 1
} catch {
failed.value = true
hasMore.value = false
} finally {
loading.value = false
}
}
function reset() {
rows.value = []
page.value = 1
hasMore.value = true
failed.value = false
}
async function reload() {
reset()
await fetchPage()
}
/** The reference re-ran the query on every search (ChildOrders.vue:327). */
async function readSearch(value: string | undefined) {
search.value = value?.trim() ? value.trim() : null
await reload()
}
async function onRefresh() {
await reload()
refreshing.value = false
}
function openDetail(item: ExpertChildOrder) {
active.value = item
detailOpen.value = true
}
// Infinite scroll, the reference's behaviour (useInfiniteScroll at :216/:330).
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(async () => {
await fetchPage()
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) void fetchPage()
})
if (sentinel.value) observer.observe(sentinel.value)
}
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped lang="less">
.cho__barExtra {
padding: 0 16px 12px;
}
.cho__body {
padding-top: 132px;
padding-bottom: 90px;
}
.cho__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
.cho__card {
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
cursor: pointer;
transition: transform 0.2s ease;
}
.cho__card:active {
transform: scale(0.99);
}
.cho__top {
display: flex;
justify-content: space-between;
gap: 12px;
}
.cho__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.cho__num {
font-size: 15px;
font-weight: 700;
margin-bottom: 10px;
}
.cho__muted {
color: @inkMuted;
font-weight: 500;
margin-left: 4px;
}
.cho__meta {
display: flex;
align-items: baseline;
gap: 4px;
}
.cho__strong {
font-size: 15px;
font-weight: 700;
}
.cho__unit {
font-size: 12px;
color: @inkMuted;
}
.cho__status {
padding: 5px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #06b6d4;
background: fade(#06b6d4, 12%);
white-space: nowrap;
}
.cho__divider {
height: 1px;
background: @hairline;
margin: 12px 0;
}
.cho__bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.cho__customer {
font-size: 14px;
font-weight: 600;
color: @inkMuted;
}
.cho__more,
.cho__sentinel {
padding: 12px 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
.cho__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: @inkMuted;
font-size: 13px;
}
.cho__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cho__detail {
padding: 20px 16px 24px;
}
.cho__detailTitle {
margin: 0 0 16px;
font-size: 17px;
font-weight: 700;
}
.cho__rows {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cho__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
background: @canvasSoft;
border-radius: 10px;
}
.cho__row dt {
font-size: 13px;
color: @inkMuted;
}
.cho__row dd {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.cho__close {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,409 @@
<!--
"لیست مشتریان" — the sales expert's own referred customers.
Ported from legacy henza_pwa/src/views/user/src/components/customerChoice.vue
(route name customerChoice, title "انتخاب مشتری"), restyled into this app's
design language. The LOGIC is the reference's and is identical in
only the styling differs from the reference.
Kept from the reference:
- the header title "لیست مشتریان" (customerChoice.vue:8)
- the search box, re-querying on each search (:11, :184)
- per card: name, mobile, phone, address and the active/inactive state
- inactive customers are LISTED, not hidden — the reference's `active()`
scope is commented out at ApiMobileController.php:187, so every referred
customer appears whatever their state (D4: replicate, don't "fix")
`changeUser` / act-as IS ported — see `enterAccount` below and D29. It was
previously left out as a session-hijack primitive (D21); the owner asked for
it back, so the capability is the reference's but the mechanism is not. The
reference minted a permanent api_token per customer
(ApiMobileController.php:197-201) and swapped the bearer token; here the
expert keeps their own token and the client names the customer in a header,
which the server re-authorises on every request.
Still deliberately NOT ported (D21):
- The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here. The state is shown, read-only.
Scope is the server's: `referredByAgentId` = this expert, from the token.
-->
<template>
<div class="cch">
<PageBar title="لیست مشتریان" back-to="userPage::hasSubRoute">
<template #below>
<div class="cch__barExtra">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<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="cch__body">
<div v-if="rows.length" class="cch__list">
<article
v-for="item in rows"
:key="item.id"
class="cch__card"
@click="openDetail(item)"
>
<div class="cch__head">
<h3 class="cch__name">{{ item.name }}</h3>
<span class="cch__state" :class="item.active === 1 ? 'is-on' : 'is-off'">
{{ item.active === 1 ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="cch__facts">
<div class="cch__fact">
<Icon icon="vuesax-linear:mobile" size="15" />
<span dir="ltr">{{ item.mobile || '—' }}</span>
</div>
<div class="cch__fact">
<Icon icon="vuesax-linear:call" size="15" />
<span dir="ltr">{{ item.phone || '—' }}</span>
</div>
<div v-if="item.store_name" class="cch__fact">
<Icon icon="vuesax-linear:shop" size="15" />
<span>{{ item.store_name }}</span>
</div>
</div>
</article>
</div>
<section v-else-if="!loading && !failed" class="cch__empty">
<Icon icon="vuesax-linear:profile-2user" size="26" />
<p>{{ search ? 'مشتری با این مشخصات پیدا نشد.' : 'هنوز مشتری‌ای به شما معرفی نشده است.' }}</p>
</section>
<section v-if="failed" class="cch__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="cch__retry" @click="load">تلاش دوباره</button>
</section>
</container>
</PullRefresh>
</Spinner>
<Drawer
v-model:open="detailOpen"
placement="bottom"
height="auto"
:closable="false"
:body-style="{ padding: '0' }"
>
<div v-if="active" class="cch__detail">
<h2 class="cch__detailTitle">{{ active.name }}</h2>
<dl class="cch__rows">
<div class="cch__row">
<dt>موبایل</dt>
<dd dir="ltr">{{ active.mobile || '—' }}</dd>
</div>
<div class="cch__row">
<dt>تلفن</dt>
<dd dir="ltr">{{ active.phone || '—' }}</dd>
</div>
<div class="cch__row">
<dt>نام مغازه</dt>
<dd>{{ active.store_name || '—' }}</dd>
</div>
<div class="cch__row">
<dt>نام پاساژ</dt>
<dd>{{ active.pasazh_name || '—' }}</dd>
</div>
<div class="cch__row">
<dt>سقف اعتبار</dt>
<dd dir="ltr">{{ grams(active.gram_level) }} گرم</dd>
</div>
<div class="cch__row">
<dt>بدهی گرمی</dt>
<dd dir="ltr">{{ grams(active.remain) }} گرم</dd>
</div>
<div class="cch__row">
<dt>وضعیت</dt>
<dd>{{ active.active === 1 ? 'فعال' : 'غیرفعال' }}</dd>
</div>
</dl>
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
</button>
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { 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 PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import { api, type ExpertCustomer } from '../../api/client'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { toast } from '../../composables/useToast'
const router = useRouter()
const session = useSessionStore()
const cart = useCartStore()
const entering = ref(false)
const rows = ref<ExpertCustomer[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const search = ref<string | null>(null)
const detailOpen = ref(false)
const active = ref<ExpertCustomer | null>(null)
function grams(value: string | number | null | undefined): string {
const n = Number(value ?? 0)
return Number.isFinite(n) ? n.toFixed(3) : '0.000'
}
/**
* The reference returned the whole list in one call (no paging on /my-child,
* ApiMobileController.php:195 ends in `->get()`), so this does the same.
*/
async function load() {
loading.value = true
failed.value = false
try {
rows.value = await api.getExpertCustomers(search.value)
} catch {
failed.value = true
rows.value = []
} finally {
loading.value = false
}
}
/** The reference re-queried the server on each search (customerChoice.vue:184). */
async function readSearch(value: string | undefined) {
search.value = value?.trim() ? value.trim() : null
await load()
}
async function onRefresh() {
await load()
refreshing.value = false
}
function openDetail(item: ExpertCustomer) {
active.value = item
detailOpen.value = true
}
/**
* «ورود به حساب مشتری» — the reference's `changeUser`
* (customerChoice.vue:155-167). There it swapped the bearer token for the
* customer's own; here the session simply records who we are acting as and the
* API client names them in a header, which the server authorises per request.
*
* The cart is cleared in both directions: it lives in localStorage, so without
* this the expert's own basket would follow them into the customer's account
* and end up on the customer's order.
*/
async function enterAccount(item: ExpertCustomer) {
entering.value = true
try {
cart.clear()
session.setActingAs({ id: item.id, name: item.name ?? item.fullName ?? item.mobile ?? '' })
detailOpen.value = false
// Same destination as the reference, which returned to the dashboard.
await router.push({ name: 'userPage::hasSubRoute' })
toast.success(`وارد حساب ${item.name ?? ''} شدید`)
} finally {
entering.value = false
}
}
onMounted(load)
</script>
<style scoped lang="less">
.cch__barExtra {
padding: 0 16px 12px;
}
.cch__body {
padding-top: 132px;
padding-bottom: 90px;
}
.cch__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
.cch__card {
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
cursor: pointer;
transition: transform 0.2s ease;
}
.cch__card:active {
transform: scale(0.99);
}
.cch__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.cch__name {
margin: 0;
font-size: 15.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cch__state {
flex: 0 0 auto;
padding: 4px 12px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
}
.cch__state.is-on {
color: @green;
background: alpha(@green, 14%);
}
.cch__state.is-off {
color: @red;
background: alpha(@red, 12%);
}
.cch__facts {
display: flex;
flex-direction: column;
gap: 7px;
}
.cch__fact {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: @inkMuted;
}
.cch__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: @inkMuted;
font-size: 13px;
}
.cch__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cch__detail {
padding: 20px 16px 24px;
}
.cch__detailTitle {
margin: 0 0 16px;
font-size: 17px;
font-weight: 700;
}
.cch__rows {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.cch__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
background: @canvasSoft;
border-radius: 10px;
}
.cch__row dt {
font-size: 13px;
color: @inkMuted;
}
.cch__row dd {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.cch__enter {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}
/* Secondary now that "enter the account" is the primary action here. */
.cch__close {
margin-top: 10px;
width: 100%;
padding: 13px;
border: 1px solid alpha(@primary, 35%);
border-radius: 14px;
background: transparent;
color: @primary;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,188 @@
<!--
One document of "جزئیات حساب".
Ported from henza_pwa/src/views/report/components/AccountingDetail.vue,
restyled into this app's design language. The LOGIC is the reference's and is
only the styling differs from the reference.
Kept from the reference: شماره سند + تاریخ, وزن خالص, عیار, نوع سند,
شماره انگ, نام آزمایشگاه, قیمت, and the طلا in/out pair normalized to 750
(AccountingDetail.vue:6-85).
Rows the reference showed that the target has no equivalent for — نام کارساخته
(nO_kar), جنس فلز (jens_felez) and the two extra percentage lines — are
OMITTED rather than rendered blank or zero. They came from an external
accounting system the target does not have; the API returns null for them and
this component skips a row whose value is null, so the card never asserts
something it does not know. Recorded as REP-01.
Rows the target CAN fill and the reference could not are added at the end
(bank/account/destination), since those live on our own ledger entry.
-->
<template>
<article class="doc">
<header class="doc__head">
<span><span class="doc__muted">شماره سند:</span> {{ item.sh_factor }}</span>
<span><span class="doc__muted">تاریخ:</span> {{ jdate(item.tarikh) }}</span>
</header>
<dl class="doc__rows">
<div class="doc__row">
<dt>نوع سند</dt>
<dd>{{ item.no }}</dd>
</div>
<div class="doc__row">
<dt>وزن خالص</dt>
<dd dir="ltr">{{ item.vazn }}</dd>
</div>
<div v-if="item.ayar !== null" class="doc__row">
<dt>عیار</dt>
<dd dir="ltr">{{ item.ayar }}</dd>
</div>
<div v-if="item.sh_sharti" class="doc__row">
<dt>شماره انگ</dt>
<dd dir="ltr">{{ item.sh_sharti }}</dd>
</div>
<div v-if="item.name_az" class="doc__row">
<dt>نام آزمایشگاه</dt>
<dd>{{ item.name_az }}</dd>
</div>
<div v-if="item.Darsad_Talayee" class="doc__row">
<dt>اجرت (گرم)</dt>
<dd dir="ltr">{{ item.Darsad_Talayee }}</dd>
</div>
<div v-if="item.fi" class="doc__row">
<dt>قیمت هر گرم</dt>
<dd dir="ltr">{{ rial(item.fi) }}</dd>
</div>
<div v-if="item.fiF" class="doc__row">
<dt>مبلغ سند</dt>
<dd dir="ltr">{{ rial(item.fiF) }}</dd>
</div>
<div v-if="item.bankName" class="doc__row">
<dt>بانک</dt>
<dd>{{ item.bankName }}</dd>
</div>
<div v-if="item.accountName" class="doc__row">
<dt>نام حساب</dt>
<dd>{{ item.accountName }}</dd>
</div>
<div v-if="item.transferTo" class="doc__row">
<dt>مقصد حواله</dt>
<dd>{{ item.transferTo }}</dd>
</div>
<div v-if="item.receivedAt" class="doc__row">
<dt>تاریخ دریافت</dt>
<dd>{{ jdate(item.receivedAt) }}</dd>
</div>
<div v-if="item.note" class="doc__row">
<dt>توضیحات</dt>
<dd>{{ item.note }}</dd>
</div>
</dl>
<div class="doc__gold">
<span>طلا</span>
<div class="doc__stack">
<span dir="ltr" class="is-in">{{ item.tabdil750_v }}</span>
<span dir="ltr" class="is-out">{{ item.tabdil750_kh }}</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { jdate, price } from '../../utils/filters'
import type { ReportDetailRow } from '../../api/client'
defineProps<{ item: ReportDetailRow }>()
/** Same rule as the statement: group the whole part only. */
function rial(value: string): string {
const n = parseFloat(value)
if (!Number.isFinite(n)) return value
const whole = Math.trunc(Math.abs(n)).toString()
return (n < 0 ? '-' : '') + price(whole)
}
</script>
<style scoped lang="less">
.doc {
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
}
.doc__head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.doc__muted {
color: @inkMuted;
font-weight: 500;
margin-left: 4px;
}
.doc__rows {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.doc__row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: @canvasSoft;
border-radius: 10px;
}
.doc__row dt {
font-size: 12.5px;
color: @inkMuted;
white-space: nowrap;
}
.doc__row dd {
margin: 0;
font-size: 13.5px;
font-weight: 600;
text-align: left;
}
.doc__gold {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding: 10px 12px;
background: @hairlineSoft;
border-radius: 10px;
font-size: 13px;
font-weight: 700;
}
.doc__stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.is-in {
color: @green;
}
.is-out {
color: @red;
}
</style>
@@ -0,0 +1,441 @@
<!--
"گزارش" — the customer's account statement.
Ported from henza_pwa/src/views/report/index.vue (route /report, name
report) plus its two card components (components/Accounting.vue,
components/AccountingDetail.vue), restyled into this app's design language.
The LOGIC is the reference's; only the
styling differs.
Kept from the reference:
- the title "گزارش" and the back arrow to the dashboard (index.vue:7-10)
- the two tabs «کلیات حساب» / «جزئیات حساب» (:15-16), switching clears the
list and re-queries from page 1 (:178-181)
- the summary card's rows, in the reference's order: شماره سند + تاریخ,
طلا (in/out), مانده طلا, ریال (in/out), مانده ریال (Accounting.vue:5-48)
- balances coloured by sign — red when negative (Accounting.vue:29, :46)
- the «جزئیات» link opening the document in a modal (:66, index.vue:44-52)
- paging by scroll on the detail tab (:231-241)
Deliberately NOT ported: the ارز (foreign-currency) rows. The reference read
them from an external accounting system; the target has no foreign-currency
ledger, so showing a hardcoded "0 $" would assert something untrue. Recorded
as REP-01.
The reference gated this page on `myStore.hasMCode()` (index.vue:174-177) —
the customer's id in that external system. The target has no mcode, so the
gate is simply a session, and the server scopes every row to the token.
-->
<template>
<div class="rep">
<PageBar title="گزارش" back-to="userPage::hasSubRoute">
<template #below>
<div class="rep__tabs">
<button
type="button"
class="rep__tab"
:class="{ 'is-on': activeKey === '1' }"
@click="changeTab('1')"
>
کلیات حساب
</button>
<button
type="button"
class="rep__tab"
:class="{ 'is-on': activeKey === '2' }"
@click="changeTab('2')"
>
جزئیات حساب
</button>
</div>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length && !details.length" tip="درحال دریافت اطلاعات">
<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="rep__body">
<!-- ── کلیات حساب ─────────────────────────────────────────── -->
<template v-if="activeKey === '1'">
<div v-if="rows.length" class="rep__list">
<article v-for="item in rows" :key="item.factor_Code" class="rep__card">
<header class="rep__head">
<span><span class="rep__muted">شماره سند:</span> {{ item.sh_factor }}</span>
<span><span class="rep__muted">تاریخ:</span> {{ jdate(item.tarikh) }}</span>
</header>
<div class="rep__pair">
<span>طلا</span>
<div class="rep__stack">
<span dir="ltr" class="is-in">{{ item.geram_v }}</span>
<span dir="ltr" class="is-out">{{ item.geram_kh }}</span>
</div>
</div>
<div class="rep__pair is-balance">
<span>مانده طلا:</span>
<span dir="ltr" :class="sign(item.tahesab_T)">{{ item.tahesab_T }}</span>
</div>
<div class="rep__pair">
<span>ریال</span>
<div class="rep__stack">
<span dir="ltr" class="is-in">{{ rial(item.rial_v) }}</span>
<span dir="ltr" class="is-out">{{ rial(item.rial_kh) }}</span>
</div>
</div>
<div class="rep__pair is-balance">
<span>مانده ریال:</span>
<span dir="ltr" :class="sign(item.tahesab_P)">{{ rial(item.tahesab_P) }}</span>
</div>
<button type="button" class="rep__more" @click="openDetail(item)">جزئیات</button>
</article>
</div>
<section v-else-if="!loading && !failed" class="rep__empty">
<Icon icon="vuesax-linear:receipt-text" size="26" />
<p>هنوز سندی برای حساب شما ثبت نشده است.</p>
</section>
</template>
<!-- ── جزئیات حساب ────────────────────────────────────────── -->
<template v-else>
<div v-if="details.length" class="rep__list">
<ReportDocument v-for="d in details" :key="d.factor_Code" :item="d" />
</div>
<section v-else-if="!loading && !failed" class="rep__empty">
<Icon icon="vuesax-linear:receipt-text" size="26" />
<p>هنوز سندی برای حساب شما ثبت نشده است.</p>
</section>
</template>
<section v-if="failed" class="rep__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="rep__retry" @click="reload">تلاش دوباره</button>
</section>
<div v-if="loading && (rows.length || details.length)" class="rep__more">در حال دریافت…</div>
<div ref="sentinel" class="rep__sentinel" />
</container>
</PullRefresh>
</Spinner>
<Drawer
v-model:open="modalOpen"
placement="bottom"
height="auto"
:closable="false"
:body-style="{ padding: '0' }"
>
<div class="rep__modal">
<Spinner :spinning="modalLoading" tip="درحال دریافت اطلاعات">
<ReportDocument v-for="d in modalRows" :key="d.factor_Code" :item="d" />
<p v-if="!modalLoading && !modalRows.length" class="rep__modalEmpty">
جزئیاتی برای این سند ثبت نشده است.
</p>
</Spinner>
<button type="button" class="rep__close" @click="modalOpen = false">بازگشت</button>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import ReportDocument from './ReportDocument.vue'
import { jdate, price } from '../../utils/filters'
import { api, type ReportRow, type ReportDetailRow } from '../../api/client'
const route = useRoute()
/**
* The reference had /report and /report/detail as two routes. The detail one
* lands on the same screen with the second tab already selected, which is what
* its 'جزئیات حساب' tab showed.
*/
const activeKey = ref<'1' | '2'>(route.name === 'reportDetail' ? '2' : '1')
const rows = ref<ReportRow[]>([])
const details = ref<ReportDetailRow[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const modalOpen = ref(false)
const modalLoading = ref(false)
const modalRows = ref<ReportDetailRow[]>([])
/**
* price() groups digits from the right, so a decimal tail would be grouped
* too. Rial figures are whole here, so the .00 is dropped before grouping and
* a negative sign is preserved outside the grouping.
*/
function rial(value: string): string {
const n = parseFloat(value)
if (!Number.isFinite(n)) return value
const whole = Math.trunc(Math.abs(n)).toString()
return (n < 0 ? '-' : '') + price(whole)
}
/** The reference coloured a balance by its sign (Accounting.vue:29). */
function sign(value: string): string {
return parseFloat(value) < 0 ? 'is-out' : 'is-in'
}
async function fetchPage() {
if (loading.value || !hasMore.value) return
loading.value = true
failed.value = false
try {
if (activeKey.value === '1') {
const data = await api.getReport(page.value)
rows.value = [...rows.value, ...(data.result ?? [])]
if ((data.result?.length ?? 0) < (data.limit ?? 50)) hasMore.value = false
else page.value += 1
} else {
const data = await api.getReportDetailList(page.value)
details.value = [...details.value, ...(data.result ?? [])]
if ((data.result?.length ?? 0) < (data.limit ?? 50)) hasMore.value = false
else page.value += 1
}
} catch {
failed.value = true
hasMore.value = false
} finally {
loading.value = false
}
}
function reset() {
rows.value = []
details.value = []
page.value = 1
hasMore.value = true
failed.value = false
}
async function reload() {
reset()
await fetchPage()
}
/** The reference cleared the list and re-queried on tab change (index.vue:178). */
async function changeTab(key: '1' | '2') {
if (activeKey.value === key) return
activeKey.value = key
await reload()
}
async function onRefresh() {
await reload()
refreshing.value = false
}
/** The «جزئیات» link — the reference opened the same card in a modal. */
async function openDetail(item: ReportRow) {
modalOpen.value = true
modalLoading.value = true
modalRows.value = []
try {
const data = await api.getReportDocument(item.factor_Code)
modalRows.value = data.result ?? []
} catch {
modalRows.value = []
} finally {
modalLoading.value = false
}
}
// The reference paged by scroll on the detail tab (index.vue:231-241).
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(async () => {
await fetchPage()
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) void fetchPage()
})
if (sentinel.value) observer.observe(sentinel.value)
}
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped lang="less">
.rep__tabs {
display: flex;
gap: 8px;
padding: 0 16px 12px;
}
.rep__tab {
flex: 1;
padding: 10px;
border: 1px solid @hairline;
border-radius: 999px;
background: @canvas;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: @inkMuted;
cursor: pointer;
}
.rep__tab.is-on {
background: @primary;
border-color: @primary;
color: #fff;
}
.rep__body {
padding-top: 132px;
padding-bottom: 90px;
}
.rep__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
.rep__card {
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
}
.rep__head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.rep__muted {
color: @inkMuted;
font-weight: 500;
margin-left: 4px;
}
.rep__pair {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 12px;
margin-bottom: 6px;
background: @canvasSoft;
border-radius: 10px;
font-size: 13px;
}
.rep__pair.is-balance {
background: @hairlineSoft;
font-weight: 700;
}
.rep__stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
font-weight: 600;
}
.is-in {
color: @green;
}
.is-out {
color: @red;
}
.rep__more,
.rep__sentinel {
padding: 12px 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
}
.rep__more {
width: 100%;
margin-top: 4px;
border: none;
background: transparent;
font-family: inherit;
font-weight: 700;
color: @wine;
cursor: pointer;
}
.rep__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: @inkMuted;
font-size: 13px;
}
.rep__retry {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid @hairline;
background: @canvas;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.rep__modal {
padding: 20px 16px 24px;
}
.rep__modalEmpty {
padding: 24px 0;
text-align: center;
font-size: 13px;
color: @inkMuted;
}
.rep__close {
margin-top: 18px;
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,103 @@
<!--
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 { useCatalogStore } from '../../stores/catalogStore'
const route = useRoute()
const catalog = useCatalogStore()
const loadingPage = ref(false)
const allProducts = computed(() => catalog.products)
// 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 = !catalog.loaded
try {
await catalog.load()
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
})
</script>
<style scoped lang="less">
.coll {
background: @page;
min-height: 100vh;
}
.coll__body {
padding-top: 76px;
padding-bottom: 90px;
}
.coll__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
.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,444 @@
<!--
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="جستجو">
<template #below>
<div class="search__barExtra">
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
<div 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>
<!--
No full-screen spinner here: it covered the page with the brand logo and
then dropped the finished results in all at once. The first load shows a
skeleton of the result grid instead, the same size as the real cards, so
the results replace it in place; a pull-to-refresh shows only the pull
indicator, over the results already on screen.
-->
<div>
<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="search__body">
<div v-if="loadingPage && !allProducts.length" class="search__skeleton" aria-hidden="true">
<div class="search__skHead">
<span class="skeleton search__skLine search__skLine--title" />
<span class="skeleton search__skLine search__skLine--sub" />
</div>
<div class="search__grid">
<div v-for="n in SKELETON_CARDS" :key="n" class="search__skCard">
<div class="skeleton search__skArt" />
<div class="search__skBody">
<span class="skeleton search__skLine search__skLine--name" />
<span class="skeleton search__skLine search__skLine--meta" />
</div>
</div>
</div>
</div>
<div v-if="availableCollections.length" class="search__collections">
<button
v-for="item in availableCollections"
:key="item.id"
type="button"
class="search__collection"
@click="$router.push({ path: `/collection/${item.id}`, query: { page: item.title } })"
>
<Icon icon="vuesax-linear:money-recive" size="19" />
<span>{{ item.title }}</span>
</button>
</div>
<SectionHead
v-if="filteredProducts.length"
title="نتایج"
:subtitle="`${filteredProducts.length} محصول`"
/>
<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>
<!-- Its own condition, not a v-else of the sentinel above: chained to
it, the box also showed under the results whenever they all fit
in the first window. -->
<section v-if="!filteredProducts.length && !loadingPage" class="search__empty">
<Icon icon="vuesax-linear:search-normal-1" size="26" />
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</div>
<!-- A bottom sheet over a see-through backdrop (teleported to <body>);
see Filters.vue. The result list stays in place underneath. -->
<Filters
v-model:show="showFilter"
v-model:filterKeys="filterKeys"
v-model="filters.query"
:products="allProducts"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Loading, 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 { useCatalogStore } from '../../stores/catalogStore'
const filters = useFiltersStore()
const catalog = useCatalogStore()
// Starts true on a cold start: the first render happens before onMounted kicks
// off the fetch, and with false the "nothing found" box flashed up for a frame
// before the loading state replaced it. With the catalogue cached it renders at once.
const loadingPage = ref(!catalog.loaded)
// Two rows of the two-column grid — enough to fill the screen under the bar.
const SKELETON_CARDS = 6
const refreshing = ref(false)
const allProducts = computed(() => catalog.products)
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(force = false) {
loadingPage.value = !catalog.loaded
try {
await catalog.load(force)
} catch (e) {
console.log(e)
} finally {
loadingPage.value = false
}
}
async function onRefresh() {
await loadProducts(true)
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
}
onMounted(() => loadProducts())
</script>
<style scoped lang="less">
.search {
background: @page;
min-height: 100vh;
}
.search__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 0;
}
.search__chips {
display: flex;
gap: 4px;
overflow-x: auto;
margin: 8px 0px 8px -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;
}
.search__chip {
scroll-snap-align: start;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
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: @primary;
border-color: @primary;
}
.search__chip--filter.is-on {
background: @wine;
border-color: @wine;
}
.search__chip:active {
transform: scale(0.97);
}
.search__body {
padding-top: 160px;
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: 11px;
}
// First-load placeholder. Mirrors SectionHead + ProductCard's box model
// (square art, 11/12/13px body padding, an 18px-radius hairline card) so
// the real results land exactly where the placeholders were.
.search__skHead {
display: flex;
flex-direction: column;
gap: 7px;
padding-bottom: 13px;
}
.search__skCard {
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
}
.search__skArt {
aspect-ratio: 1 / 1;
}
.search__skBody {
display: flex;
flex-direction: column;
gap: 11px;
padding: 15px 12px 16px;
}
.search__skLine {
display: block;
height: 11px;
border-radius: 6px;
}
.search__skLine--title {
width: 72px;
height: 15px;
}
.search__skLine--sub {
width: 52px;
}
.search__skLine--name {
width: 78%;
height: 13px;
}
.search__skLine--meta {
width: 45%;
}
.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,340 @@
<!--
تنظیمات ظاهری — the colour theme and the "fast & light" motion switch.
Reached from the dashboard; open to signed-out users too (these are device
preferences, not account data). State lives in stores/appearanceStore.ts,
which applies it to <html> immediately — nothing to save or confirm.
The theme previews are drawn with FIXED colours, not theme tokens: each one
has to show what that theme looks like, whichever theme is active now.
STRUCTURE IS SHARED, LOOK IS NOT: template and script are identical in
user-app; only the "Look" block differs, per app.
-->
<template>
<div class="ap">
<PageBar title="تنظیمات ظاهری" back-to="userPage::hasSubRoute" />
<container class="ap__body">
<!-- ── Theme ───────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 id="ap-theme" class="ap__title">پوسته برنامه</h2>
<p class="ap__sub">ظاهر روشن یا تیره، یا هماهنگ با تنظیمات گوشی شما</p>
</header>
<div class="ap__themes" role="radiogroup" aria-labelledby="ap-theme">
<button
v-for="opt in THEMES"
:key="opt.value"
type="button"
role="radio"
class="ap__theme"
:class="{ 'is-on': appearance.theme === opt.value }"
:aria-checked="appearance.theme === opt.value"
@click="appearance.setTheme(opt.value)"
>
<span class="ap__preview" :class="`ap__preview--${opt.value}`" aria-hidden="true">
<span class="ap__pvBar"></span>
<span class="ap__pvCard"></span>
<span class="ap__pvLine"></span>
<span class="ap__pvLine ap__pvLine--short"></span>
<span class="ap__pvBtn"></span>
</span>
<span class="ap__themeLabel">
<Icon :icon="opt.icon" size="15" />
{{ opt.label }}
</span>
<span class="ap__check" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="18" />
</span>
</button>
</div>
<p v-if="appearance.theme === 'system'" class="ap__note">
الان {{ appearance.resolvedTheme === 'dark' ? 'تیره' : 'روشن' }} است، مطابق تنظیمات گوشی.
</p>
</section>
<!-- ── Motion ──────────────────────────────────────────── -->
<section class="ap__section">
<header class="ap__head">
<h2 class="ap__title">انیمیشن‌ها</h2>
<p class="ap__sub">برای سرعت بیشتر روی گوشی‌های ضعیف‌تر</p>
</header>
<!-- The whole row is the switch: one tap target, one announcement. -->
<button
type="button"
role="switch"
class="ap__row"
:aria-checked="appearance.reduceMotion"
@click="appearance.setReduceMotion(!appearance.reduceMotion)"
>
<span class="ap__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:flash" size="18" />
</span>
<span class="ap__rowText">
<b>حالت سبک و سریع</b>
<small>
حرکت‌های سنگین، افکت‌های شیشه‌ای و اسلاید خودکار خاموش می‌شوند؛ بازخورد کوتاه لمس‌ها و جابه‌جایی‌های سریع
باقی می‌مانند تا برنامه خشک و بی‌روح نشود.
</small>
</span>
<ToggleSwitch :on="appearance.reduceMotion" />
</button>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import ToggleSwitch from '../../components/ui/ToggleSwitch.vue'
import { useAppearanceStore, type ThemeChoice } from '../../stores/appearanceStore'
const appearance = useAppearanceStore()
const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
{ value: 'light', label: 'روشن', icon: 'vuesax-linear:sun-1' },
{ value: 'dark', label: 'تیره', icon: 'vuesax-linear:moon' },
{ value: 'system', label: 'مطابق سیستم', icon: 'vuesax-linear:monitor-mobbile' },
]
</script>
<style scoped lang="less">
/* ── Structure (same in both apps) ──────────────────────────── */
.ap {
min-height: 100vh;
}
.ap__body {
padding-top: 76px;
padding-bottom: 90px;
display: flex;
flex-direction: column;
gap: 26px;
}
.ap__title,
.ap__sub,
.ap__note {
margin: 0;
}
.ap__themes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.ap__theme {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.ap__theme:active {
transform: scale(0.97);
}
.ap__themeLabel {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.ap__check {
position: absolute;
top: 8px;
inset-inline-end: 8px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.ap__theme.is-on .ap__check {
opacity: 1;
transform: none;
}
// Mini screen, fixed colours on purpose (see the header).
.ap__preview {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 6%;
padding: 10% 12%;
--pv-bg: #ffffff;
--pv-bar: #f4f5f6;
--pv-card: #ecedef;
--pv-line: #dadce0;
--pv-btn: #14161a;
background: var(--pv-bg);
}
.ap__preview--dark {
--pv-bg: #16171a;
--pv-bar: #1e2024;
--pv-card: #2b2e34;
--pv-line: #3a3e45;
--pv-btn: #f1f2f4;
}
// System: the light screen with its lower-left half in dark.
.ap__preview--system::after {
content: '';
position: absolute;
inset: 0;
background: #16171a;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.94;
}
.ap__pvBar {
height: 9%;
border-radius: 4px;
background: var(--pv-bar);
}
.ap__pvCard {
flex: 1;
border-radius: 7px;
background: var(--pv-card);
}
.ap__pvLine {
height: 6%;
border-radius: 3px;
background: var(--pv-line);
}
.ap__pvLine--short {
width: 60%;
}
.ap__pvBtn {
height: 11%;
border-radius: 999px;
background: var(--pv-btn);
}
.ap__row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s ease, border-color 0.2s ease;
}
.ap__row:active {
transform: scale(0.985);
}
.ap__rowIcon {
flex: 0 0 auto;
display: grid;
place-items: center;
}
.ap__rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Look: user-app ─────────────────────────────────────────── */
// White hairline-bordered cards at the 16px radius on the soft-grey ground,
// chosen = a brand-green border with a green check (the app's chips and
// variant picker); the motion row is a card with the dashboard tiles' grey
// icon tile.
.ap {
background: @canvasSoft;
}
.ap__head {
margin-bottom: 13px;
}
.ap__title {
font-size: 15px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
}
.ap__sub {
margin-top: 5px;
font-size: 11.5px;
color: @inkFaint;
}
.ap__note {
margin-top: 10px;
font-size: 11.5px;
color: @inkMuted;
}
.ap__theme {
padding: 8px 8px 11px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
color: @inkMuted;
}
.ap__theme.is-on {
color: @ink;
border-color: @primary;
box-shadow: 0 0 0 1px @primary;
}
.ap__preview {
border-radius: 11px;
box-shadow: 0 0 0 1px rgba(20, 22, 26, 0.07);
}
.ap__themeLabel {
font-size: 12px;
font-weight: 700;
}
.ap__check {
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
background: @primary;
}
.ap__row {
padding: 14px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.ap__rowIcon {
width: 38px;
height: 38px;
border-radius: 12px;
color: @inkSoft;
background: @hairlineSoft;
}
.ap__rowText b {
font-size: 13px;
font-weight: 800;
color: @ink;
}
.ap__rowText small {
font-size: 11px;
line-height: 1.9;
color: @inkFaint;
}
</style>
@@ -0,0 +1,196 @@
<!--
"تکمیل پروفایل" — the customer uploads their three documents.
Ported from henza_pwa/src/views/user/src/completeProfile.vue (route
/complete_profile, name completeProfile), restyled into this app's design
language. The LOGIC is the reference's; only the styling differs.
Kept from the reference:
- the header title "تکمیل پروفایل" (completeProfile.vue:12) and the back
arrow returning to the dashboard (:11)
- exactly three uploads, in the reference's order and with its texts:
جواز کسب, سردر مغازه, کارت ملی (:45-56)
- the submit button "بارگذاری اطلاعات" (:81)
- the client-side all-three check BEFORE posting, with the reference's own
message "لطفا تمامی عکس های خواسته شده را آپلود کنید" (:191-198)
- POST with the three snake_case ids (:193)
- the success dialog with its text and its "متوجه شدم" button (:94-111),
after which the reference navigated home (:175-181)
Deliberately NOT ported: the reference's `window.setInterval(... reload)` in
refresh() (:178-180), which reloaded the whole page every second FOREVER —
an unterminated timer, not a refresh. The store is re-read once instead.
The page is reachable only while the profile is incomplete; see the router
guard, which mirrors the reference's (henza_pwa router/index.ts:266).
-->
<template>
<div class="cpf">
<PageBar title="تکمیل پروفایل" back-to="userPage::hasSubRoute" />
<Spinner :spinning="submitting" tip="در حال ثبت اطلاعات">
<container class="cpf__body">
<p class="cpf__lead">
برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید.
</p>
<form class="cpf__form" @submit.prevent="submit">
<SingleImageUploader
upload-text="بارگذاری عکس جواز کسب"
purpose="business-license"
@done="(id: string) => (form.business_license_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس سردر مغازه"
purpose="shop-image"
@done="(id: string) => (form.shop_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس کارت ملی"
purpose="national-card"
@done="(id: string) => (form.ncode_image_id = id)"
/>
<p v-if="error" class="cpf__error">{{ error }}</p>
<button type="submit" class="cpf__submit" :disabled="submitting">
بارگذاری اطلاعات
</button>
</form>
</container>
</Spinner>
<DialogVantOneButton
v-model:show="done"
:time-out="-1"
:close-on-click-overlay="false"
>
<template #title>
<div class="cpf__dialogText">
تکمیل اطلاعات شما با موفقیت انجام شد.
</div>
</template>
<template #button>
<div class="cpf__dialogBtn" @click="finish">متوجه شدم</div>
</template>
</DialogVantOneButton>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from '../../composables/useToast'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
import { KEYS, useQueryCache } from '../../stores/queryCache'
const router = useRouter()
const form = reactive({
business_license_image_id: null as string | null,
shop_image_id: null as string | null,
ncode_image_id: null as string | null,
})
const submitting = ref(false)
const done = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
// The reference checked all three client-side before posting
// (completeProfile.vue:191-198); the server requires them too.
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
toast.warning('لطفا تمامی عکس های خواسته شده را آپلود کنید')
return
}
submitting.value = true
try {
await api.completeProfile({
business_license_image_id: form.business_license_image_id,
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
useQueryCache().invalidate(KEYS.profile)
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
} finally {
submitting.value = false
}
}
/**
* The reference sent the user home after the dialog (:175-177).
*
* `replace`: once the documents are in, this route is no longer reachable — the
* router guard redirects it home — so leaving it in history gives a back button
* that appears to do nothing.
*/
function finish() {
done.value = false
router.replace({ name: 'home' })
}
</script>
<style scoped lang="less">
.cpf__body {
padding-top: 90px;
padding-bottom: 40px;
}
.cpf__lead {
margin: 4px 0 18px;
font-size: 13px;
color: @inkMuted;
}
.cpf__form {
display: flex;
flex-direction: column;
gap: 14px;
}
.cpf__error {
margin: 2px;
font-size: 12.5px;
color: @red;
}
.cpf__submit {
margin-top: 6px;
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.cpf__submit:disabled {
opacity: 0.6;
cursor: progress;
}
.cpf__dialogText {
padding: 20px;
line-height: 1.9;
text-align: justify;
font-size: 14px;
}
.cpf__dialogBtn {
text-align: center;
cursor: pointer;
}
</style>
@@ -0,0 +1,568 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue,
restyled into the redesigned design language (see components/home/* and
HomeView). The live rate block now reuses components/home/RateStrip.vue
rather than re-implementing the same strip a second time.
Legacy's original (820 lines) is a multi-role workbench mixing
plain-customer, sales-agent ("reagent") and "banker" (type=1, sub-account
switching) views. Kept: the real balance card, the nav grid to
Orders/Documents/Profile/Favorites, and the logout/login-nudge dialogs —
all backed by real data.
DROPPED as architecturally incompatible, not merely deferred: everything
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
customerChoice. 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, shown as one strip
rather than fabricating rows that don't exist.
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
<button
v-if="session.isLoggedIn"
type="button"
class="dash__barBtn"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="19" />
</button>
<span v-else class="dash__barSpacer" aria-hidden="true" />
</div>
</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 -->
<template v-if="customer">
<section class="dash__balance reveal" :style="revealDelay(0)">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
<div class="dash__figure">
{{ grams }}
<span class="dash__unit">گرم</span>
</div>
</div>
<span class="dash__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="dash__balanceRow">
<span class="dash__rowLabel">بدهی ریالی</span>
<span class="dash__rowValue">{{ price(customer.fiatDebtRial) }} <i>ریال</i></span>
</div>
<div class="dash__identity">
<span class="dash__name">{{ customer.fullName }}</span>
<span class="dash__mobile">{{ session.mobile }}</span>
</div>
</section>
</template>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite reveal" :style="revealDelay(0)">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
<div class="dash__inviteCopy">
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
</div>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<button
v-for="item in list"
:key="item.id"
type="button"
class="dash__tile"
:class="{ 'is-disabled': item.disabled }"
@click="goToDetails(item.id)"
>
<span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" />
</span>
<span class="dash__tileTitle">{{ item.title }}</span>
</button>
</div>
</section>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section reveal" :style="revealDelay(2)">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
</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-[color:var(--c-primary)]" @click="logOut">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @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">
برای دسترسی به تمامی امکانات نرم افزار {{ brand.name }}، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @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 { brand } from '../../brand'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const router = useRouter()
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? 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 })
const BASE_TILES = [
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', disabled: false },
// D24 — the account statement. The reference put it on the dashboard too,
// gated on hasMCode() (Dashboard.vue:179, :449). The target has no mcode, so
// every signed-in customer sees it and the server scopes it to their token.
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', disabled: false },
]
/**
* D21 — the two sales-expert entries, appended only for an expert. The
* reference showed them the same way, gated on the agent's own account
* (Dashboard.vue:117-125). An ordinary customer never sees them, and the
* server refuses both routes for a non-expert token regardless.
*/
const EXPERT_TILES = [
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', disabled: false },
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false },
]
// Appearance (theme + reduced motion) — always last, and open to everyone:
// device preferences, not account data. See goToDetails.
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', disabled: false }
const list = computed(() => [
...BASE_TILES,
...(session.isSalesExpert ? EXPERT_TILES : []),
APPEARANCE_TILE,
])
function goToDetails(id: number) {
// The only tile that needs no login.
if (id === 7) {
router.push({ name: 'appearanceSettings' })
return
}
if (!session.isLoggedIn) {
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' })
if (id === 4) router.push({ name: 'ChildOrder' })
if (id === 5) router.push({ name: 'customerChoice' })
if (id === 6) router.push({ name: 'report' })
}
async function logOut() {
data.notify = false
try {
await api.endSharedSession()
} catch {
// خروج محلی ادامه پیدا می‌کند
}
let central: string | null = null
try {
central = (await api.ssoLogoutUrl()).logout_url
} catch {
central = null
}
session.logout()
sessionStorage.removeItem('gb_iam_sso_checked')
if (central) {
window.location.assign(central)
return
}
router.push({ name: 'home' })
}
async function loadDashboard() {
// Not wrapped before: a failure here (expired token, network blip) left
// the exception uncaught, so onRefresh's refreshing.value = false never
// ran and PullRefresh stayed stuck in its loading state — a working pull
// gesture never dismissed it again until a full page reload.
const [me, price] = await Promise.allSettled([
// The balance on this tab is the one figure read fresh on every visit;
// the cached copy only fills the screen until it arrives.
session.isLoggedIn ? reads.me({ force: true }) : Promise.resolve(null),
// Also forced, per the owner's 2026-10-12 ruling: both figures on this tab
// are read fresh on every visit. This used to follow a `force` parameter
// that only pull-to-refresh set, so the price here could be as old as the
// app launch. With both reads always fresh the parameter had nothing left
// to control, so it is gone.
reads.goldPrice({ force: true }),
])
if (me.status === 'fulfilled') customer.value = me.value
else console.log(me.reason)
goldPrice.value = price.status === 'fulfilled' ? price.value : null
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
let activatedOnce = false
onActivated(() => {
// Kept alive by MasterLayout, so every later visit lands here, not in onMounted.
if (activatedOnce) void loadDashboard()
activatedOnce = true
})
// First-paint entrance, the same choreography HomeView uses: played once,
// after the first load; pull-to-refresh and tab returns don't replay it.
const { revealed, revealDelay, reveal } = useReveal('dashboard')
onMounted(async () => {
await loadDashboard()
reveal()
})
</script>
<style scoped lang="less">
.dash {
background: @page;
min-height: 100vh;
}
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__barTitle {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__barBtn {
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__barBtn:active {
transform: scale(0.94);
}
.dash__barSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 76px;
padding-bottom: 90px;
}
.dash__balance {
padding: 16px;
border-radius: 20px;
background: @primary;
color: #fff;
box-shadow: 0 14px 30px -22px rgba(20, 22, 26, 0.95);
}
.dash__balanceHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__eyebrow {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__figure {
padding-top: 5px;
font-size: 25px;
font-weight: 800;
letter-spacing: -0.3px;
}
.dash__unit {
font-size: 11.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 4px;
}
.dash__seal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__seal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__seal.is-creditor {
color: #14161A;
background: rgba(255, 255, 255, 0.9);
}
.dash__balanceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__rowLabel {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__rowValue {
font-size: 13.5px;
font-weight: 700;
}
.dash__rowValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
.dash__identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 13px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__name {
font-size: 12.5px;
font-weight: 700;
}
.dash__mobile {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
direction: ltr;
}
.dash__invite {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.dash__inviteIcon {
flex: 0 0 42px;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__inviteCopy {
flex: 1;
min-width: 0;
}
.dash__inviteCopy h3 {
margin: 0;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.dash__inviteCopy p {
margin: 4px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.dash__inviteCta {
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: #fff;
background: @primary;
}
.dash__section {
margin-top: 26px;
}
.dash__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
}
.dash__tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 6px;
border: 1px solid @hairline;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
transition: transform 0.18s ease;
}
.dash__tile:active {
transform: scale(0.97);
}
.dash__tile.is-disabled {
opacity: 0.4;
}
.dash__tileIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @inkSoft;
background: @hairlineSoft;
}
.dash__tileTitle {
font-size: 11px;
font-weight: 700;
color: @inkSoft;
}
</style>
@@ -0,0 +1,131 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
Favourites are server-side (FAV-01: GET/POST customer/me/favorites), and the
products behind them are fetched BY ID — the saved ids go straight to
`?ids=…`, one request for the lot.
It used to fetch the whole catalogue and filter it in the browser, which with
~16k products meant downloading all of them to show a handful. It was also
wrong beyond the first page once paging existed: a favourite outside the
loaded slice would simply not appear.
-->
<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, watch } 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 fetched = ref<ProductSummary[]>([])
/**
* Still filtered against the store, so un-hearting a product removes its card
* at once without waiting for a refetch — the fetched list is the source of the
* product DATA, the store is the source of truth for what is saved.
*/
const favoriteProducts = computed(() => fetched.value.filter((p) => favorites.isFavorite(p.id)))
async function load() {
const ids = favorites.ids
if (ids.length === 0) {
fetched.value = []
return
}
loading.value = fetched.value.length === 0
try {
fetched.value = await api.getProductsByIds(ids)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(load)
// The ids arrive from the server just after sign-in, and change when the person
// hearts something elsewhere in the app — both land here as a new id list.
watch(() => favorites.ids.join(','), load)
</script>
<style scoped lang="less">
.favs {
background: @page;
min-height: 100vh;
}
.favs__body {
padding-top: 76px;
padding-bottom: 90px;
}
.favs__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
.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: @primary;
}
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../../api/client'
import { useSessionStore } from '../../stores/session'
const router = useRouter()
const session = useSessionStore()
function backToCaller() {
sessionStorage.setItem('gb_iam_sso_skip', '1')
const saved = sessionStorage.getItem('gb_iam_return')
sessionStorage.removeItem('gb_iam_return')
const path = saved && saved.startsWith('/') && !saved.startsWith('//') ? saved : '/'
return router.replace(path)
}
onMounted(async () => {
const params = new URLSearchParams(window.location.search)
sessionStorage.removeItem('gb_iam_sso_checked')
if (params.get('error') || !params.get('code') || !params.get('state')) {
await backToCaller()
return
}
try {
const result = await api.ssoCallback(params.get('code') as string, params.get('state') as string)
session.login(result.token, result.customer.id, result.customer.mobile)
try {
const who = await api.getExpertIdentity()
session.setSalesExpert(who.isSalesExpert)
} catch {
session.setSalesExpert(false)
}
await router.replace({ name: 'home' })
} catch {
await backToCaller()
}
})
</script>
<template>
<div style="min-height: 100vh; display: grid; place-items: center">در حال ورود...</div>
</template>
@@ -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>
+364
View File
@@ -0,0 +1,364 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
The flow is REAL. These notes used to say it was stubbed, and that stale text
misled a reader as recently as 2026-10-12, so it is corrected rather than
deleted:
- Step 1 posts the number to the server, which looks it up itself. It no
longer pulls the customer list into the client to match on `mobile` — that
was the login leak (D8). An unregistered number is answered with 404 and
the server's Persian message is shown; owner ruling 2026-10-12, because
the earlier opaque answer walked the person to the code box to wait for an
SMS that was never going to arrive.
- Step 2 IS verified by the server: hashed code, two-minute expiry, attempt
limit (D6). Accepting any 4-digit code is long gone.
- `token.fireBaseToken` still does not exist in this port (PUSH-01).
- `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
-->
<template>
<div class="auth">
<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">ورود به {{ brand.name }}</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 { brand } from '../../brand'
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 { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api } 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 body = reactive({
step: 1,
loading: false,
defaultFocus: false,
})
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
setTimeout(() => {
if (timer.value > 0) {
timer.value -= 1
countDownTimer()
}
}, 1000)
}
}
/**
* Step 1 — ask the server to send an SMS code.
*
* This used to download every customer via GET /customers and match the phone
* number here in the browser, which handed any visitor the whole customer book
* (SEC-08). The lookup now happens server-side, exactly as the reference did it
* (henza_server ApiMobileController::userAuth, :39-49).
*
* The response is deliberately the same whether or not the number is
* registered, so an unknown number can no longer be told apart from a known
* one. That means we advance to step 2 either way; a number that does not exist
* simply never receives a code, and the verify call then rejects it.
*/
const sendPhone = async () => {
try {
body.loading = true
await api.requestOtp(String(formModel.username ?? ''))
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e: unknown) {
// Both of these carry a Persian, user-facing `message` from the server, so
// show it instead of a generic one:
// 429 — the send throttle (too soon, or too many in the window);
// 404 — the number is not registered (owner ruling 2026-10-12). Without
// this branch the person is told "خطا در ارسال کد"، which is the
// very confusion the 404 was added to remove: they would still not
// know whether the SMS was late or the number was never on file.
const text =
e instanceof Error && /\((?:429|404|403)\)/.test(e.message) ? extractServerMessage(e) : null
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
} finally {
body.loading = false
}
}
/**
* Step 2 — submit the code.
*
* The server compares it (reference: ApiMobileController::userVerfiy, :51-57,
* which aborts 422 on a mismatch) and returns a real token plus that one
* customer. Previously any 4-digit code was accepted client-side.
*/
const sendToken = async (otpCode: string) => {
try {
body.loading = true
const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
const { token, customer } = verified
session.login(token, customer.id, customer.mobile)
if (verified.iamBootstrapUrl) {
window.location.assign(verified.iamBootstrapUrl)
return
}
// D21 — ask the server which kind of principal this token is, so the
// expert-only entries appear for a sales expert. Failure is non-fatal:
// the entries simply stay hidden, and the server refuses those routes
// for anyone who is not an expert anyway.
try {
const who = await api.getExpertIdentity()
session.setSalesExpert(who.isSalesExpert)
} catch {
session.setSalesExpert(false)
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
// `replace`, not `push`: the login screen must not stay in history. Pushed,
// going back from the page they just reached returns them to the login box
// they have already used, which the guard above then bounces to home — so
// one tap of "back" loses their place entirely.
await router.replace(redirect ?? { name: 'home' })
toast.success('با موفقیت وارد شدید')
} catch (e: unknown) {
toast.error(
e instanceof Error && /\(429\)/.test(e.message)
? (extractServerMessage(e) ?? 'تعداد تلاش‌ها بیش از حد مجاز است.')
: 'کد وارد شده صحیح نیست',
)
} finally {
body.loading = false
}
}
/** Pulls the API's Persian `message` out of the thrown error's body text. */
function extractServerMessage(e: Error): string | null {
const match = e.message.match(/\{.*\}/)
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string }
return typeof parsed.message === 'string' ? parsed.message : null
} catch {
return null
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style scoped lang="less">
.auth {
background: @page;
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: @primary;
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: @primary;
}
.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: @page;
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>
+524
View File
@@ -0,0 +1,524 @@
<!--
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">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<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, onActivated, onMounted, reactive, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { Loading, 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 type { OrderDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const session = useSessionStore()
const reads = useCachedReads()
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[]>(reads.peek<OrderDetail[]>(KEYS.orders) ?? [])
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(force = false) {
if (!session.isLoggedIn) {
allOrders.value = []
return
}
loading.value = reads.peek(KEYS.orders) === undefined
try {
allOrders.value = await reads.myOrders({ force })
} catch (e) {
console.log(e)
} 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(true)
refreshing.value = false
}
/**
* Owner ruling 2026-10-12: fetch what the screen needs each time it is opened,
* rather than caching it with a lifetime — «هربار با رفتن به اون صفحه اون
* مقادیر مورد نیاز رو درخواست بزنه، هم سربار برای سرور نداره، هم درخواست الکی
* نمیخوره، هم دیتاها به‌روز میمونه».
*
* `force` matters because an order's status changes on the ADMIN side — nothing
* in this app invalidates the cache when that happens, so without it a list
* fetched once was shown unchanged for the rest of the session.
*
* `onActivated` as well as `onMounted`: this view is in MasterLayout's
* keep-alive list, so it is mounted once and every later visit only activates
* it. With `onMounted` alone the second visit made no request at all.
*
* The cached copy still renders instantly (`peek` above) and `loading` only
* shows a spinner when there is nothing to show, so the refresh is invisible.
*
* `activatedOnce` guards the first visit, where Vue runs both hooks — the same
* shape DashboardView uses. Without it the two calls would overlap; the cache
* would collapse them into one request anyway, but not firing twice is clearer
* than relying on that.
*/
onMounted(() => loadOrders(true))
let activatedOnce = false
onActivated(() => {
if (activatedOnce) void loadOrders(true)
activatedOnce = true
})
</script>
<style scoped lang="less">
.ord {
background: @page;
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: @primary;
border-color: @primary;
}
.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: alpha(@canvas, 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: @primary;
border-color: @primary;
}
.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,329 @@
<!--
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>
<!-- D27 §12: the balance in the accounting software, re-read on every visit. -->
<section v-if="balance && balance.source !== 'none'" class="prof__block">
<SectionHead title="مانده‌ی حساب" :subtitle="balanceSubtitle" />
<dl class="prof__specs">
<div class="prof__spec">
<dt>مانده‌ی طلا</dt>
<dd :class="sideClass(balance.gold)">{{ grams(balance.gold) }} <small>{{ sideLabel(balance.gold) }}</small></dd>
</div>
<div class="prof__spec">
<dt>مانده‌ی ریالی</dt>
<dd :class="sideClass(balance.rial)">{{ rials(balance.rial) }} <small>{{ sideLabel(balance.rial) }}</small></dd>
</div>
</dl>
</section>
<section v-else-if="balanceLoading" class="prof__block">
<SectionHead title="مانده‌ی حساب" subtitle="در حال دریافت از حسابداری…" />
<div class="prof__specs prof__skeleton" />
</section>
<section v-else-if="!loading" class="prof__empty">
<Icon icon="vuesax-linear:user" size="26" />
<p>اطلاعات حساب در دسترس نیست.</p>
</section>
<!--
"تکمیل پروفایل" — the reference showed this button here, on the profile,
and only while sts_verify_step_two was neither 1 nor 2
(henza_pwa .../components/profile.vue:96-102). The same condition holds
here against the target VerificationStep enum.
-->
<button
v-if="canCompleteProfile"
type="button"
class="prof__complete"
@click="router.push({ name: 'completeProfile' })"
>
تکمیل پروفایل
</button>
</container>
</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 SectionHead from '../../components/home/SectionHead.vue'
import { useSessionStore } from '../../stores/session'
import type { AccountingBalance, CustomerDetail } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
STEP_ONE_SUBMITTED: 'مرحله اول ثبت شده',
STEP_TWO_SUBMITTED: 'مرحله دوم ثبت شده',
VERIFIED: 'تایید شده',
REJECTED: 'رد شده',
}
const session = useSessionStore()
const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const loading = ref(false)
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
/**
* The reference gated the button on sts_verify_step_two being neither 1 nor 2,
* i.e. show it only while the documents have not been submitted. STEP_TWO_
* SUBMITTED and VERIFIED are those two states here.
*/
const canCompleteProfile = computed(() => {
const step = customer.value?.verificationStep
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
})
const router = useRouter()
// D27 §12 — fetched on its own, every time the page opens, so a slow
// accounting server never holds up the profile itself. The last copy fills the
// screen until the fresh one arrives.
const balance = ref<AccountingBalance | null>(reads.peek<AccountingBalance>(KEYS.balance) ?? null)
const balanceLoading = ref(false)
const num = (v: string | null) => Number(v ?? 0)
const sideLabel = (v: string | null) => (num(v) > 0 ? 'بدهکار' : num(v) < 0 ? 'بستانکار' : '')
const sideClass = (v: string | null) => (num(v) > 0 ? 'is-debt' : num(v) < 0 ? 'is-credit' : '')
const grams = (v: string | null) =>
`${Math.abs(num(v)).toLocaleString('fa-IR', { maximumFractionDigits: 3 })} گرم`
const rials = (v: string | null) => `${Math.abs(num(v)).toLocaleString('fa-IR')} ریال`
const balanceSubtitle = computed(() => {
const b = balance.value
if (!b?.at) return ''
const when = new Date(b.at).toLocaleString('fa-IR', { dateStyle: 'short', timeStyle: 'short' })
return b.source === 'stored' ? `آخرین مانده‌ی ثبت‌شده — ${when}` : `از ${b.provider ?? 'حسابداری'} — ${when}`
})
async function loadBalance() {
balanceLoading.value = !balance.value
try {
balance.value = await reads.accountingBalance({ force: true })
} catch {
balance.value = null
} finally {
balanceLoading.value = false
}
}
onMounted(async () => {
if (!session.isLoggedIn) return
void loadBalance()
loading.value = !customer.value
try {
customer.value = await reads.me({ force: true })
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.prof {
background: @page;
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: alpha(@green, 10%);
}
.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__spec dd.is-debt {
color: @wine;
}
.prof__spec dd.is-credit {
color: @green;
}
.prof__spec dd small {
font-weight: 500;
font-size: 11px;
}
.prof__skeleton {
height: 92px;
background: @hairlineSoft;
}
.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;
}
.prof__complete {
margin-top: 20px;
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: @primary;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,240 @@
<!--
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.
Submits to POST customer/auth/register (legacy POST /major/register). The new
customer is created inactive and lands in the admin's «مشتریان در انتظار»
list; they can sign in once an admin approves them.
-->
<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'
import { api } from '../../api/client'
import { toast } from '../../composables/useToast'
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
await api.register({
fname: String(formModel.fname ?? ''),
lname: String(formModel.lname ?? ''),
mobile: String(formModel.mobile ?? ''),
store_name: String(formModel.store_name ?? ''),
phone: String(formModel.phone ?? ''),
address: String(formModel.address ?? ''),
city: String(formModel.city ?? ''),
})
registeredInfo.value = true
} catch (e: unknown) {
toast.error(serverMessage(e) ?? 'خطا در ثبت اطلاعات. لطفاً دوباره تلاش کنید.')
} finally {
profileData.loading = false
}
}
/** The API's Persian `message` (duplicate number, bad mobile, throttle), if any. */
function serverMessage(e: unknown): string | null {
const match = e instanceof Error ? e.message.match(/\{.*\}/) : null
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string | string[] }
return Array.isArray(parsed.message) ? parsed.message[0] ?? null : parsed.message ?? null
} catch {
return null
}
}
</script>
<style scoped lang="less">
.auth {
background: @page;
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: @primary;
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,357 @@
<!--
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 type { LedgerEntrySummary } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
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 reads = useCachedReads()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>(reads.peek<LedgerEntrySummary[]>(KEYS.ledger) ?? [])
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
}
/**
* Owner ruling 2026-10-12: re-read on entering the screen.
*
* `force` is the point here. Ledger entries are written by the ACCOUNTING side,
* never by this app, so nothing here ever invalidated the cached copy — the
* first visit's list was shown for the rest of the session, and a document
* posted meanwhile stayed invisible until pull-to-refresh.
*
* This view is not in the keep-alive list, so `onMounted` runs on every visit
* and no `onActivated` is needed. The cached copy renders immediately (`peek`
* above), so the re-read is not visible as a loading state.
*/
onMounted(async () => {
if (!session.isLoggedIn) return
loading.value = reads.peek(KEYS.ledger) === undefined
try {
entries.value = await reads.myLedgerEntries({ force: true })
} finally {
loading.value = false
}
})
</script>
<style scoped lang="less">
.tx {
background: @page;
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: alpha(@green, 10%);
}
.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>