feat: initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
امکان دریافت مجدد رمز در {{ ' ' + 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user