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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user