feat: initial commit

This commit is contained in:
s.esmaeili
2026-09-09 06:46:27 +03:30
commit 0ed6bed028
214 changed files with 20177 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
<!--
Ported from legacy henza_pwa/src/views/cart.vue.
"ثبت نهایی سفارش" (submit order) is wired for real against the actual,
already-existing POST /orders endpoint — not a stub. Legacy's own
`/set-order` payload had two hardcoded placeholder numbers with the
original author's own comment "how to get?!" (wage_weight/weight sent
regardless of actual cart contents) — a genuine, self-acknowledged
incompleteness in legacy itself, not something to faithfully reproduce.
The real Orders API computes gold weight server-side from
productVariantId + wagePercent, so there's nothing dubious to port here;
wagePercent is taken from the cart item's pinned variant.
ADAPTED (not in legacy's cart.vue): submitting now requires being logged in
for real (a real customerId is required by the Orders API). Legacy's own
router didn't gate the cart route itself, so this gate is new — reusing the
same login-nudge dialog pattern as Home/SingleProduct for a consistent UX
rather than a raw failure.
"لیست سفارشات" routes to `home` instead of the legacy `ordersList` route,
which doesn't exist in this port yet (deferred).
EXISTING ISSUE, ported as-is: the unscoped `.rtl { direction: ltr }` rule
below is dead — it can never actually apply, since the global `.rtl`
defined in style.less carries `!important` and a non-!important rule can
never override an !important one regardless of load order. Kept for fidelity.
-->
<template>
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<nav class="flex justify-center items-center">
<div class="flex mb-0 items-center h-[19px]">
<Icon icon="vuesax-linear:bag-happy" class="ml-2 text-white" size="19" />
<h4 class="bold mb-0 text-white">سبد خرید</h4>
</div>
<span />
</nav>
</AppHeader>
</div>
<div class="h-16"></div>
<container class="overflow-y-scroll pt-4">
<div v-show="cartData.step === 1">
<EmptyState v-if="cart.items.length === 0" :image="cartEmpty" />
<template v-else>
<CartItem
v-for="item in cart.items"
:value="item"
:key="item.pid + item.vid"
class="mb-4"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</template>
</div>
<div v-show="cartData.step === 2" class="h-full flex justify-center items-center flex-col">
<h4 class="text-primary">اطلاعات سفارش با موفقیت ثبت شد</h4>
<div class="mb-2 text-secondary">شماره سفارش {{ cartData.orderID }}</div>
<small class="mb-2 text-secondary">برای بررسی وضعیت سفارش خود روی لیست سفارشات کلیک کنید</small>
<Button
type="primary"
@click="
async () => {
await router.push({ name: 'home' });
cartData.step = 1;
}
"
>
لیست سفارشات
</Button>
</div>
</container>
<Drawer v-model:visible="cartData.showPopUp" placement="bottom" v-if="cartData.tempRow?.product" :closable="false" height="280px" :class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']">
<div class="mb-2 font-semibold">توضیح برای محصول {{ cartData.tempRow.product.title }}</div>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote" class="henza-form bg-primaryLighten">
<FormItem has-feedback name="note">
<Textarea v-model:value="formModel.note" :rows="5" />
</FormItem>
<Button class="btn-px-double w-full" type="primary" size="large" html-type="submit">ثبت</Button>
</Form>
</Drawer>
<DialogVant :timeOut="-1" v-model:show="cartData.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="dialogEmpty4" alt="" class="opacity-70" />
</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="submitOrder">بله</div>
<div class="text-center text-[#6b7280]" @click="cartData.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<div class="h-36"></div>
<div class="shadowset flex flex-row-reverse p-[16px] pb-[75px] justify-between fixed bottom-0 w-full bg-white z-[2] items-center border-t border-solid border-primary/10 shadow-md" v-if="cart.items.length > 0 && cartData.step === 1">
<div class="text-[16px]">
جمع وزن :
<span class="font-bold">{{ cart.total() }}</span
><span class="pr-1">گرم</span>
</div>
<Button class="w-1/2" type="primary" @click="cartData.notify = true">ثبت نهایی سفارش</Button>
</div>
</Spinner>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import cartEmpty from '../assets/img/empty/cartEmpty.svg'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import Spinner from '../components/Spinner.vue'
import EmptyState from '../components/EmptyState.vue'
import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
const router = useRouter()
const cart = useCartStore()
const session = useSessionStore()
const loginNotify = ref(false)
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 }
}
const submitOrder = async () => {
cartData.notify = false
if (!session.customerId) {
loginNotify.value = true
return
}
try {
cartData.loadingPage = true
const order = await api.createOrder({
customerId: session.customerId,
items: cart.items.map((item) => {
const variant = cart.variant(item)
return {
productVariantId: item.vid,
quantity: item.qty,
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
}
}),
})
cartData.orderID = order.orderNumber
cartData.step = 2
cart.clear()
} catch (e) {
console.log(e)
} finally {
cartData.loadingPage = false
}
}
onMounted(async () => {
await cart.init()
})
</script>
<style lang="less">
.rtl {
direction: ltr;
.ant-form-vertical .ant-form-item-label,
.ant-col-24.ant-form-item-label,
.ant-col-xl-24.ant-form-item-label {
text-align: right !important;
}
}
.btn-px-double {
padding-right: 24px !important;
padding-left: 24px !important;
}
.ant-btn-primary[disabled],
.ant-btn-primary[disabled]:hover,
.ant-btn-primary[disabled]:focus,
.ant-btn-primary[disabled]:active {
border-color: @primary-color;
background: @primary-color;
}
.shadowset {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
border-radius: 10px 10px 0 0;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<!--
Ported from legacy henza_pwa/src/views/home/index.vue.
Honest adaptations:
- legacy fetched a single curated payload from POST /front (categories
with icons, a hand-picked "newest" list, a hand-picked "shelf" list, a
home banner slider config). The new API only exposes GET
/catalog/products (flat, uncurated), so `configStore` supplies the
banner slides (placeholder artwork, legacy's exact slide shape) and
derives the real category list from the product catalogue. The two
product sections both draw from the same flat product list (first 4 as
"newest", the rest as the shelf) rather than two independently curated
lists.
- the "please login" nudge (DialogVant, 5s after load) is ported and will
fire, since there is genuinely no login yet — matching legacy's real
behavior for an unauthenticated visitor.
- the notification bell (`NotificationList`) was already commented out of
legacy's own template — not ported, matching legacy's actual current state.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<AppHeader>
<nav class="flex justify-between items-center">
<span></span>
<img src="/img/henza1.svg" alt="هنزاگلد" />
</nav>
</AppHeader>
<container class="px-[16px] pb-[66px]" v-if="flag">
<HomeSlider class="z-0 mx-[-16px]" :slides="memory.config.main_slider" :height="sliderHeight" />
<section>
<h5 class="font-bold">دسته بندی ها</h5>
<div class="no-scrollbar grid grid-cols-3 gap-4 mt-[16px] items-stretch">
<Category class="h-full" v-for="item in memory.cats" :key="item.id" :value="item" />
</div>
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
<h4 class="font-bold">محصولات جدید</h4>
<ProductHorizontal class="mt-[10px]" v-for="item in newestProducts" :key="item.id" :value="item" />
<router-link :to="{ name: 'newestProduct' }">
<Button :type="('light' as any)" class="mt-[16px]" block size="large">
<template #icon>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="25" />
</template>
مشاهده همه
</Button>
</router-link>
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
<div class="no-scrollbar flex overflow-x-auto mt-[16px] mx-[-16px] px-[16px]">
<ProductVertical class="flex-[0_0_280px] ml-[16px]" v-for="item in shelfProducts" :key="item.id" :value="item" />
<ViewAll class="last:ml-0 flex-[0_0_280px]" to="/search" />
</div>
</section>
</container>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</Spinner>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Divider } from 'ant-design-vue'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import Spinner from '../components/Spinner.vue'
import DialogVant from '../components/DialogVant.vue'
import Category from '../components/product/Category.vue'
import ProductHorizontal from '../components/product/ProductHorizontal.vue'
import ProductVertical from '../components/product/ProductVertical.vue'
import ViewAll from '../components/ViewAll.vue'
import HomeSlider from '../components/slider/HomeSlider.vue'
import vectorIcon from '../assets/img/Vector.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import { useWindowDataStore } from '../stores/windowDataStore'
import { useConfigStore } from '../stores/configStore'
import { isUserLoggedIn } from '../utils/checkAccess'
import { api, type ProductSummary } from '../api/client'
const router = useRouter()
const windowData = useWindowDataStore()
// legacy's `memory` — the global config store (banner slider + categories)
const memory = useConfigStore()
const loading = ref(false)
const flag = ref(false)
const loginNotify = ref(false)
const products = ref<ProductSummary[]>([])
const newestProducts = computed(() => products.value.slice(0, 4))
const shelfProducts = computed(() => products.value.slice(4))
const sliderHeight = computed(() => Math.min(windowData.width, 768) / 2.2125 + 'px')
const getFront = async () => {
try {
loading.value = true
products.value = await api.listProducts()
flag.value = true
setTimeout(() => {
if (!isUserLoggedIn()) {
loginNotify.value = true
}
}, 5000)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
// legacy home/index.vue ran getFront() alongside the global store init, which
// is what populated the slider + category grid.
await Promise.all([getFront(), memory.init()])
})
</script>
+36
View File
@@ -0,0 +1,36 @@
<!-- Ported from legacy henza_pwa/src/views/newest.vue.
See useProducts.ts for the pagination-simplification note. -->
<template>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<AppHeader>
<nav class="flex justify-start items-center">
<router-link :to="{ name: 'home' }" class="h-[25px] w-[25px]">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="28" />
</router-link>
<h4 class="text-white bold mb-0 mr-[20%] md:mr-[40%]">جدید ترین محصولات</h4>
</nav>
</AppHeader>
<ProductList padding="81px" class="px-[16px] pb-[66px]" @pull="pull" :value="productsData.productList" :loading="loadingPull" :flag="productsData.flag" />
</Spinner>
</template>
<script setup lang="ts">
import { onBeforeMount, ref } from 'vue'
import Icon from '../components/icon/Icon.vue'
import AppHeader from '../components/AppHeader.vue'
import Spinner from '../components/Spinner.vue'
import ProductList from '../components/product/ProductList.vue'
import { useProducts } from '../composables/useProducts'
const { productsData, getProducts } = useProducts()
const loadingPage = ref(false)
const loadingPull = ref(false)
onBeforeMount(async () => {
await getProducts(loadingPage, false)
})
const pull = async () => {
await getProducts(loadingPull, true)
}
</script>
@@ -0,0 +1,71 @@
<!--
Ported from legacy henza_pwa/src/views/search/collection/index.vue.
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.
-->
<template>
<div>
<AppHeader>
<nav class="grid grid-cols-10 justify-center items-center">
<div class="w-full col-span-1" @click="router.push({ name: 'search' })">
<Icon icon="vuesax-linear:arrow-right-1" class="ml-2 text-white" size="19" />
</div>
<div class="w-full col-span-8 flex mb-0 justify-center items-center h-[19px]">
<Icon icon="vuesax-linear:money-2" class="ml-2 text-white" size="19" />
<h4 class="bold mb-0 text-white">{{ route.query.page }}</h4>
</div>
<div class="w-full col-span-1"></div>
</nav>
</AppHeader>
<div class="flex flex-col gap-4 m-4 p-2 bg-gray-50 h-[90vh] rounded-lg">
<span class="text-center font-bold mt-2">برای مشاهده جزئیات محصول روی آیتم ضربه بزنید</span>
<Spin :spinning="loadingPage">
<div class="grid grid-cols-4 gap-8 overflow-y-auto mx-4 max-h-[85vh] pb-10">
<CollectionCard v-for="item in collectionProducts" :key="item.id" @click="openDrawer(item.id)" :item="item" />
</div>
</Spin>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="closeDrawer" :key="activeProductId" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Drawer, Spin } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import CollectionCard from '../../components/product/CollectionCard.vue'
import SingleProduct from '../../components/product/SingleProduct.vue'
import { api, type ProductSummary } from '../../api/client'
const route = useRoute()
const router = useRouter()
const visible = ref(false)
const activeProductId = ref<string | null>(null)
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const collectionProducts = computed(() => allProducts.value.filter((p) => p.collections.some((c) => c.collectionId === route.params.id)))
onMounted(async () => {
loadingPage.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loadingPage.value = false
}
})
function openDrawer(id: string) {
activeProductId.value = id
visible.value = true
}
function closeDrawer() {
visible.value = false
}
</script>
@@ -0,0 +1,118 @@
<!--
Ported from legacy henza_pwa/src/views/search/searchPage.vue. All filtering
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).
-->
<template>
<Spinner :spinning="loadingPage" tip="درحال دریافت اطلاعات">
<header class="w-full z-10 top-0 bg-white shadow-md fixed">
<div class="p-[16px]">
<div class="pb-[10px] flex w-fit justify-center items-center text-primary cursor-pointer" v-if="showFilter" @click="hideFilters()">
<Icon icon="vuesax-linear:arrow-right-1" class="ml-1" size="25" />
بازگشت
</div>
<SearchInput @search="search" place-holder="جستجوی دسته بندی، محصولات و ..." />
</div>
<div class="no-scrollbar flex overflow-x-auto my-[16px] mt-0 pr-[16px]" v-if="!showFilter">
<Button :type="('secondary' as any)" class="btn-secondary ml-[8px] whitespace-nowrap" v-if="filterKeys.length > 0" @click="showFilters">{{ `${filterKeys.length} فیلتر` }}</Button>
<Button :type="('secondary' as any)" class="ml-[8px] whitespace-nowrap" v-else @click="showFilters">اعمال فیلتر</Button>
<div class="flex-1 flex overflow-x-auto no-scrollbar pl-[16px]">
<Button :type="('secondary' as any)" :ghost="!!filters.query.cats" class="last:ml-0 ml-[8px] whitespace-nowrap" @click="setCats(undefined)">همه</Button>
<Button
:type="('secondary' as any)"
:ghost="!(filters.query.cats && filters.query.cats[0] === item.id)"
class="last:ml-0 ml-[8px] whitespace-nowrap"
v-for="item in availableCategories"
:key="item.id"
@click="setCats(item.id)"
>{{ item.title }}</Button
>
</div>
</div>
</header>
<Filters v-model:filterKeys="filterKeys" v-show="showFilter" v-model="filters.query" :products="allProducts" @change="hideFilters" />
<ProductList class="px-[16px] pb-[66px]" padding="146px" v-show="!showFilter" @pull="() => {}" :value="filteredProducts" :ciny="availableCollections" :loading="false" :flag="filteredProducts.length === 0" />
</Spinner>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Button } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import Spinner from '../../components/Spinner.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import Filters from '../../components/search/Filters.vue'
import ProductList from '../../components/product/ProductList.vue'
import { useFiltersStore } from '../../stores/filtersStore'
import { useNavStore } from '../../stores/navStore'
import { api, type ProductSummary } from '../../api/client'
const filters = useFiltersStore()
const nav = useNavStore()
const loadingPage = ref(false)
const allProducts = ref<ProductSummary[]>([])
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
const availableCategories = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
if (p.category) seen.set(p.category.id, p.category)
}
return [...seen.values()]
})
const availableCollections = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of allProducts.value) {
for (const c of p.collections) seen.set(c.collection.id, { id: c.collection.id, title: c.collection.title })
}
return [...seen.values()]
})
const filteredProducts = computed(() => {
let list = allProducts.value
const q = filters.query
if (q.text) list = list.filter((p) => p.title.includes(q.text!))
if (q.cats && q.cats.length > 0) list = list.filter((p) => p.category && q.cats!.includes(p.category.id))
if (q.brands.length > 0) list = list.filter((p) => p.brand && q.brands.includes(p.brand.id))
if (q.limit_gram.length === 2) {
list = list.filter((p) => p.variants.some((v) => parseFloat(v.rashWeightGrams) >= q.limit_gram[0] && parseFloat(v.rashWeightGrams) <= q.limit_gram[1]))
}
return list
})
async function loadProducts() {
loadingPage.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loadingPage.value = false
}
}
function search(value: string | undefined) {
filters.query.text = value
}
function setCats(id: string | undefined) {
filters.query.cats = id ? [id] : undefined
}
function showFilters() {
showFilter.value = true
nav.setNavVisibility(false)
}
function hideFilters(event?: { resetFilters: boolean }) {
showFilter.value = false
nav.setNavVisibility(true)
if (event?.resetFilters) {
filters.reset()
filterKeys.value = []
}
}
onMounted(loadProducts)
</script>
@@ -0,0 +1,192 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue —
the most heavily adapted view in this port so far. 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. In the redesigned schema (see the original migration
audit), sales agents are AdminUser records with no customer-facing session
at all — there is no "type" field on our Customer model, so there is
nothing to adapt here; the underlying 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. Shown as
a disabled tile rather than a broken link.
REPLACED, not dropped: legacy's "گزارش حساب" van-action-sheet called out to
the external Tahesab accounting service (hesabMe()) that the very first
migration audit explicitly decided to retire in favor of this system's own
internal ledger — this dashboard's balance card already shows that real,
internal balance directly, so there's no separate popup needed.
DROPPED: the multi-row world gold/currency price ticker (legacy's GET
/price, ~14 rows). The new API has one single-karat quote endpoint
(GET /pricing/latest) — shown as one line instead of fabricating rows that
don't exist.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<div></div>
<h3 class="text-white bold mb-0 text-center w-full">میز کار</h3>
<Icon v-if="session.customerId" icon="vuesax-linear:logout" @click="data.notify = true" class="cursor-pointer text-white absolute left-5" size="25" />
</div>
</AppHeader>
</div>
<div class="h-16"></div>
<container class="overflow-y-scroll pt-4">
<div v-if="session.customerId">
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="grid grid-cols-5 items-center pb-2 mb-2 border-b-2 border-solid border-[#E5E7EB]">
<h5 class="font-bold col-span-2 mb-0">{{ customer?.fullName }}</h5>
</div>
<div class="flex justify-between">
<div>شماره تلفن همراه</div>
<div>{{ session.mobile }}</div>
</div>
</div>
</div>
<div v-else>
<div class="rounded-[10px] bg-[#F9F9F9] p-[16px] w-full mb-[16px] border-[#E5E7EB] border border-solid">
<div class="text-center font-bold">برای استفاده از امکانات نرم افزار ابتدا وارد شوید</div>
<div class="flex w-full justify-center mt-2" @click="router.push({ name: 'login' })">
<Button type="primary" class="w-[17rem]">ورود/ثبت نام</Button>
</div>
</div>
</div>
<div class="rounded-[10px] bg-light p-[16px] w-full mb-[16px] bg-[#F9F9F9]" v-if="customer">
<div class="flex justify-between">
<div>
<div class="text-[12px] mb-2">{{ remainText }}</div>
<div class="font-bold">{{ Math.abs(parseFloat(customer.goldDebtGrams)) }} گرم</div>
</div>
<div>
<div class="text-[12px] mb-2">بدهی ریالی</div>
<div class="font-bold">{{ priceUnit(customer.fiatDebtRial) }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-6 my-4">
<div v-for="item in list" :key="item.id" class="grid col-span-1 cursor-pointer" @click="goToDetails(item.id)">
<div class="border border-solid border-[#E5E7EB] bg-[#F9F9F9] rounded-[5px] flex justify-center items-center py-4" :class="{ 'opacity-40': item.disabled }">
<Icon :icon="item.icon" />
</div>
<div class="text-center text-[14px] font-medium pt-2">{{ item.title }}</div>
</div>
</div>
<div class="rounded-[10px] text-secondary col-span-6 mb-24" v-if="goldPrice">
<div class="flex items-center justify-between bg-[#1C2D56] text-[#ffffff] p-3 rounded">
<div class="font-bold text-[16px]">نرخ طلای ۱۸ عیار</div>
<div>{{ jdate(goldPrice.quotedAt) }}</div>
</div>
<div class="grid grid-cols-1 mt-2">
<div class="bg-[#F9F9F9] rounded flex justify-between items-center p-2">
<div class="flex items-center">
<div class="bg-white rounded w-9 h-9 flex justify-center items-center">
<Icon class="text-primary" icon="vuesax-linear:number-18" />
</div>
<div class="text-[#1C2D56] text-[15px] font-semibold mr-3">هر گرم طلای ۱۸ عیار</div>
</div>
<div class="text-[#1C2D56] text-[18px] font-semibold flex items-center">
{{ price(goldPrice.pricePerGramRial) }}
<div class="text-xs text-[#8D6A46] pr-0.5 font-medium">ریال</div>
</div>
</div>
</div>
</div>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</container>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.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, priceUnit } from '../../utils/filters'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const remainText = computed(() => (customer.value && parseFloat(customer.value.goldDebtGrams) > 0 ? 'بدهکار گرمی' : 'بستانکار گرمی'))
const data = reactive({ notify: false, loginNotify: false })
const list = [
{ 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 },
]
function goToDetails(id: number) {
if (!session.customerId) {
data.loginNotify = true
return
}
if (id === 0) router.push({ name: 'ordersList' })
if (id === 1) router.push({ name: 'transactionsList' })
if (id === 2) router.push({ name: 'profileData' })
if (id === 3) router.push({ name: 'favoritesList' })
}
function logOut() {
session.logout()
data.notify = false
router.push({ name: 'home' })
}
onMounted(async () => {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
})
</script>
@@ -0,0 +1,50 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/favorits.vue.
Real, not stubbed: favorites are the local favoritesStore (per-device, no
backend persistence — see favoritesStore.ts from an earlier batch), filtered
against the real product catalog client-side. No pagination — the whole
catalog is fetched once (same "small catalog" approach as everywhere else).
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">علاقمندی</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-16"></div>
<Spinner :spinning="loading" tip="در حال دریافت اطلاعات">
<ProductList class="px-[16px] pt-8" padding="px" :flag="favoriteProducts.length === 0" :value="favoriteProducts" :loading="false" is-fav @pull="() => {}" />
</Spinner>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import ProductList from '../../components/product/ProductList.vue'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { api, type ProductSummary } from '../../api/client'
const router = useRouter()
const favorites = useFavoritesStore()
const loading = ref(false)
const allProducts = ref<ProductSummary[]>([])
const favoriteProducts = computed(() => allProducts.value.filter((p) => favorites.isFavorite(p.id)))
onMounted(async () => {
loading.value = true
try {
allProducts.value = await api.listProducts()
} finally {
loading.value = false
}
})
</script>
@@ -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>
+197
View File
@@ -0,0 +1,197 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue.
Per your decision: OTP-based flow ported structurally, but the SMS-send step
is stubbed — no SMS provider exists. What's real vs. stubbed:
- Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. If it matches a real seeded customer, that customer's
exact legacy-notify message path is preserved: a non-match shows
legacy's own "اطلاعات کاربری در سیستم وجود ندارد" text (legacy's actual
404 handling), not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
there is no real code to check against. On "verify," the session store
logs in as the matched real customer (session.login), so downstream
features (balance, ledger, favorites-as-logged-in) work against real
seeded data. This is the "session wiring" — apps/api itself is untouched.
- `token.fireBaseToken` (Firebase push token) doesn't exist in this port
(Firebase was already deferred) — omitted from the stub verify call.
- `useOtpToken()` (WebOTP browser autofill) was already dead in legacy
itself (its call was commented out in App.vue, and even active, nothing
ever read its output into the OTP input) — not ported.
-->
<template>
<div>
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ورود</h3>
<div></div>
</div>
</AppHeader>
<div class="flex justify-center mb-8 mt-5">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div>
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div>
</div>
</div>
</div>
<div class="w-full text-center font-bold text-black text-lg">ورود به اپلیکیشن هنزا گلد</div>
<div class="px-8 custom-form">
<OtpInput
class="w-full mb-[10px] text-black mt-5"
v-show="body.step == 2 && timer > 0"
input-classes="otp-input"
inputType="tel"
:num-inputs="4"
:should-auto-focus="true"
:defaultFocus="body.defaultFocus"
@on-complete="sendToken"
/>
<div v-show="body.step == 2 && timer === 0" class="text-center mb-[10px] mt-5">برای دریافت رمز عبور مجدد تلاش کنید</div>
<div v-show="body.step == 2 && timer !== 0" class="text-center text-xl mb-[10px] mt-5">
امکان دریافت مجدد رمز در &nbsp;{{ ' ' + counterZeroShow(timer) + ' ' }} ثانیه دیگر
</div>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="w-full">
<FormItem name="username" label="تلفن همراه" v-show="body.step == 1" :rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]">
<Input type="number" v-model:value="formModel.username" />
</FormItem>
<Button class="btn-px-double" type="primary" html-type="submit" size="large" block :loading="body.loading" :disabled="timer > 0 && body.step === 2">
{{ btnText }}
</Button>
</Form>
<div class="flex justify-center mt-5">
<div>اگر ثبت نام نکرده اید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', true)">ثبت نام کنید.</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Form, FormItem, Input } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import editImg from '../../assets/img/henza.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import NotifyVant from '../../components/NotifyVant.vue'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerSummary } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
const body = reactive({
step: 1,
loading: false,
defaultFocus: false,
})
let matchedCustomer: CustomerSummary | null = null
const timer = ref(0)
const countDownTimer = () => {
if (timer.value > 0) {
setTimeout(() => {
if (timer.value > 0) {
timer.value -= 1
countDownTimer()
}
}, 1000)
}
}
const sendPhone = async () => {
try {
body.loading = true
const customers = await api.listCustomers()
matchedCustomer = customers.find((c) => c.mobile === formModel.username) ?? null
if (!matchedCustomer) {
notify.text = 'اطلاعات کاربری در سیستم وجود ندارد.'
notify.show = true
return
}
body.step = 2
timer.value = 120
countDownTimer()
await nextTick(() => {
body.defaultFocus = true
})
} catch (e) {
console.log(e)
} finally {
body.loading = false
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sendToken = async (_otpCode: string) => {
// Stub: the OTP code itself isn't verified (no SMS was actually sent to
// check it against). `matchedCustomer` was already confirmed real in
// sendPhone(), so any 4-digit code here logs in as that customer.
try {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
await router.push({ name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} finally {
body.loading = false
}
}
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
</script>
<style lang="less">
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
.custom-form {
.otp-input {
width: 50px;
height: 50px;
padding: 5px;
margin: 0;
font-size: 20px;
background: transparent;
border: 0 solid @black;
border-bottom-width: 2px;
text-align: center;
&.error {
border: 1px solid @danger !important;
}
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
</style>
+282
View File
@@ -0,0 +1,282 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Orders.vue.
Adapted:
- Status filter tabs use our real OrderStatus enum values directly (see
utils/orderStatus.ts) rather than legacy's own inconsistent tab labels.
- Fetches real order history via the already-existing GET
/customers/:id/orders endpoint, filtered client-side by tab (that
endpoint has no server-side status filter — small enough dataset that
client-side filtering is reasonable, and apps/api stays untouched).
- Search matches against `orderNumber` (our real user-facing identifier)
instead of legacy's numeric `id`.
DROPPED: "دریافت فاکتور" (get invoice) button — legacy's handler opened a
hardcoded link to the real legacy production domain
(https://henzagold.com/oapi/...). Not just unimplemented — an actual
external production URL that must never be carried into this port. No
invoice-generation endpoint exists in the new API either.
-->
<template>
<div class="fixed top-0 z-10 w-full bg-white">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'home' })" />
<h3 class="text-white bold mb-0">سفارشات</h3>
<div></div>
</div>
</AppHeader>
<div class="pt-3 px-2 pb-3 mb-3 border-b-solid border-[#E5E7EB] w-full">
<SearchInput @search="readSearch" place-holder="جستجو" />
</div>
</div>
<div class="h-36"></div>
<container class="bg-[#F9FAFB] px-2 orders">
<Tabs v-model:activeKey="activeTab" style="padding-right: 0.2rem !important; font-weight: bold">
<TabPane v-for="item in tabs" :key="item.key" :tab="item.title" force-render>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-y-scroll no-scrollbar">
<OrderCard @view="openModal" v-for="item in filteredOrders" :key="item.id" :value="item" />
<EmptyState v-if="filteredOrders.length === 0" :image="ordersEmpty" />
</Spinner>
</TabPane>
</Tabs>
<Modal v-model:visible="viewData.model" :mask="false" :footer="false" :closable="false" class="henza-custom-modal" v-if="viewData.item">
<div class="flex justify-between items-center p-4">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="viewData.model = false" />
<h3 class="font-semibold mb-0">جزئیات سفارش</h3>
<div></div>
</div>
<container>
<div class="overflow-y-auto h-screen">
<div class="flex justify-between items-center pt-4">
<span class="font-semibold text-[14px]">اطلاعات سفارش</span>
</div>
<div class="responsive-steps">
<div class="steps-container">
<div v-for="(step, index) in ORDER_STEPS" :key="index" class="step" :class="{ active: currentStep === index }">
<div class="step-circle">{{ index + 1 }}</div>
<div class="step-title">{{ step }}</div>
<div v-if="index !== 0" class="step-line"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 border-[1.5px] border-[#E5E7EB] border-solid mt-4 rounded-[6px]">
<div class="grid col-span-1 p-4 text-[#9CA3AF] text-[12px] font-medium">
<div>شماره سفارش :</div>
<div class="pt-4">وضعیت سفارش :</div>
<div class="pt-4">تعداد کالاها :</div>
<div class="pt-4">وزن سفارش :</div>
<div class="pt-4">تاریخ سفارش :</div>
</div>
<div class="grid col-span-1 text-left p-4">
<div class="font-bold text-[18px] text-[#23335B]">{{ viewData.item.orderNumber }}</div>
<div class="pt-4 text-center flex justify-end">
<div class="rounded-[5px] py-[4px] px-[12px] text-[14px] font-semibold w-fit" :style="{ background: modalColor?.background, color: modalColor?.font }">
{{ modalStatusLabel }}
</div>
</div>
<div class="pt-4">
<span class="text-[16px] font-bold pl-2 text-black">{{ viewData.item.items.length }}</span>
<span class="text-[14px] font-medium">آیتم</span>
</div>
<div class="pt-4 font-medium">
<span class="text-[16px] font-bold pl-1 text-black">{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }}</span>
<span class="pr-2">گرم</span>
</div>
<div class="pt-4 font-semibold text-[#1E2229]">{{ jdate(viewData.item.createdAt) }}</div>
</div>
</div>
<div>
<div class="font-semibold mt-4">آیتم های سفارش</div>
<div class="w-full max-w-screen-md m-auto h-full flex flex-col">
<div class="flex-1 py-[10px] pb-[70px]">
<OrderItemCard v-for="item in viewData.item.items" :key="item.id" :value="item" class="border-[#E5E7EB] border-[1.5px] border-solid text-primary bg-[#F9F9F9] rounded-[6px] mb-4" />
</div>
</div>
</div>
</div>
</container>
</Modal>
</container>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Modal, TabPane, Tabs } from 'ant-design-vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, ORDER_STEPS, orderStepIndex } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const tabs = [
{ title: 'همه', key: 'ALL' },
{ title: ORDER_STATUS_LABELS.SUSPENDED, key: 'SUSPENDED' },
{ title: ORDER_STATUS_LABELS.PLACED, key: 'PLACED' },
{ title: ORDER_STATUS_LABELS.ADMIN_APPROVED, key: 'ADMIN_APPROVED' },
{ title: ORDER_STATUS_LABELS.SOURCING, key: 'SOURCING' },
{ title: ORDER_STATUS_LABELS.PREPARING, key: 'PREPARING' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_AGENT, key: 'DELIVERED_TO_AGENT' },
{ title: ORDER_STATUS_LABELS.DELIVERED_TO_CUSTOMER, key: 'DELIVERED_TO_CUSTOMER' },
{ title: ORDER_STATUS_LABELS.CANCELLED, key: 'CANCELLED' },
]
const activeTab = ref('ALL')
const loading = ref(false)
const allOrders = ref<OrderDetail[]>([])
const searchTerm = ref<string | undefined>(undefined)
const filteredOrders = computed(() => {
let list = allOrders.value
if (activeTab.value !== 'ALL') list = list.filter((o) => o.status === activeTab.value)
if (searchTerm.value) list = list.filter((o) => o.orderNumber.includes(searchTerm.value!))
return list
})
function readSearch(value: string | undefined) {
searchTerm.value = value
}
async function loadOrders() {
if (!session.customerId) return
loading.value = true
try {
allOrders.value = await api.getCustomerOrders(session.customerId)
} finally {
loading.value = false
}
}
const viewData = reactive<{ model: boolean; item: OrderDetail | null }>({ model: false, item: null })
const modalStatusLabel = computed(() => (viewData.item ? ORDER_STATUS_LABELS[viewData.item.status] ?? viewData.item.status : ''))
const modalColor = computed(() => (viewData.item ? ORDER_STATUS_COLORS[viewData.item.status] : undefined))
const currentStep = computed(() => (viewData.item ? orderStepIndex(viewData.item.status) : 0))
function openModal(item: OrderDetail) {
viewData.item = item
viewData.model = true
}
onMounted(loadOrders)
</script>
<style lang="less">
.henza-custom-modal,
.henza-custom-modal .ant-modal-body,
.henza-custom-modal .ant-modal-content {
border-radius: 0;
width: 100vw !important;
max-width: 100vw;
margin: 0;
height: 100vh;
top: 0;
padding: 0;
direction: rtl;
}
.orders {
min-height: calc(100vh - 150px);
height: fit-content;
}
.orders .ant-tabs > .ant-tabs-nav,
.ant-tabs > div > .ant-tabs-nav {
background-color: white;
position: fixed;
z-index: 10;
overflow-x: auto;
width: 100%;
max-width: 768px;
margin: auto;
left: 0;
right: 0;
padding-right: 10px;
padding-left: 10px;
border-bottom-style: solid;
border-bottom-color: #e5e7eb;
border-bottom-width: 1px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.responsive-steps {
width: 100%;
padding: 10px;
.steps-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
text-align: center;
position: relative;
.step-circle {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #9ca3af;
z-index: 1;
}
.step-title {
margin-top: 6px;
font-size: 12px;
white-space: normal;
word-break: break-word;
}
.step-line {
position: absolute;
top: 15px;
left: 50%;
width: 100%;
height: 2px;
background-color: #e5e7eb;
z-index: 0;
transform: translateX(0);
}
&.active .step-circle {
background-color: @primary-color;
color: white;
}
}
}
@media (max-width: 600px) {
.step-title {
font-size: 11px;
}
.step-circle {
width: 24px;
height: 24px;
font-size: 10px;
}
}
</style>
@@ -0,0 +1,93 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/profile.vue,
heavily simplified — 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, faithfully styled: the avatar circle and the real read-only
info card (name/mobile/national code/verification status/city), all backed
by real fields the API already returns.
DROPPED: "تکمیل پروفایل" (complete profile) button, "افزودن کاربر" (add
sub-user) FAB, the "کارشناس فروش" (sales agent) contact card — that last
one needs the referring AdminUser's name/mobile, which the current
GET /customers/:id response doesn't expose (only referredByAgentId, a bare
id) — not resolved here to keep apps/api untouched this batch.
-->
<template>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="t bold mb-0">اطلاعات کاربری</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-20"></div>
<container>
<div class="flex justify-center mb-8">
<div class="h-[150px] w-[150px] bg-[#F8F7F6] rounded-full flex justify-center items-center bg-opacity-50">
<div class="bg-[#F9F5F0] h-[120px] w-[120px] flex justify-center items-center rounded-full">
<div class="rounded-full w-[90px] h-[90px] flex justify-center bg-[#F1E8DE]">
<img :src="editImg" class="p-6" alt="henza" />
</div>
</div>
</div>
</div>
<div v-if="customer" class="border-solid border-[1px] border-[#F3F4F6] rounded-[6px] p-8 mt-2 bg-[#FEFEFE]">
<div class="flex justify-between">
<div class="text-[#9CA3AF] font-medium">نام و نام خانوادگی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.fullName }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">تلفن همراه :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.mobile }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">کد ملی :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ customer.nationalCode }}</div>
</div>
<div class="flex justify-between mt-8">
<div class="text-[#9CA3AF] font-medium">وضعیت احراز هویت :</div>
<div class="text-[#374151] text-[16px] font-bold text-left">{{ VERIFICATION_LABELS[customer.verificationStep] }}</div>
</div>
</div>
</container>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import editImg from '../../assets/img/henza.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail } from '../../api/client'
const VERIFICATION_LABELS: Record<string, string> = {
UNVERIFIED: 'احراز نشده',
STEP_ONE_SUBMITTED: 'مرحله اول ثبت شده',
STEP_TWO_SUBMITTED: 'مرحله دوم ثبت شده',
VERIFIED: 'تایید شده',
}
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
onMounted(async () => {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
})
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>
@@ -0,0 +1,128 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue.
DROPPED as confirmed-dead code (not "possibly dead" — verified unreachable):
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 (the template binds
`formModel.*`, a *different*, separately-declared empty reactive object —
`userFormModel` is never referenced anywhere), are not ported.
STUBBED, not real: POST /major/register — apps/api has no create-customer
endpoint. Submitting shows legacy's exact success dialog without actually
persisting anything.
-->
<template>
<div>
<div class="fixed w-full">
<img class="w-full absolute left-0 right-0" :src="pattern1" alt="" />
<img class="absolute w-full left-0 right-0" :src="pattern2" alt="" />
</div>
<div class="fixed top-0 z-10 w-full">
<AppHeader class="bg-white shadowHeader">
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer" size="25" @click="router.push({ name: 'home' })" />
<h3 class="t bold mb-0">ثبت نام</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="relative w-full py-16 px-5">
<div class="mt-14">
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<Form :model="formModel" layout="vertical" @finish="register" class="w-full">
<FormItem style="font-size: 16px" name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" />
</FormItem>
<FormItem style="font-size: 16px" name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
</FormItem>
<FormItem style="font-size: 16px" name="mobile" label="شماره موبایل" :rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]">
<Input v-model:value="formModel.mobile" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" />
</FormItem>
<FormItem style="font-size: 16px" name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input v-model:value="formModel.phone" type="number" />
</FormItem>
<FormItem style="font-size: 16px" name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" />
</FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" />
</FormItem>
<Button type="primary" class="w-full max-w-[768px] m-auto" style="height: 42px" html-type="submit">
<span style="font-size: 18px">ثبت اطلاعات</span>
</Button>
</Form>
</Spinner>
<div class="flex justify-center mt-10">
<div>اگر حساب کاربری دارید از این قسمت</div>
<div class="pr-1 text-primary font-bold" @click="emit('register', false)">وارد شوید.</div>
</div>
</div>
</div>
<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 { Button, Form, FormItem, Input, Textarea } from 'ant-design-vue'
import pattern1 from '../../assets/img/pattern2.svg'
import pattern2 from '../../assets/img/pattern3.svg'
import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const profileData = reactive({ loading: false })
const registeredInfo = ref(false)
const formModel = reactive({
fname: undefined as string | undefined,
lname: undefined as string | undefined,
mobile: undefined as string | undefined,
store_name: undefined as string | undefined,
phone: undefined as string | undefined,
address: undefined as string | undefined,
city: undefined as string | undefined,
})
const register = async () => {
try {
profileData.loading = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
registeredInfo.value = true
} finally {
profileData.loading = false
}
}
</script>
<style scoped>
.shadowHeader {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>
@@ -0,0 +1,168 @@
<!--
Ported from legacy henza_pwa/src/views/user/src/components/transactions.vue
("اسناد" — financial documents). Replaces the English AccountView scaffold
that had no legacy counterpart.
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 exactly: `hasFactor(item)` was
`item.type != 9` (type 9 = سفارش, the debt-creating order entry) → green
#F0FDF4/#22C55E + add-square for everything else, red #FEF2F2/#F44336 +
minus-square for orders. That maps 1:1 onto our CREDIT/DEBIT direction,
since an ORDER_STOCK entry is exactly the DEBIT case.
DROPPED: 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="fixed top-0 z-10 w-full">
<AppHeader>
<div class="flex justify-between items-center">
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white" size="25" @click="router.push({ name: 'userPage::hasSubRoute' })" />
<h3 class="text-white bold mb-0">اسناد</h3>
<div></div>
</div>
</AppHeader>
</div>
<div class="h-24 bg-[#F9FAFB]"></div>
<container class="overflow-y-auto bg-[#F9FAFB]">
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="no-scrollbar overflow-auto">
<div
v-if="entries.length"
v-for="item in entries"
:key="item.id"
@click="openEntry(item)"
class="rounded-[10px] border-[#E8EAEE] py-[16px] px-[14px] w-full mb-[16px] grid grid-cols-8 items-center cursor-pointer bg-white"
>
<div class="col-span-1 items-end h-full">
<Icon
:icon="getIcon(item)"
:style="{ background: getColor(item), color: getColor2(item) }"
class="rounded-full p-[10px] ml-2 h-[40px] w-[40px]"
size="20"
/>
</div>
<div class="col-span-7 pr-1 text-primary mt-2">
<div class="flex justify-between items-center">
<div class="font-bold mb-2">{{ item.note || '- -' }}</div>
<div
class="rounded-[5px] px-[20px] py-[4px] w-fit text-[16px] mb-2 font-semibold"
:style="{ background: getColor(item), color: getColor2(item) }"
>
{{ SOURCE_LABELS[item.sourceType] ?? item.sourceType }}
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-[14px] font-semibold mb-2">{{ formatAmount(item) }}</div>
<div class="h-10"></div>
</div>
<div class="mb-2 text-[14px] text-grey font-semibold">{{ jdate(item.createdAt) }}</div>
<div class="flex justify-between pt-1">
<div class="text-[12px] font-bold">{{ item.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="20" />
</div>
</div>
</div>
<EmptyState v-else :image="transactionEmpty" />
</Spinner>
<Drawer
v-model:visible="showPopUp"
placement="bottom"
v-if="activeEntry"
:closable="false"
height="450px"
:class="['henza-rounded-drawer rtl', 'bg-white', 'text-primary']"
:bodyStyle="{ display: 'flex', flexDirection: 'column' }"
>
<div class="w-full h-full flex flex-col">
<div class="pb-[10px] border-b border-solid border-[#E5E7EB] flex text-[16px] font-bold">جزئیات سند</div>
<div class="flex-1 py-[10px] overflow-auto no-scrollbar">
<div class="flex justify-between mb-4">
<div class="text-grey">نوع سند :</div>
<div class="font-semibold">{{ SOURCE_LABELS[activeEntry.sourceType] ?? activeEntry.sourceType }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">مبلغ :</div>
<div class="font-semibold">{{ formatAmount(activeEntry) }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">وضعیت :</div>
<div class="font-semibold">{{ activeEntry.confirmed ? 'تایید شده' : 'در انتظار تایید' }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">تاریخ :</div>
<div class="font-semibold">{{ jdate(activeEntry.createdAt) }}</div>
</div>
<div class="flex justify-between mb-4">
<div class="text-grey">توضیحات :</div>
<div class="font-semibold">{{ activeEntry.note || '- -' }}</div>
</div>
</div>
</div>
</Drawer>
</container>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import AppHeader from '../../components/AppHeader.vue'
import Spinner from '../../components/Spinner.vue'
import EmptyState from '../../components/EmptyState.vue'
import transactionEmpty from '../../assets/img/empty/transactionEmpty.svg'
import { jdate, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type LedgerEntrySummary } from '../../api/client'
const SOURCE_LABELS: Record<string, string> = {
ORDER_STOCK: 'سفارش',
ORDER_PRE_ORDER: 'سفارش ساخت',
REPAYMENT_MELTED_GOLD: 'طلای آب‌شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
WALLET_BONUS: 'پاداش',
ADJUSTMENT: 'اصلاحیه',
}
const router = useRouter()
const session = useSessionStore()
const loading = ref(false)
const entries = ref<LedgerEntrySummary[]>([])
const showPopUp = ref(false)
const activeEntry = ref<LedgerEntrySummary | null>(null)
// legacy hasFactor(item) === item.type != 9 (9 = سفارش/order). Our DEBIT
// entries are exactly that case.
const isCredit = (item: LedgerEntrySummary) => item.direction === 'CREDIT'
const getIcon = (item: LedgerEntrySummary) => (isCredit(item) ? 'vuesax-linear:add-square' : 'vuesax-linear:minus-square')
const getColor = (item: LedgerEntrySummary) => (isCredit(item) ? '#F0FDF4' : '#FEF2F2')
const getColor2 = (item: LedgerEntrySummary) => (isCredit(item) ? '#22C55E' : '#F44336')
const formatAmount = (item: LedgerEntrySummary) =>
item.currency === 'GOLD_GRAM' ? `${item.amount} گرم` : `${price(item.amount)} ریال`
const openEntry = (item: LedgerEntrySummary) => {
activeEntry.value = item
showPopUp.value = true
}
onMounted(async () => {
if (!session.customerId) return
loading.value = true
try {
entries.value = await api.getLedgerEntries(session.customerId)
} finally {
loading.value = false
}
})
</script>