fix kashkool

This commit is contained in:
2026-06-29 22:43:28 +03:30
parent be17631109
commit 8b855e5ee4
150 changed files with 11974 additions and 4445 deletions
+13
View File
@@ -0,0 +1,13 @@
export interface AboutSection {
title?: string
paragraphs?: string[]
items?: string[]
}
export function getAboutSections(projectName: string): AboutSection[] {
return [
{
paragraphs: [`${projectName} وبسایت رسمی است.`],
},
]
}
@@ -0,0 +1,28 @@
<template>
<div
class="dabout-content dabout-content--default"
:class="variant === 'desktop' ? 'dabout-content--default' : 'bg-primary text-white rounded-[12px] p-4 text-xs leading-5'"
>
<template v-for="(section, sectionIndex) in aboutSections" :key="sectionIndex">
<div
v-if="sectionIndex > 0"
:class="variant === 'desktop' ? 'dabout-content__divider' : 'w-full h-1px my-3 bg-tertiary'"
/>
<div
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
:key="`p-${sectionIndex}-${paragraphIndex}`"
:class="[variant === 'desktop' ? 'dabout-content__paragraph' : '', paragraphIndex > 0 ? 'mt-3' : '']"
>
{{ paragraph }}
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { useAboutPageContext } from '../composables/useAboutPage'
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
const { aboutSections } = useAboutPageContext()
</script>
@@ -0,0 +1,23 @@
<template>
<div :class="variant === 'desktop' ? 'dabout-support' : 'wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center'">
<a class="w-full" :href="supportHref" target="_blank" rel="noopener noreferrer">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="supportHref" target="_blank" rel="noopener noreferrer">
<img
src="@/assets/img/rubika.webp"
alt=""
:class="variant === 'desktop' ? 'dabout-support__bale' : 'w-12'"
/>
</a>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
const { supportHref } = useAboutPageContext()
</script>
@@ -0,0 +1,29 @@
import { computed, inject, provide, type InjectionKey, type ComputedRef } from 'vue'
import { getConfig } from '@/config/app-config'
import { getAboutSections, type AboutSection } from '../aboutContent'
export interface AboutPageContext {
projectName: ComputedRef<string>
aboutSections: ComputedRef<AboutSection[]>
supportHref: ComputedRef<string>
}
export const ABOUT_PAGE_CONTEXT_KEY: InjectionKey<AboutPageContext> = Symbol('aboutPageContext')
const SUPPORT_LINK = 'https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c'
export function useAboutPage() {
const projectName = computed(() => getConfig('name_fa', 'کشکول گلد'))
const aboutSections = computed(() => getAboutSections(projectName.value))
const supportHref = computed(() => SUPPORT_LINK)
const context: AboutPageContext = { projectName, aboutSections, supportHref }
provide(ABOUT_PAGE_CONTEXT_KEY, context)
return context
}
export function useAboutPageContext() {
const context = inject(ABOUT_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useAboutPageContext must be used within useAboutPage')
return context
}
@@ -0,0 +1,42 @@
<template>
<div class="dabout">
<div class="dabout__container">
<DesktopPageTopbar eyebrow="اطلاعات" title="درباره ما" show-back />
<div class="dabout-layout">
<section class="dabout-surface dabout-main">
<div class="dabout-main__intro">
<div class="dabout-main__logo">
<BasicIcon icon="vuesax-linear:info-circle" size="28" />
</div>
<div>
<h2 class="dabout-main__project">{{ projectName }}</h2>
<p class="dabout-main__tagline">آشنایی با خدمات و چشم‌انداز ما</p>
</div>
</div>
<AboutSectionsContent variant="desktop" />
</section>
<aside class="dabout-surface dabout-side">
<h3 class="dabout-side__title">پشتیبانی و ارتباط</h3>
<p class="dabout-side__text">
در صورت نیاز به راهنمایی یا پاسخ به سوالات، از راه‌های زیر با ما در ارتباط باشید.
</p>
<AboutSupportActions variant="desktop" />
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
const { projectName } = useAboutPageContext()
</script>
<style lang="less" src="./styles/desktop-about.less"></style>
@@ -0,0 +1,234 @@
/* Premium desktop about page */
.dabout {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dabout__container {
max-width: 1080px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dabout-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dabout-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.dabout-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dabout-main {
padding: 28px;
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.dabout-content {
&--default {
padding: 0;
background: transparent;
color: #374151;
}
&--boursecu {
padding: 20px;
border: 1px solid rgba(9, 61, 81, 0.15);
border-radius: 12px;
background: rgba(9, 61, 81, 0.03);
color: @App-primary-color;
}
&__divider {
width: 100%;
height: 1px;
background: #eef0f3;
margin: 20px 0;
}
&__title {
font-size: 16px;
font-weight: 700;
color: inherit;
margin: 0 0 10px;
}
&__paragraph {
font-size: 14px;
line-height: 1.9;
color: inherit;
text-align: justify;
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
&__list-item {
position: relative;
padding-right: 16px;
font-size: 14px;
line-height: 1.7;
color: inherit;
&::before {
content: '';
position: absolute;
right: 0;
top: 10px;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.6;
}
}
}
.dabout-side {
padding: 24px;
position: sticky;
top: 24px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 8px;
}
&__text {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 20px;
}
}
.dabout-support {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
> a:first-child {
flex: 1;
min-width: 0;
}
&__bale {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
@media (max-width: 1023.98px) {
.dabout-layout {
grid-template-columns: 1fr;
}
.dabout-side {
position: static;
}
}
+12 -184
View File
@@ -1,195 +1,23 @@
<template>
<div>
<div class="bg-white wrap-fix z-200001 py-3 px-3 text-primary font-bold drop-shadow flex">
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<!-- <img :src="logoFull" class="mb-20 mt-5" />-->
<div class="overflow-y-auto h-65vh">
<div class="bg-primary text-white rounded-[12px] p-4 text-xs leading-5">
<div class="w-full h-1px my-3 bg-tertiary"></div>
<div>
کشکول گلد وبسایت رسمی تست.
</div>
<!-- <div class="mt-3">-->
<!-- این وبسایت با پشتوانه سه مجموعه معتبر نور هشتم فعالیت می‌کند و امکان خرید شمش طلا را از وزن‌های خرد تا معاملات عمده، با قیمت شفاف و امنیت کامل فراهم می‌سازد.-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- تمامی فرآیندهای خرید در کشکول گلد بر اساس استانداردهای حرفه‌ای بازار طلا و با اتکا به اعتبار مجموعه‌های نور هشتم مشهد، نورهشتم فردوس و نورهشتم (داماس) بجستان انجام می‌شود تا کاربران با آرامش و اطمینان کامل اقدام به خرید کنند.-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- کشکول گلد‌کشکول گلد، بانکی از طلا بساز...-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- برای ارتباط حضوری، دریافت مشاوره و اطمینان بیشتر، کاربران می‌توانند از طریق آدرس‌های سه شعبه رسمی نور هشتم با مجموعه در ارتباط باشند و از پشتیبانی مستقیم مجموعه بهره‌مند شوند.-->
<!-- </div>-->
<!-- <div class="w-full h-1px my-3 bg-tertiary"></div>-->
<!-- <div class="mt-3 text-xl font-bold">چرا کشکول گلد؟</div>
<div class="mt-3">
۱. خرید بدون اجرت و مالیات
طلایی که از اپلیکیشن کشکول گلد خریداری می‌کنید، بدون اجرت ساخت و مالیات محاسبه می‌شود و تنها ۰.۵٪ کارمزد از شما دریافت خواهد شد.
</div>
<div class="mt-3">
۲. خرید بدون محدودیت
در کشکول گلد می‌توانید از ۵۰ سوت تا چند کیلوگرم طلا را به‌صورت آنلاین و بدون هیچ‌گونه محدودیت مکانی خریداری کنید
</div>
<div class="mt-3">
۳. فعالیت در ساعات رسمی معاملات طلا
برای رعایت قوانین صنف طلا و جلوگیری از خالی‌فروشی، فعالیت اپلیکیشن کشکول گلد منطبق با ساعات رسمی معاملات اتحادیه طلافروشان کشور است.
</div>
<div class="mt-3">
۴. امکان تحویل فیزیکی طلا
کشکول گلد دارای شعب حضوری معتبر با مجوز خرده‌فروشی طلاست تا کاربران بتوانند در صورت تمایل، طلای خریداری‌شده خود را به‌صورت فیزیکی تحویل بگیرند و از نزدیک از اصالت آن اطمینان حاصل کنند.
</div>
<div class="w-full h-1px my-3 bg-tertiary"></div>-->
<!-- <div class="mt-3">-->
<!-- آدرس شعب ما:-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- مشهد : خیابان امام خمینی / برج طلا و جواهر مرمر / طبقه همکف / واحد 16 / طلا و سکه نورهشتم-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- فردوس : خیابان امام خمینی / بعد از امام خمینی 11 / طلا و سکه نورهشتم-->
<!-- </div>-->
<!-- <div class="mt-3">-->
<!-- بجستان : خیابان امام خمینی / چهارراه وحدت / طلا و جواهر داماس [طلا و سکه نورهشتم]-->
<!-- </div>-->
<!-- <div class="w-full h-1px my-3 bg-tertiary"></div>-->
</div>
</div>
</div>
<div class="wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center">
<a class="w-full" href="https://web.rubika.ir/#">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://web.rubika.ir/#">
<img src="@/assets/img/rubika.webp" alt="" class=" w-12"/>
</a>
</div>
</div>
</div>
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
<div class="about-page-root">
<DesktopAboutView v-show="isDesktop" />
<MobileAboutView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import img from '@/assets/img/user_ly1.svg'
import gr_bottom from './assets/img/gr_bottom_white.svg'
import { loading } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { lengthValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User } from '@/models/login'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import img_pattern from "@/views/login/assets/img/pattern.svg";
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAboutPage } from './composables/useAboutPage'
import DesktopAboutView from './desktop/DesktopAboutView.vue'
import MobileAboutView from './mobile/MobileAboutView.vue'
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
})
const formRef = ref<FormInstance>()
const userStore = useUserStore()
// const editPassVisible = ref<boolean>(false)
const editPassVisible = ref<boolean>(false)
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
function shareCode() {
// nextTick(() => {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({
title: 'کشکول گلد',
text: val
})
// message.info('کد رونوشت شد')
// })
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
}
}
onMounted(() => {
setForm()
})
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const isDesktop = useDesktopBreakpoint()
useAboutPage()
</script>
<style scoped>
/*.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
.about-page-root {
min-height: 100%;
height: 100%;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
</style>
@@ -0,0 +1,23 @@
<template>
<div>
<div class="bg-white wrap-fix z-200001 py-3 px-3 text-primary font-bold drop-shadow flex">
<div class="cursor-pointer" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">درباره ما</div>
</div>
<div class="pt-20 pb-24">
<div class="px-5">
<AboutSectionsContent variant="mobile" />
</div>
<AboutSupportActions variant="mobile" />
</div>
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
</script>
@@ -0,0 +1,309 @@
import { computed, inject, onMounted, onUnmounted, provide, ref, watch, type InjectionKey } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useCartStore } from '@/store/cartStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { checkoutCart } from '@/api/card'
import { registerCodeDiscount } from '@/api/giftCard'
import { getProductBaseBuyPrice } from '@/utils/productPrice'
import { subscribeProductListStream } from '@/utils/productStreamBridge'
import {
getLastPrices,
lastPrice,
startLastPricePolling,
stopPriceInterval,
} from '@/utils/price'
import {
priceSocketConnected,
priceStreamLifecycle,
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { getToken, loading } from '@/utils'
import router from '@/router'
import type { Price } from '@/models/home'
import type { Order } from '@/models/order'
import type { Product } from '@/models/products'
export interface CartPageContext {
cartStore: ReturnType<typeof useCartStore>
loading: typeof loading
infoVisible: ReturnType<typeof ref<boolean>>
showDiscountModal: ReturnType<typeof ref<boolean>>
discountCode: ReturnType<typeof ref<string>>
discountInfo: ReturnType<typeof ref<any>>
price: ReturnType<typeof computed<Price>>
totalPrice: ReturnType<typeof computed<number>>
displaytotalPrice: ReturnType<typeof computed<number | string>>
discountPercentLabel: ReturnType<typeof computed<string>>
formatWeight: (weight?: number) => string
handleRemoveItem: (itemId: string) => void
handleCheckout: () => Promise<void>
submitDiscount: () => Promise<void>
removeDiscount: () => void
}
export const CART_PAGE_CONTEXT_KEY: InjectionKey<CartPageContext> = Symbol('cartPageContext')
export function useCartPage() {
const cartStore = useCartStore()
const walletStore = useWalletStore()
const { loadWalletAssets } = useWalletAssets()
const infoVisible = ref(false)
const showDiscountModal = ref(false)
const discountCode = ref('')
const discountInfo = ref<any>()
let cartRefreshInterval: ReturnType<typeof setInterval> | undefined
let unsubscribeProductStream: (() => void) | undefined
const price = computed((): Price => <Price>lastPrice.value ?? 0)
function syncCartFromProducts(products: Product[]) {
if (cartStore.itemCount === 0) return
const removedCount = cartStore.syncProductsFromServer(products)
cartStore.updateCartItemPrices()
if (removedCount > 0) {
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
}
}
watch(lastPrice, () => {
if (lastPrice.value && cartStore.itemCount > 0) {
cartStore.updateCartItemPrices()
}
}, { deep: true, immediate: false })
const totalPrice = computed(() =>
parseInt(String(cartStore.cartItems.reduce((sum, item) => sum + item.totalPrice, 0)))
)
const displaytotalPrice = computed(() => {
if (discountInfo.value?.amount > 0) {
return (totalPrice.value - totalPrice.value * discountInfo.value.amount).toFixed(0)
}
return totalPrice.value
})
const discountPercentLabel = computed(() => {
const amount = Number(discountInfo.value?.amount ?? 0)
if (amount <= 0) return ''
return `${Math.round(amount * 100)}٪`
})
function removeDiscount() {
discountInfo.value = undefined
discountCode.value = ''
message.success('کد تخفیف حذف شد')
}
async function submitDiscount() {
try {
loading.value = true
const temp = await registerCodeDiscount(discountCode.value)
discountInfo.value = temp.giftcard
message.success('کد تخفیف با موفقیت اعمال شد')
showDiscountModal.value = false
} finally {
loading.value = false
}
}
function formatWeight(weight?: number) {
if (!weight) return ''
if (weight < 1) {
const sot = weight * 1000
return sot < 1 ? '1 سوت' : `${Math.round(sot)} سوت`
}
return `${weight} گرم`
}
function handleRemoveItem(itemId: string) {
Modal.confirm({
title: 'حذف از سبد خرید',
content: 'آیا از حذف این آیتم از سبد خرید اطمینان دارید؟',
okText: 'تایید',
okType: 'danger',
centered: true,
cancelText: 'انصراف',
onOk() {
cartStore.removeFromCart(itemId)
message.success('آیتم از سبد خرید حذف شد')
},
})
}
async function handleCheckout() {
if (!getToken()) {
message.warning('برای تسویه حساب، لطفا ابتدا وارد حساب کاربری خود شوید')
await router.push({ name: 'login' })
return
}
if (cartStore.itemCount === 0) {
message.warning('سبد خرید شما خالی است')
return
}
if (!lastPrice.value) {
message.error('لطفا منتظر بمانید تا قیمت‌ها بارگذاری شوند')
return
}
let cashItemsTotal = cartStore.cartItems
.filter((item) => item.method === 'cash' && !item.gateway)
.reduce((sum, item) => parseInt(String(sum + item.totalPrice)), 0)
if (cashItemsTotal > 0) {
if (discountInfo.value?.amount > 0) {
cashItemsTotal -= cashItemsTotal * discountInfo.value.amount
}
if (!walletStore.walletInfo) await walletStore.getMyWallet()
if (!walletStore.walletInfo?.cash || cashItemsTotal > walletStore.walletInfo.cash) {
message.error('موجودی کیف پول شما ناکافی است')
return
}
}
const goldItemsTotal = cartStore.cartItems
.filter((item) => item.method === 'gold')
.reduce((sum, item) => sum + (item.goldVazn ? item.goldVazn * item.quantity : 0), 0)
if (goldItemsTotal > 0) {
if (!walletStore.walletInfo) await walletStore.getMyWallet()
if (!walletStore.walletInfo?.gold || goldItemsTotal > walletStore.walletInfo.gold) {
message.error('موجودی کیف طلا شما ناکافی است')
return
}
}
try {
infoVisible.value = true
const orders: Order[] = cartStore.cartItems.map((item) => ({
direction: 1,
price: getProductBaseBuyPrice(item.product),
amount: item.product.amount,
gateway: item.gateway,
product_id: item.product.id,
method: item.method,
number: item.quantity,
discount_code: discountInfo.value?.code,
}))
const data = await checkoutCart(orders)
let returnUrl: string | null = null
if (Array.isArray(data)) {
const itemWithReturnUrl = data.find((item) => item?.returnUrl)
if (itemWithReturnUrl?.returnUrl) returnUrl = itemWithReturnUrl.returnUrl
} else if (data?.returnUrl) {
returnUrl = data.returnUrl
}
if (returnUrl) {
window.location.href = returnUrl
return
}
await walletStore.getMyWallet()
cartStore.clearCart()
await router.push({ name: 'transactions' })
message.success('سفارشات شما با موفقیت ثبت شد')
await loadWalletAssets(true)
} catch (e) {
console.error('Error in checkout:', e)
message.error('خطا در ثبت سفارش. لطفا دوباره تلاش کنید.')
} finally {
loading.value = false
infoVisible.value = false
}
}
async function initPrice() {
try {
if (shouldUsePricePolling()) await getLastPrices()
startLastPricePolling()
cartStore.updateCartItemPrices()
} catch (e) {
console.log(e)
}
}
async function refreshCart() {
const removedCount = await cartStore.refreshCartFromServer()
if (removedCount > 0) {
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
}
}
function startCartRefreshPolling() {
if (cartRefreshInterval || !shouldUsePricePolling()) return
cartRefreshInterval = setInterval(() => {
if (shouldUsePricePolling()) void refreshCart()
}, 10000)
}
function stopCartRefreshPolling() {
if (cartRefreshInterval) {
clearInterval(cartRefreshInterval)
cartRefreshInterval = undefined
}
}
function syncCartRefreshPolling() {
if (shouldUsePricePolling()) startCartRefreshPolling()
else stopCartRefreshPolling()
}
watch(
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
() => {
syncCartRefreshPolling()
if (shouldUsePricePolling() && cartStore.itemCount > 0) void refreshCart()
},
)
onMounted(async () => {
cartStore.loadCartFromStorage()
unsubscribeProductStream = subscribeProductListStream(syncCartFromProducts)
loading.value = true
try {
await initPrice()
if (shouldUsePricePolling()) await refreshCart()
syncCartRefreshPolling()
} finally {
loading.value = false
}
})
onUnmounted(() => {
unsubscribeProductStream?.()
unsubscribeProductStream = undefined
stopCartRefreshPolling()
stopPriceInterval()
})
const context: CartPageContext = {
cartStore,
loading,
infoVisible,
showDiscountModal,
discountCode,
discountInfo,
price,
totalPrice,
displaytotalPrice,
discountPercentLabel,
formatWeight,
handleRemoveItem,
handleCheckout,
submitDiscount,
removeDiscount,
}
provide(CART_PAGE_CONTEXT_KEY, context)
return context
}
export function useCartPageContext() {
const context = inject(CART_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useCartPageContext must be used within useCartPage')
return context
}
@@ -0,0 +1,184 @@
<template>
<div class="dinner cart-page">
<div class="dinner__container">
<DesktopPageTopbar title="سبد خرید" show-back>
<template #trailing>
<div v-if="price" class="dcart-header-price number-ltr">
<span class="dcart-header-price__label">قیمت خرید از کشکول گلد:</span>
<strong>{{ usePriceWithoutType(price?.price_last ?? 0) }}</strong>
<span>ریال</span>
</div>
</template>
</DesktopPageTopbar>
<GoldLoading v-model:show="infoVisible" title="" />
<template v-if="loading">
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
</template>
<div v-else-if="cartStore.itemCount === 0" class="dinner-surface dcart-empty">
<p>سبد خرید شما خالی است</p>
<BasicButton type="primary" @click="router.push({ name: 'buyProduct' })">مشاهده محصولات</BasicButton>
</div>
<div v-else class="dcart-grid">
<div class="dcart-items">
<article v-for="item in cartStore.cartItems" :key="item.id" class="dcart-item">
<div class="dcart-item__image">
<ProductImage :product="item.product" img-class="max-w-full max-h-full object-contain" />
</div>
<div class="dcart-item__main">
<h3 class="dcart-item__title">{{ item.product.title }}</h3>
<p class="dcart-item__meta">
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
<span v-if="item.method === 'cash' && item.gateway"> (با درگاه)</span>
</p>
<p class="dcart-item__meta">
تعداد: {{ item.quantity }} عدد
<span v-if="item.goldVazn"> | وزن: {{ formatWeight(item.goldVazn) }}</span>
</p>
<div class="dcart-item__footer">
<span class="dcart-item__price">{{ usePrice(item.totalPrice) }}</span>
<BasicIcon
icon="vuesax-linear:trash"
class="text-red-500 cursor-pointer"
@click.stop="handleRemoveItem(item.id)"
/>
</div>
</div>
</article>
</div>
<aside class="dinner-surface dcart-summary">
<h2 class="dcart-summary__title">خلاصه سفارش</h2>
<div
v-if="discountInfo?.amount > 0"
class="dcart-summary__discount"
>
<div>
<span>کد تخفیف: </span>
<strong>{{ discountInfo.code }}</strong>
<div class="text-xs text-green-600">{{ discountPercentLabel }} تخفیف اعمال شد</div>
</div>
<button type="button" class="dcart-summary__remove-discount" @click="removeDiscount">
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
حذف
</button>
</div>
<div class="dcart-summary__row">
<span>مجموع:</span>
<div class="text-left">
<div class="dcart-summary__total">{{ usePrice(displaytotalPrice) }}</div>
<div v-if="discountInfo?.amount > 0" class="text-xs text-gray-400 line-through">
{{ usePrice(totalPrice) }}
</div>
</div>
</div>
<button
v-if="!discountInfo?.amount"
type="button"
class="dcart-summary__discount-link"
@click="showDiscountModal = true"
>
کد تخفیف دارم
</button>
<BasicButton class="w-full" type="primary" size="large" @click="handleCheckout">
تسویه حساب
</BasicButton>
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import GoldLoading from '@/components/GoldLoading.vue'
import ProductImage from '@/components/ProductImage.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import { usePrice, usePriceWithoutType } from '@/utils/usePrice'
import { useCartPageContext } from '../composables/useCartPage'
const {
cartStore,
loading,
infoVisible,
showDiscountModal,
discountInfo,
price,
totalPrice,
displaytotalPrice,
discountPercentLabel,
formatWeight,
handleRemoveItem,
handleCheckout,
removeDiscount,
} = useCartPageContext()
</script>
<style scoped>
.dcart-header-price {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
color: #6b7280;
strong {
font-size: 16px;
color: #111827;
}
}
.dcart-empty {
padding: 48px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #6b7280;
}
.dcart-summary__discount {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
border-radius: 10px;
background: rgba(167, 43, 119, 0.06);
font-size: 13px;
}
.dcart-summary__remove-discount {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: #ef4444;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.dcart-summary__discount-link {
border: none;
background: transparent;
color: @App-primary-color;
font-size: 13px;
text-decoration: underline;
cursor: pointer;
font-family: inherit;
text-align: center;
}
</style>
+26 -453
View File
@@ -1,464 +1,37 @@
<template>
<div>
<div class="absolute inset-0 bg-cover bg-center opacity-60 z--1" :style="{ backgroundImage: `url(${img})` }"></div>
<div class="bg-white drop-shadow z-[1999] wrap-fix">
<div class="py-3 px-3 font-bold flex items-center justify-between">
<div>سبد خرید</div>
<!-- Price Display in Header -->
<div v-if="price" class="flex gap-1 items-center text-xs flex-1 justify-end">
<span class="text-gray-600 text-xs">قیمت خرید از کشکول گلد:</span>
<div class="font-bold text-base" style="font-family: iran-sans; direction: ltr">
{{ usePriceWithoutType(price?.price_last ?? 0) }}
</div>
<span class="text-xs">ریال</span>
</div>
<div class="cart-page-root">
<DesktopCartView v-show="isDesktop" />
<MobileCartView v-show="!isDesktop" />
<AModal
v-model:visible="showDiscountModal"
ok-text="اعمال کد تخفیف"
cancel-text="انصراف"
@ok="submitDiscount"
@cancel="showDiscountModal = false"
>
<div class="p-5">
<div>کد تخفیف</div>
<AInput v-model:value="discountCode" />
<div class="font-bold text-xs mt-3">*حساس به حروف بزرگ و کوچک</div>
</div>
</div>
<GoldLoading v-model:show="infoVisible" title=""/>
<div class="px-5 pt-26 pb-30" :class="loading ? 'h-[60vh]' : ''">
<template v-if="loading">
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
</template>
<template v-else>
<!-- Empty Cart Message -->
<div v-if="cartStore.itemCount === 0" class="flex flex-col items-center justify-center h-[60vh]">
<div class="text-gray-500 text-lg font-semibold mb-4">سبد خرید شما خالی است</div>
<BasicButton type="primary" @click="$router.push({ name: 'buyProduct' })">
مشاهده محصولات
</BasicButton>
</div>
<!-- Cart Items -->
<div v-else>
<div class="space-y-4 mb-20">
<div
v-for="item in cartStore.cartItems"
:key="item.id"
class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm"
>
<div class="flex gap-4">
<!-- Product Image -->
<div class="bg-gray-100 rounded-lg flex justify-center items-center p-1 min-w-[80px] h-[80px]">
<ProductImage
:product="item.product"
img-class="max-w-full max-h-full object-contain"
/>
</div>
<!-- Product Info -->
<div class="flex-1 flex flex-col justify-between">
<div>
<div class="text-base font-semibold mb-1">{{ item.product.title }}</div>
<!-- <div class="text-xs text-gray-500 mb-2">-->
<!-- {{ getProductAmount(item.product.amount) }}-->
<!-- </div>-->
<div class="text-xs text-gray-500 mb-2">
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
<span v-if="item.method === 'cash' && item.gateway" class="mr-2">(با درگاه)</span>
</div>
<div class="text-xs text-gray-500">
تعداد: {{ item.quantity }} عدد
<span v-if="item.goldVazn" class="mr-2">| وزن: {{ formatWeight(item.goldVazn) }}</span>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<div class="text-primary font-bold">
{{ usePrice(item.totalPrice) }}
</div>
<BasicIcon
icon="vuesax-linear:trash"
class="text-red-500 cursor-pointer"
@click.stop="handleRemoveItem(item.id)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Total and Checkout -->
<div class=" flex justify-center fixed bottom-20 left-0 right-0 z-1">
<div class="md:w-[426px] w-full bg-white border-t border-gray-200 p-4 shadow-lg">
<div
v-if="discountInfo?.amount > 0"
class="mb-3 flex items-center justify-between rounded-lg bg-purple-50 px-3 py-2"
>
<div class="flex flex-col gap-0.5 text-sm">
<div class="flex items-center gap-2">
<span class="text-gray-600">کد تخفیف:</span>
<span class="font-semibold">{{ discountInfo.code }}</span>
</div>
<div class="text-xs text-green-600">
{{ discountPercentLabel }} تخفیف اعمال شد
</div>
</div>
<button
type="button"
class="flex items-center gap-1 text-xs text-red-500"
@click="removeDiscount"
>
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
<span>حذف</span>
</button>
</div>
<div class="flex justify-between items-center mb-4">
<div class="text-lg font-semibold">مجموع:</div>
<div class="flex flex-col items-end gap-1">
<div class="text-primary font-bold text-xl">
{{ usePrice(displaytotalPrice) }}
</div>
<div
v-if="discountInfo?.amount > 0"
class="text-xs text-gray-400 line-through"
>
{{ usePrice(totalPrice) }}
</div>
</div>
</div>
<div
v-if="!discountInfo?.amount"
class="mb-3 text-center"
>
<span
class="text-xs text-primary underline cursor-pointer"
@click="showDiscountModal = true"
>
کد تخفیف دارم
</span>
</div>
<BasicButton
class="w-full py-3"
type="primary"
size="large"
@click="handleCheckout"
>
تسویه حساب
</BasicButton>
</div>
</div>
</div>
</template>
</div>
</AModal>
</div>
<AModal v-model:visible="showDiscountModal" ok-text="اعمال کد تخفیف" cancel-text="انصراف" @ok="submitDiscount"
@cancel="showDiscountModal=false">
<div class="p-5">
<div>کد تخفیف</div>
<AInput v-model:value="discountCode"></AInput>
<div class="font-bold text-xs mt-3">
*حساس به حروف بزرگ و کوچک
</div>
</div>
</AModal>
</template>
<script setup lang="ts">
import {ref, onMounted, onUnmounted, watch, computed} from 'vue'
import {useCartStore} from '@/store/cartStore'
import {usePrice, usePriceWithoutType} from '@/utils/usePrice'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import {message, Modal} from 'ant-design-vue'
import img from '@/assets/img/user_ly1.svg'
import {
getLastPrices,
lastPrice,
stopPriceInterval,
setIntervalGetLastPrice
} from '@/utils/price'
import {checkoutCart} from '@/api/card'
import router from '@/router'
import {useWalletStore} from '@/store/walletStore'
import {useFundUserStore} from '@/store/fundStore'
import type {Price} from '@/models/home'
import type {Order} from '@/models/order'
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useCartPage } from './composables/useCartPage'
import DesktopCartView from './desktop/DesktopCartView.vue'
import MobileCartView from './mobile/MobileCartView.vue'
import {loading, getToken} from '@/utils'
import GoldLoading from '@/components/GoldLoading.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import ProductImage from '@/components/ProductImage.vue'
import {registerCodeDiscount} from "@/api/giftCard";
const cartStore = useCartStore()
const walletStore = useWalletStore()
const fundStore = useFundUserStore()
const infoVisible = ref(false)
const showDiscountModal = ref(false)
const discountCode = ref<string>('')
const discountInfo = ref()
let totalPriceWithDiscount = 0
const price = computed((): Price => <Price>lastPrice.value ?? 0)
// Watch for price updates and update cart items
watch(lastPrice, () => {
if (lastPrice.value) {
cartStore.recalculateCartPrices(lastPrice.value)
}
}, {deep: true, immediate: false})
// Computed total price that updates with price changes
const totalPrice = computed(() => {
return parseInt(cartStore.cartItems.reduce((sum, item) => sum + item.totalPrice, 0))
})
const displaytotalPrice = computed(() => {
if (discountInfo.value?.amount > 0) {
return (totalPrice.value - (totalPrice.value * discountInfo.value?.amount)).toFixed(0)
} else
return totalPrice.value
})
const discountPercentLabel = computed(() => {
const amount = Number(discountInfo.value?.amount ?? 0)
if (amount <= 0) return ''
return `${Math.round(amount * 100)}٪`
})
function removeDiscount() {
discountInfo.value = undefined
discountCode.value = ''
message.success('کد تخفیف حذف شد')
}
async function submitDiscount() {
try {
loading.value = true
const temp = await registerCodeDiscount(discountCode.value)
discountInfo.value = temp.giftcard
message.success('کد تخفیف با موفقیت اعمال شد')
showDiscountModal.value = false
} catch (e) {
} finally {
loading.value = false
}
}
function getProductAmount(amount?: number) {
if (!amount) return ''
if (amount < 1) {
// کمتر از 1 گرم را به سوت تبدیل کن (مشابه ProductItem)
const sot = amount * 1000
// اگر نتیجه کمتر از 1 شد، حداقل 1 نمایش بده
if (sot < 1) {
return `1 سوت`
}
// در غیر این صورت، عدد را به عدد صحیح گرد کن
return `${Math.round(sot)} سوت`
}
return `${amount} گرم`
}
function formatWeight(weight?: number) {
if (!weight) return ''
if (weight < 1) {
// کمتر از 1 گرم را به سوت تبدیل کن
const sot = weight * 1000
// اگر نتیجه کمتر از 1 شد، حداقل 1 نمایش بده
if (sot < 1) {
return `1 سوت`
}
// در غیر این صورت، عدد را به عدد صحیح گرد کن
return `${Math.round(sot)} سوت`
}
return `${weight} گرم`
}
function handleRemoveItem(itemId: string) {
Modal.confirm({
title: 'حذف از سبد خرید',
content: 'آیا از حذف این آیتم از سبد خرید اطمینان دارید؟',
okText: 'تایید',
okType: 'danger',
centered: true,
cancelText: 'انصراف',
onOk() {
cartStore.removeFromCart(itemId)
message.success('آیتم از سبد خرید حذف شد')
}
})
}
async function handleCheckout() {
// Check if user is logged in
if (!getToken()) {
message.warning('برای تسویه حساب، لطفا ابتدا وارد حساب کاربری خود شوید')
await router.push({name: 'login'})
return
}
if (cartStore.itemCount === 0) {
message.warning('سبد خرید شما خالی است')
return
}
if (!lastPrice.value) {
message.error('لطفا منتظر بمانید تا قیمت‌ها بارگذاری شوند')
return
}
// Check wallet balance for cash method items (without gateway)
let cashItemsTotal = cartStore.cartItems
.filter(item => item.method === 'cash' && !item.gateway)
.reduce((sum, item) => parseInt(sum + item.totalPrice), 0)
if (cashItemsTotal > 0) {
// Ensure wallet info is loaded
if (discountInfo.value?.amount && discountInfo.value?.amount>0){
cashItemsTotal=cashItemsTotal-cashItemsTotal*discountInfo.value?.amount
}
if (!walletStore.walletInfo) {
await walletStore.getMyWallet()
}
if (!walletStore.walletInfo?.cash || cashItemsTotal > walletStore.walletInfo.cash) {
message.error('موجودی کیف پول شما ناکافی است')
return
}
}
// Check gold balance for gold method items
const goldItemsTotal = cartStore.cartItems
.filter(item => item.method === 'gold')
.reduce((sum, item) => {
// Calculate total gold weight needed
const goldWeight = item.goldVazn ? item.goldVazn * item.quantity : 0
return sum + goldWeight
}, 0)
if (goldItemsTotal > 0) {
// Ensure wallet info is loaded
if (!walletStore.walletInfo) {
await walletStore.getMyWallet()
}
if (!walletStore.walletInfo?.gold || goldItemsTotal > walletStore.walletInfo.gold) {
message.error('موجودی کیف طلا شما ناکافی است')
return
}
}
try {
infoVisible.value = true
// Prepare orders array with same structure as createOrder for each product
const orders: Order[] = cartStore.cartItems.map(item => {
console.log(item)
const product = item.product
// Calculate price similar to createOrder (buy price)
let productPrice = 0
if (product.group_type == 'آبشده') {
productPrice = lastPrice.value?.price_last ?? 0
} else {
productPrice = (product.amount ?? 0) * (lastPrice.value?.price_last ?? 0)
}
return {
direction: 1, // 1 for buy (cart items are always buy orders)
price: productPrice,
amount: product.amount,
gateway: item.gateway,
product_id: product.id,
method: item.method,
number: item.quantity,
discount_code:discountInfo.value?.code
}
})
// Call checkoutCart API with orders array
const data = await checkoutCart(orders)
// Check for returnUrl (gateway payment)
let returnUrl = null
if (Array.isArray(data)) {
const itemWithReturnUrl = data.find(item => item?.returnUrl)
if (itemWithReturnUrl?.returnUrl) {
returnUrl = itemWithReturnUrl.returnUrl
}
} else if (data?.returnUrl) {
returnUrl = data.returnUrl
}
// If returnUrl exists, redirect to gateway and stop execution
if (returnUrl) {
window.location.href = returnUrl
return // Stop execution here
}
// If no gateway, continue with normal flow
await walletStore.getMyWallet()
cartStore.clearCart()
await router.push({name: 'transactions'})
message.success('سفارشات شما با موفقیت ثبت شد')
await fundStore.getMyFund()
} catch (e: any) {
console.error('Error in checkout:', e)
message.error('خطا در ثبت سفارش. لطفا دوباره تلاش کنید.')
} finally {
loading.value = false
infoVisible.value = false
}
}
async function initPrice() {
try {
await getLastPrices()
await setIntervalGetLastPrice()
if (lastPrice.value) {
cartStore.recalculateCartPrices(lastPrice.value)
}
} catch (e: any) {
console.log(e)
}
}
async function refreshCart() {
const removedCount = await cartStore.refreshCartFromServer()
if (removedCount > 0) {
message.warning(`${removedCount} محصول از سبد خرید حذف شد (دیگر در دسترس نیست)`)
}
if (lastPrice.value) {
cartStore.recalculateCartPrices(lastPrice.value)
}
}
onMounted(async () => {
cartStore.loadCartFromStorage()
loading.value = true
try {
await initPrice()
await refreshCart()
const refreshCartInfo=setInterval(async () => {
await refreshCart()
// await getUpPrice()
}, 10000)
} finally {
loading.value = false
}
})
onUnmounted(() => {
stopPriceInterval()
})
const isDesktop = useDesktopBreakpoint()
const { showDiscountModal, discountCode, submitDiscount } = useCartPage()
</script>
<style scoped>
.text-primary {
color: #d3a233;
}
@media screen and (min-width: 426px) {
.wrap-fix {
width: 426px;
margin: 0 auto;
}
.cart-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,142 @@
<template>
<div>
<div class="absolute inset-0 bg-cover bg-center opacity-60 z--1" :style="{ backgroundImage: `url(${img})` }" />
<div class="bg-white drop-shadow z-[1999] wrap-fix">
<div class="py-3 px-3 font-bold flex items-center justify-between">
<div>سبد خرید</div>
<div v-if="price" class="flex gap-1 items-center text-xs flex-1 justify-end">
<span class="text-gray-600 text-xs">قیمت خرید از کشکول گلد:</span>
<div class="font-bold text-base" style="font-family: iran-sans; direction: ltr">
{{ usePriceWithoutType(price?.price_last ?? 0) }}
</div>
<span class="text-xs">ریال</span>
</div>
</div>
</div>
<GoldLoading v-model:show="infoVisible" title="" />
<div class="px-5 pt-26 pb-30" :class="loading ? 'h-[60vh]' : ''">
<template v-if="loading">
<TransactionSkeleton v-for="i in 3" :key="i" class="mt-4" />
</template>
<template v-else>
<div v-if="cartStore.itemCount === 0" class="flex flex-col items-center justify-center h-[60vh]">
<div class="text-gray-500 text-lg font-semibold mb-4">سبد خرید شما خالی است</div>
<BasicButton type="primary" @click="router.push({ name: 'buyProduct' })">مشاهده محصولات</BasicButton>
</div>
<div v-else>
<div class="space-y-4 mb-20">
<div
v-for="item in cartStore.cartItems"
:key="item.id"
class="bg-white rounded-lg border border-gray-200 p-4 shadow-sm"
>
<div class="flex gap-4">
<div class="bg-gray-100 rounded-lg flex justify-center items-center p-1 min-w-[80px] h-[80px]">
<ProductImage :product="item.product" img-class="max-w-full max-h-full object-contain" />
</div>
<div class="flex-1 flex flex-col justify-between">
<div>
<div class="text-base font-semibold mb-1">{{ item.product.title }}</div>
<div class="text-xs text-gray-500 mb-2">
روش پرداخت: {{ item.method === 'cash' ? 'نقدی' : 'طلایی' }}
<span v-if="item.method === 'cash' && item.gateway" class="mr-2">(با درگاه)</span>
</div>
<div class="text-xs text-gray-500">
تعداد: {{ item.quantity }} عدد
<span v-if="item.goldVazn" class="mr-2">| وزن: {{ formatWeight(item.goldVazn) }}</span>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<div class="text-primary font-bold">{{ usePrice(item.totalPrice) }}</div>
<BasicIcon
icon="vuesax-linear:trash"
class="text-red-500 cursor-pointer"
@click.stop="handleRemoveItem(item.id)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center fixed bottom-20 left-0 right-0 z-1">
<div class="md:w-[426px] w-full bg-white border-t border-gray-200 p-4 shadow-lg">
<div
v-if="discountInfo?.amount > 0"
class="mb-3 flex items-center justify-between rounded-lg bg-purple-50 px-3 py-2"
>
<div class="flex flex-col gap-0.5 text-sm">
<div class="flex items-center gap-2">
<span class="text-gray-600">کد تخفیف:</span>
<span class="font-semibold">{{ discountInfo.code }}</span>
</div>
<div class="text-xs text-green-600">{{ discountPercentLabel }} تخفیف اعمال شد</div>
</div>
<button type="button" class="flex items-center gap-1 text-xs text-red-500" @click="removeDiscount">
<BasicIcon icon="vuesax-linear:close-circle" size="18" />
<span>حذف</span>
</button>
</div>
<div class="flex justify-between items-center mb-4">
<div class="text-lg font-semibold">مجموع:</div>
<div class="flex flex-col items-end gap-1">
<div class="text-primary font-bold text-xl">{{ usePrice(displaytotalPrice) }}</div>
<div v-if="discountInfo?.amount > 0" class="text-xs text-gray-400 line-through">
{{ usePrice(totalPrice) }}
</div>
</div>
</div>
<div v-if="!discountInfo?.amount" class="mb-3 text-center">
<span class="text-xs text-primary underline cursor-pointer" @click="showDiscountModal = true">
کد تخفیف دارم
</span>
</div>
<BasicButton class="w-full py-3" type="primary" size="large" @click="handleCheckout">
تسویه حساب
</BasicButton>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import img from '@/assets/img/user_ly1.svg'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import GoldLoading from '@/components/GoldLoading.vue'
import ProductImage from '@/components/ProductImage.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { usePrice, usePriceWithoutType } from '@/utils/usePrice'
import { useCartPageContext } from '../composables/useCartPage'
const {
cartStore,
loading,
infoVisible,
showDiscountModal,
discountInfo,
price,
totalPrice,
displaytotalPrice,
discountPercentLabel,
formatWeight,
handleRemoveItem,
handleCheckout,
removeDiscount,
} = useCartPageContext()
</script>
<style scoped>
.text-primary {
color: #d3a233;
}
</style>
@@ -0,0 +1,79 @@
import { inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import { message } from 'ant-design-vue'
import { getEarnMoney } from '@/api/earnMoney'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
import type { EarnMoney } from '@/models/earnMoney'
export interface EarnMoneyPageContext {
data: ReturnType<typeof ref<EarnMoney | undefined>>
userStore: ReturnType<typeof useUserStore>
isTransaction: ReturnType<typeof ref<boolean>>
setIsTransaction: (value: boolean) => void
copyCode: () => void
shareCode: () => void
}
export const EARN_MONEY_PAGE_CONTEXT_KEY: InjectionKey<EarnMoneyPageContext> = Symbol('earnMoneyPageContext')
export function useEarnMoneyPage() {
const data = ref<EarnMoney>()
const userStore = useUserStore()
const isTransaction = ref(false)
function setIsTransaction(value: boolean) {
isTransaction.value = value
}
function copyCode() {
const val = userStore.userInfo?.referral_id
if (val) {
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
}
function shareCode() {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({ title: 'کشکول گلد', text: val })
}
async function earnMoney() {
try {
loading.value = true
data.value = await getEarnMoney()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(() => {
void earnMoney()
})
const context: EarnMoneyPageContext = {
data,
userStore,
isTransaction,
setIsTransaction,
copyCode,
shareCode,
}
provide(EARN_MONEY_PAGE_CONTEXT_KEY, context)
return context
}
export function useEarnMoneyPageContext() {
const context = inject(EARN_MONEY_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useEarnMoneyPageContext must be used within useEarnMoneyPage')
return context
}
@@ -0,0 +1,101 @@
<template>
<div class="dinner earn-money-page">
<div class="dinner__container">
<DesktopPageTopbar title="کسب درآمد" show-back />
<section class="dinner-surface p-5 mb-4">
<div class="flex items-start gap-3">
<div class="bg-[#DBEAFE] h-10 w-10 rounded-full flex justify-center items-center flex-shrink-0">
<img :src="coin" alt="" class="w-6 h-6" />
</div>
<div class="min-w-0">
<h2 class="font-bold mb-2">دعوت دوستان</h2>
<p class="text-sm text-gray-600 leading-6">
با دعوت دوستانتان به اپلیکیشن کشکول گلد، دوست شما اولین خرید خود را بدون کارمزد انجام می‌دهد
و شما نیز ۱۰٪ از کارمزد ۱۰ معامله‌ی اول او را به‌عنوان پاداش دریافت می‌کنید.
</p>
</div>
</div>
<div class="flex items-center justify-center gap-2 mt-4">
<span class="text-sm text-gray-500">کد بازاریابی</span>
<strong class="text-lg tracking-wide">{{ userStore.userInfo?.referral_id }}</strong>
</div>
<div class="grid grid-cols-2 gap-2 mt-4 max-w-md mx-auto">
<BasicButton icon="vuesax-linear:share" size="small" icon-size="18" @click="shareCode">اشتراک گذاری</BasicButton>
<BasicButton icon="vuesax-linear:copy" size="small" icon-size="18" @click="copyCode">رونوشت</BasicButton>
</div>
</section>
<div class="dearn-stats">
<div class="dearn-stat dearn-stat--blue">
<div class="dearn-stat__value">{{ data?.usersSum }} عدد</div>
<div class="dearn-stat__label">تعداد زیر مجموعه</div>
</div>
<div class="dearn-stat dearn-stat--yellow">
<div class="dearn-stat__value">{{ data?.giftsSum }} سوت</div>
<div class="dearn-stat__label">میزان کل پاداش ها</div>
</div>
</div>
<div class="dinner-surface p-2 mb-4 flex gap-2">
<button
type="button"
class="dearn-tab"
:class="{ 'dearn-tab--active': isTransaction }"
@click="setIsTransaction(true)"
>
پاداش ها
</button>
<button
type="button"
class="dearn-tab"
:class="{ 'dearn-tab--active': !isTransaction }"
@click="setIsTransaction(false)"
>
فهرست زیر مجموعه
</button>
</div>
<div class="dearn-list">
<template v-if="isTransaction">
<GiftCard v-for="(val, i) in data?.gifts" :key="`gift-${i}`" :item="val" />
</template>
<template v-else>
<EarnCard v-for="(val, i) in data?.users" :key="`user-${i}`" :item="val" />
</template>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import coin from '@/assets/img/coin.png'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import EarnCard from '../components/EarnCard.vue'
import GiftCard from '../components/GiftCard.vue'
import { useEarnMoneyPageContext } from '../composables/useEarnMoneyPage'
const { data, userStore, isTransaction, setIsTransaction, copyCode, shareCode } = useEarnMoneyPageContext()
</script>
<style scoped>
.dearn-tab {
flex: 1;
border: none;
background: transparent;
padding: 10px;
border-radius: 10px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: #9ca3af;
cursor: pointer;
&--active {
background: #fff;
color: @App-primary-color;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
}
</style>
+14 -154
View File
@@ -1,163 +1,23 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow flex">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">کسب درآمد</div>
</div>
<div class="earn-money-page-root">
<DesktopEarnMoneyView v-show="isDesktop" />
<MobileEarnMoneyView v-show="!isDesktop" />
</div>
<div class="p-4">
<div class=" p-3 border-solid border-1 rounded-[8px] border-[#a72b77] ">
<div class="flex items-center">
<div class=" bg-[#DBEAFE] h-10 w-10 rounded-100% flex justify-center items-center">
<img :src="coin" />
</div>
<div class="mr-2">
<div class="flex">
<div class="font-bold">دعوت دوستان</div>
<!-- <div class="text-xs ">راهنمای کسب درآمد</div>-->
</div>
<div class=" mt-2 text-xs">با دعوت دوستانتان به اپلیکیشن کشکول گلد،
دوست شما اولین خرید خود را بدون کارمزد انجام می‌دهد
و شما نیز ۱۰٪ از کارمزد ۱۰ معامله‌ی اول او را به‌عنوان پاداش دریافت می‌کنید. 💰</div>
</div>
</div>
<div class="flex justify-center mt-4">
<div class="text-[10px] content-center ">کد بازاریابی</div>
<div class="mr-2 font-bold">{{userStore.userInfo.referral_id}}</div>
</div>
<div class="flex mt-4 gap-2">
<BasicButton icon="vuesax-linear:share" size="small" class="flex-1" icon-size="18" @click.stop="shareCode()">
اشتراک گذاری
</BasicButton>
<BasicButton icon="vuesax-linear:copy" size="small" class="flex-1" icon-size="18" @click.stop="copyCode()">
رونوشت
</BasicButton>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4 ">
<div class="text-center bg-[#DBEAFE] p-2 rounded-[8px]">
<div class="font-bold text-[#2563EB] text-md">
{{data?.usersSum}} عدد
</div>
<div class="text-xs text-[#2563EB] mt-2">تعداد زیر مجموعه</div>
</div>
<div class="text-center bg-[#FEF9C3] p-2 rounded-[8px]">
<div class="font-bold text-[#CA8A04] text-md">
{{data?.giftsSum}} سوت
</div>
<div class="text-xs text-[#CA8A04] mt-2">میزان کل پاداش ها</div>
</div>
</div>
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded-lg mt-6 ">
<div :class="isTransaction?'text-primary bg-white h-8 w-1/2 rounded-lg':'text-gray-400'" class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(true) ">
پاداش ها
</div>
<div :class="!isTransaction?'text-primary bg-white h-8 w-1/2 rounded-lg':'text-gray-400'" class="w-full text-center z-2 flex items-center justify-center"
@click.stop="setIsTransaction(false) ">
فهرست زیر مجموعه
</div>
</div>
<!-- //**************************************-->
<!-- <div class="flex mt-6">-->
<!-- <div class="bg-[#a72b77] p-2 rounded-[8px] w-full">-->
<!-- <div class="text-white text-center">فهرست زیرمجموعه</div>-->
<!-- </div>-->
<!-- <div class="bg-[#a72b77] p-2 rounded-[8px] w-full mr-2">-->
<!-- <div class="text-white text-center">پاداش ها</div>-->
<!-- </div>-->
<!-- </div>-->
<div class="border-solid border-1 border-[#E5E7EB] mt-6 "></div>
<div class="h-20rem overflow-y-scroll" v-if="isTransaction">
<GiftCard v-for="(val,i) in data?.gifts" :key="i" :item="val"/>
</div>
<div v-else class="h-20rem overflow-y-scroll">
<EarnCard v-for="(val,i) in data?.users" :key="i" :item="val"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useEarnMoneyPage } from './composables/useEarnMoneyPage'
import DesktopEarnMoneyView from './desktop/DesktopEarnMoneyView.vue'
import MobileEarnMoneyView from './mobile/MobileEarnMoneyView.vue'
import router from '@/router'
import coin from '@/assets/img/coin.png'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useUserStore } from '@/store/userStore'
import { type FormInstance, message } from 'ant-design-vue'
import { onMounted, reactive, ref } from 'vue'
import type { EarnMoney } from '@/models/earnMoney'
import { loading } from '@/utils'
import { getEarnMoney } from '@/api/earnMoney'
import EarnCard from '@/views/base/earnMoney/components/EarnCard.vue'
import TransactionCard from '@/views/base/transactions/components/Item.vue'
import GiftCard from '@/views/base/earnMoney/components/GiftCard.vue'
const data = ref<EarnMoney>()
const userStore = useUserStore()
const isTransaction = ref<boolean>(false)
function setIsTransaction(value: boolean) {
isTransaction.value = value
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
function shareCode() {
// nextTick(() => {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({
title: 'کشکول گلد',
text: val
})
// message.info('کد رونوشت شد')
// })
}
async function earnMoney() {
try {
loading.value = true
data.value = await getEarnMoney()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
function subsetList(){
}
onMounted(async () => {
earnMoney()
})
function useStyle() {
if (data.value?.User) return 'right: 2%;'
// if (isbuy.value) return 'animation-direction: normal'
// else return 'animation-direction: reverse'
else return ' right: 98%; transform: translate(100%, 0);'
}
const isDesktop = useDesktopBreakpoint()
useEarnMoneyPage()
</script>
<style scoped>
</style>
.earn-money-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,86 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-primary font-bold drop-shadow flex">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">کسب درآمد</div>
</div>
<div class="p-4">
<div class="p-3 border-solid border-1 rounded-[8px] border-[#a72b77]">
<div class="flex items-center">
<div class="bg-[#DBEAFE] h-10 w-10 rounded-100% flex justify-center items-center">
<img :src="coin" />
</div>
<div class="mr-2">
<div class="font-bold">دعوت دوستان</div>
<div class="mt-2 text-xs">
با دعوت دوستانتان به اپلیکیشن کشکول گلد، دوست شما اولین خرید خود را بدون کارمزد انجام می‌دهد
و شما نیز ۱۰٪ از کارمزد ۱۰ معامله‌ی اول او را به‌عنوان پاداش دریافت می‌کنید. 💰
</div>
</div>
</div>
<div class="flex justify-center mt-4">
<div class="text-[10px] content-center">کد بازاریابی</div>
<div class="mr-2 font-bold">{{ userStore.userInfo?.referral_id }}</div>
</div>
<div class="flex mt-4 gap-2">
<BasicButton icon="vuesax-linear:share" size="small" class="flex-1" icon-size="18" @click="shareCode">
اشتراک گذاری
</BasicButton>
<BasicButton icon="vuesax-linear:copy" size="small" class="flex-1" icon-size="18" @click="copyCode">
رونوشت
</BasicButton>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="text-center bg-[#DBEAFE] p-2 rounded-[8px]">
<div class="font-bold text-[#2563EB] text-md">{{ data?.usersSum }} عدد</div>
<div class="text-xs text-[#2563EB] mt-2">تعداد زیر مجموعه</div>
</div>
<div class="text-center bg-[#FEF9C3] p-2 rounded-[8px]">
<div class="font-bold text-[#CA8A04] text-md">{{ data?.giftsSum }} سوت</div>
<div class="text-xs text-[#CA8A04] mt-2">میزان کل پاداش ها</div>
</div>
</div>
<div class="flex justify-evenly w-full px-2 h-11 items-center bg-gray-50 relative rounded-lg mt-6">
<div
:class="isTransaction ? 'text-primary bg-white h-8 w-1/2 rounded-lg' : 'text-gray-400'"
class="w-full text-center z-2 flex items-center justify-center"
@click="setIsTransaction(true)"
>
پاداش ها
</div>
<div
:class="!isTransaction ? 'text-primary bg-white h-8 w-1/2 rounded-lg' : 'text-gray-400'"
class="w-full text-center z-2 flex items-center justify-center"
@click="setIsTransaction(false)"
>
فهرست زیر مجموعه
</div>
</div>
<div class="border-solid border-1 border-[#E5E7EB] mt-6" />
<div v-if="isTransaction" class="h-20rem overflow-y-scroll">
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
</div>
<div v-else class="h-20rem overflow-y-scroll">
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import coin from '@/assets/img/coin.png'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import EarnCard from '../components/EarnCard.vue'
import GiftCard from '../components/GiftCard.vue'
import { useEarnMoneyPageContext } from '../composables/useEarnMoneyPage'
const { data, userStore, isTransaction, setIsTransaction, copyCode, shareCode } = useEarnMoneyPageContext()
</script>
+48 -56
View File
@@ -1,69 +1,61 @@
<template>
<div v-if="item" class="bg-white flex flex-col items-center p-3 rounded-xl">
<img v-lazy="{ src: itemImg, loading: logo,error: logo}" alt="" class="w-[87px] ">
<div class="mt-3">{{props.item?.title?? '-'}}</div>
<div class="mt-1 flex gap-1"><div class="font-bold">{{props.item?.wallet[0]?.gold?? '-'}}</div> گرم</div>
<!-- {{props.item?.wallet[0]?.cash}}-->
<div
v-if="item"
class="bg-white flex flex-col items-center p-3 rounded-xl"
:class="{ 'dfund-card': variant === 'desktop' }"
>
<img :src="displayImage" class="w-[87px] h-[87px] object-contain" alt="" @error="onImageError">
<div class="mt-3 text-center line-clamp-2">{{ item.title ?? '-' }}</div>
<div class="mt-1 flex flex-col items-center gap-0.5 text-center">
<div class="flex gap-1">
<div class="font-bold">{{ item.quantity ?? '-' }}</div>
{{ item.unit }}
</div>
<div v-if="item.unit_weight && item.display_type === 'count'" class="text-xs text-gray-500">
هر عدد {{ item.unit_weight }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
import {computed} from "vue";
import type {Fund} from "@/models/fund";
import logo from "@/assets/img/logo_full_.png";
/*function itemImg() {
import { computed, ref, watch } from 'vue'
import type { WalletInventoryCard } from '@/models/walletAssets'
import { getProductDisplayImage } from '@/utils/productAssets'
}*/
interface Prop {
item: Fund
item: WalletInventoryCard
variant?: 'mobile' | 'desktop'
}
const props = defineProps<Prop>()
const itemImg = computed(() => {
switch (props.item?.title) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default: return savingImg
}
const props = withDefaults(defineProps<Prop>(), {
variant: 'mobile',
})
const imageSrc = ref('')
/*const itemName = computed(() => {
switch (props.type) {
case 'home':
return 'خرید لوازم منزل'
case 'gift':
return 'هدیه'
case 'circulation':
return 'گردش'
case 'love':
return 'ازدواج'
case 'repairs':
return 'تعمیرات'
case 'saving':
return 'پس انداز'
default: return null
}
})*/
const displayImage = computed(() => imageSrc.value || resolveImage())
watch(
() => props.item,
() => {
imageSrc.value = resolveImage()
},
{ immediate: true, deep: true }
)
function resolveImage() {
return getProductDisplayImage({
title: props.item.title,
group_type: props.item.group_type,
file: props.item.file,
})
}
function onImageError() {
imageSrc.value = getProductDisplayImage({
title: props.item.title,
group_type: props.item.group_type,
file: null,
})
}
</script>
<style scoped>
</style>
@@ -0,0 +1,99 @@
import { computed, inject, onMounted, provide, type InjectionKey, type Ref } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { fundPageInventoryCards, formatMetalTotal as buildMetalTotalLabel } from '@/utils/productAssets'
import { getLastPrices } from '@/utils/price'
import img from '@/assets/img/cash_img.svg'
import empty from '@/assets/img/cash_empty.svg'
export interface FundPageContext {
meta: ReturnType<typeof computed>
inventoryCards: ReturnType<typeof computed>
metalTotals: ReturnType<typeof computed>
primaryMetalTotal: ReturnType<typeof computed>
cashWallet: ReturnType<typeof computed>
totalRialValue: ReturnType<typeof computed>
countBasedInventory: ReturnType<typeof computed>
fundHeaderBackground: string
fundEmptyImage: string
fundEmptyText: string
formatMetalTotal: (metal: WalletMetalTotal) => string
openFundInfo: (productId: number) => void
goBack: () => void
}
export const FUND_PAGE_KEY: InjectionKey<FundPageContext> = Symbol('fundPage')
export function useFundPage(_isDesktop: Ref<boolean>) {
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const meta = computed(() => assetsSummary.value?.meta)
const inventoryCards = computed(() =>
fundPageInventoryCards(assetsSummary.value?.inventory_cards ?? [])
)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce(
(sum, item) => sum + Number(item.quantity ?? 0),
0
)
)
function formatMetalTotal(metal: WalletMetalTotal) {
return buildMetalTotalLabel(metal, inventoryCards.value)
}
function openFundInfo(productId: number) {
const id = Number(productId)
if (!Number.isFinite(id) || id <= 0) return
router.push({ name: 'fund_info', params: { id: String(id) } })
}
function goBack() {
router.back()
}
onMounted(async () => {
try {
loading.value = true
await Promise.all([loadWalletAssets(true), getLastPrices()])
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
const context: FundPageContext = {
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground: img,
fundEmptyImage: empty,
fundEmptyText: 'هنوز محصولی خریداری نکرده‌اید',
formatMetalTotal,
openFundInfo,
goBack,
}
provide(FUND_PAGE_KEY, context)
return context
}
export function useFundPageContext() {
const context = inject(FUND_PAGE_KEY)
if (!context) {
throw new Error('useFundPageContext must be used within useFundPage')
}
return context
}
@@ -0,0 +1,114 @@
<template>
<div class="dfund">
<div class="dfund__container">
<header class="dfund-topbar">
<button type="button" class="dfund-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-topbar__meta">
<p class="dfund-topbar__eyebrow">دارایی</p>
<h1 class="dfund-topbar__title">دارایی‌ها</h1>
<p class="dfund-topbar__subtitle">نمای کلی موجودی محصولات و کیف پول</p>
</div>
</header>
<section class="dfund-summary dfund-summary--ghatreh">
<img :src="fundHeaderBackground" alt="" class="dfund-summary__bg" />
<div class="dfund-summary__content">
<h2 class="dfund-summary__title">موجودی دارایی‌ها</h2>
<div v-if="meta?.has_count_based_products" class="dfund-summary__row">
<span>محصولات</span>
<strong>{{ countBasedInventory }} عدد</strong>
</div>
<template v-if="meta?.single_metal && primaryMetalTotal">
<div class="dfund-summary__row">
<span>{{ primaryMetalTotal.label }}</span>
<strong>{{ formatMetalTotal(primaryMetalTotal) }}</strong>
</div>
</template>
<template v-else>
<div
v-for="metal in metalTotals"
:key="`metal-${metal.metal_id}`"
class="dfund-summary__row"
>
<span>{{ metal.label }}</span>
<strong>{{ formatMetalTotal(metal) }}</strong>
</div>
</template>
<div v-if="meta?.has_count_based_products" class="dfund-summary__row">
<span>مجموع موجودی</span>
<strong>{{ countBasedInventory }} عدد</strong>
</div>
<div class="dfund-summary__row">
<span>دارایی ریالی</span>
<strong class="number-ltr">{{ usePriceWithoutType(cashWallet) }} ریال</strong>
</div>
<div class="dfund-summary__row dfund-summary__row--highlight">
<span>معادل ریالی مجموع</span>
<strong class="number-ltr">{{ usePriceWithoutType(totalRialValue) }} ریال</strong>
</div>
</div>
</section>
<section class="dfund-inventory">
<div class="dfund-inventory__header">
<h3 class="dfund-inventory__title">لیست دارایی‌ها</h3>
<span class="dfund-inventory__count">{{ inventoryCards.length }} مورد</span>
</div>
<div
v-if="inventoryCards.length === 0 && !loading"
class="dfund-inventory__empty"
>
<img :src="fundEmptyImage" alt="" class="dfund-inventory__empty-img" />
<p>{{ fundEmptyText }}</p>
</div>
<a-spin :spinning="loading">
<div v-if="inventoryCards.length" class="dfund-inventory__grid">
<button
v-for="item in inventoryCards"
:key="item.product_id"
type="button"
class="dfund-inventory__card"
@click="openFundInfo(item.product_id)"
>
<Item :item="item" variant="desktop" />
</button>
</div>
</a-spin>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const {
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
<style lang="less" src="./styles/desktop-fund.less"></style>
@@ -0,0 +1,506 @@
/* Premium desktop fund page */
.dfund {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 6px 0 0;
line-height: 1.6;
}
}
.dfund-summary {
position: relative;
overflow: hidden;
border-radius: 16px;
margin-bottom: 24px;
background: @App-primary-color;
color: #fff;
&--bazretala {
background: #000;
}
&--ghatreh {
background: #efced9;
color: #374151;
}
&__bg {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
opacity: 0.08;
pointer-events: none;
&--cover {
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.35;
}
}
&__content {
position: relative;
z-index: 1;
padding: 24px 28px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px 24px;
}
&__title {
grid-column: 1 / -1;
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: #fff;
}
&__row {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
span {
opacity: 0.85;
}
strong {
font-size: 16px;
font-weight: 700;
}
&--highlight {
grid-column: 1 / -1;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
}
}
.dfund-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dfund-inventory {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
&__title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #111827;
}
&__count {
font-size: 13px;
color: #9ca3af;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
&__card {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-family: inherit;
text-align: inherit;
width: 100%;
&:hover .dfund-card {
border-color: rgba(15, 23, 42, 0.12);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
color: #6b7280;
}
&__empty-img {
width: 160px;
max-width: 100%;
}
}
.dfund-card {
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
border-radius: 14px !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@media (max-width: 1024px) {
.dfund-summary__content {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dfund-inventory__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dfund-inventory__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Fund info desktop */
.dfund-info {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund-info__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-info-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dfund-info__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.dfund-info__hero {
padding: 28px 24px;
text-align: center;
}
.dfund-info__image {
width: 120px;
height: 120px;
margin: 0 auto 16px;
border-radius: 16px;
background: #e0f2fe;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
max-width: 80%;
max-height: 80%;
object-fit: contain;
}
}
.dfund-info__balance {
display: flex;
align-items: baseline;
justify-content: center;
gap: 6px;
}
.dfund-info__balance-value {
font-size: 28px;
font-weight: 700;
color: @App-primary-color;
}
.dfund-info__balance-unit {
font-size: 14px;
color: #6b7280;
}
.dfund-info__balance-label {
margin: 8px 0 0;
font-size: 13px;
color: #9ca3af;
}
.dfund-info__transactions {
padding: 24px;
}
.dfund-info__transactions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dfund-info__transactions-title {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.dfund-info__transactions-count {
font-size: 13px;
color: #9ca3af;
}
.dfund-info__transactions-list {
max-height: 520px;
overflow-y: auto;
}
@media (max-width: 1024px) {
.dfund-info__layout {
grid-template-columns: 1fr;
}
}
/* Fund new desktop */
.dfund-new {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dfund-new__container {
max-width: 640px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dfund-new-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.dfund-new__card {
padding: 32px 28px;
}
.dfund-new__preview {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.dfund-new__preview-img {
width: 120px;
height: 120px;
object-fit: contain;
border-radius: 16px;
background: #e0f2fe;
padding: 16px;
}
.dfund-new__label {
display: block;
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.dfund-new__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0 28px;
}
.dfund-new__tag {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: #f3f4f6;
color: #4b5563;
font-size: 12px;
font-family: inherit;
cursor: pointer;
&--active {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
.dfund-new__submit {
width: 100%;
}
+14 -113
View File
@@ -1,122 +1,23 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-black font-bold drop-shadow fixed md:absolute top-0 flex h-12 z-20">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1"/>
</div>
<div class="mr-2">قلک</div>
</div>
<div class="bg-[#EFCED9] w-screen md:w-full h-[70vh] fixed md:absolute">
<div class="pt-15 flex flex-col">
<div class="text-gray-500 font-bold mr-5 z-20">موجودی قلک ها</div>
<div class="flex items-baseline justify-center mt-2 w-full z-20">
<div class="text-3xl font-bold">{{ inventory }}</div>
/گرم
</div>
<img :src="img" alt="" class="mt-[-4rem] mr-10 max-w-[22rem] self-end">
</div>
</div>
<div class="pt-[20rem] ">
<div class="bg-gray-100 w-screen md:w-full z-20 relative rounded-t-3xl h-[calc(100vh-20rem-70px)] overflow-y-auto pb-15"
style="box-shadow: 0px -2px 4px -2px #0000001A;">
<div v-if="items.length === 0 && !loading" class="w-full h-full flex justify-center items-center">
<div>
<img :src="empty" alt="" class="w-70 ">
<div class="text-center text-lg">یک قلک جدید بساز!</div>
</div>
</div>
<a-spin :spinning="loading">
<div :class="loading? 'h-[40vh]':''" class="grid grid-cols-2 gap-3 p-5">
<Item v-for="item in items" :item="item"
@click="router.push({name: 'fund_info', params:{id:item.id}})"/>
</div>
</a-spin>
</div>
</div>
<div class="fixed w-full flex justify-center bottom-23 z-200 md:right-0">
<div class="rounded-3xl bg-primary text-white px-5 py-2" @click="router.push({name: 'fund_add'})">افزودن قلک
</div>
</div>
<div class="fund-page-root">
<DesktopFundView v-show="isDesktop" />
<MobileFundView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundPage } from './composables/useFundPage'
import DesktopFundView from './desktop/DesktopFundView.vue'
import MobileFundView from './mobile/MobileFundView.vue'
import router from "@/router";
import BasicIcon from "@/components/BasicPacks/BasicIcon.vue";
import img from '@/assets/img/cash_img.svg'
import empty from '@/assets/img/cash_empty.svg'
import Item from './components/Item.vue'
import {onMounted, ref} from "vue";
import {loading} from "@/utils";
import type {TableOutput} from "@/models/table";
import type {Fund} from "@/models/fund";
import {useFundUserStore} from "@/store/fundStore";
const items = ref<Fund[]>([])
const tableOutput = ref<TableOutput<Fund> | null>(null)
const inventory = ref<number>(0)
const fundStore = useFundUserStore()
onMounted(() => {
initTable()
})
async function initTable() {
try {
loading.value = true
tableOutput.value = fundStore.myFunds ?? await fundStore.getMyFund() ?? null
// console.log(tableOutput.value)
items.value.push(...tableOutput.value?.rows ?? [])
items.value.forEach(x => {
// console.log(x.wallet[0]?.gold)
inventory.value += x?.wallet[0]?.gold ?? 0
// console.log(inventory.value)
})
// console.log(tableOutput.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
/*watch(()=> router.currentRoute.value.name, () =>{
console.log('salammmmmmmmmmmmmmmmmmmmmmmmmmm')
})*/
/*
const items = [
{
type: 'home',
amount: 2.7
},
{
type: 'gift',
amount: 13.1
},
{
type: 'saving',
amount: 6.4
},
{
type: 'repairs',
amount: 16.6
},
{
type: 'circulation',
amount: 5.4
},
{
type: 'love',
amount: 3.7
},
]
*/
const isDesktop = useDesktopBreakpoint()
useFundPage(isDesktop)
</script>
<style scoped>
</style>
.fund-page-root {
min-height: 100%;
height: 100%;
}
</style>
+42 -67
View File
@@ -1,69 +1,63 @@
<template>
<div @click="infoVisible = true">
<div class="flex">
<div :class="isUp? 'bg-green-50':'bg-red-50'"
class="rounded-lg p-3 h-fit w-fit flex justify-center items-center ">
<Icon :class="isUp? 'text-green-600':'text-red-600'"
:icon="`vuesax-linear:${isUp? 'arrow-up-2':'arrow-down-2'}`"/>
<div
:class="isUp ? 'bg-green-50' : 'bg-red-50'"
class="rounded-lg p-3 h-fit w-fit flex justify-center items-center"
>
<Icon
:class="isUp ? 'text-green-600' : 'text-red-600'"
:icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`"
/>
</div>
<div class="w-full mr-3">
<div class="flex justify-between w-full">
<div>{{ item.type_str }}</div>
<div>{{ item.title }}</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div class="flex justify-between mt-2 ">
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
</div>
<div class="flex justify-between mt-2">
<div
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
class="text-gray-500 text-xs number-ltr"
/>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div :class="item.gold > 0 ? 'text-green-600':'text-red-600'" class="text-base number-ltr">
{{ item.gold }}
<div
:class="item.gold > 0 ? 'text-green-600' : 'text-red-600'"
class="text-base number-ltr"
>
{{ formatGoldAmount(item.gold) }}
</div>
گرم
{{ item.unit }}
</div>
</div>
</div>
</div>
<!-- <div v-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="text-base">{{ usePriceWithoutType(Math.abs(item.cash) / Math.abs(item.gold)) }}</div>
ریال
</div>
</div>-->
<!-- <div v-else class="h-8"></div>-->
<a-divider class="!my-3"/>
<a-divider class="!my-3" />
<BasicActionSheet v-model:show="infoVisible">
<div class="font-bold text-lg">{{ props.item.type_str }}</div>
<div class="font-bold text-lg">{{ props.item.title }}</div>
<div class="pb-20 mt-5">
<div class="flex justify-between mb-3">
<div class="text-black">مقدار طلا مبادله شده:</div>
<div :class="item.gold >= 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center">
<div class="text-base ml-1 number-ltr">{{ item.gold }}</div>
گرم
<div class="text-black">مقدار {{ props.title }} مبادله شده:</div>
<div
:class="item.gold >= 0 ? 'text-green-600' : 'text-red-600'"
class="flex text-xs items-center"
>
<div class="text-base ml-1 number-ltr">{{ formatGoldAmount(item.gold) }}</div>
{{ item.unit }}
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-black">موجودی کیف طلا:</div>
<div class="text-black">موجودی {{ props.title }}:</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
گرم
<div class="text-base ml-1">{{ formatGoldAmount(item.balance_gold) }}</div>
{{ item.unit }}
</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-black">موجودی کیف پول:</div>
<div class="flex text-xs items-center" :class="isUp? 'text-green-600' : 'text-red-600'"><div class="text-base">{{usePriceWithoutType(12000000)}}</div>ریال</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-black">موجودی کیف طلا:</div>
<div class="flex items-center" :class="isUp? 'text-red-600' : 'text-green-600'">60 گرم</div>
</div>-->
<div class="flex justify-between mb-3">
<div class="text-black">وضعیت:</div>
<div class="flex text-xs items-center">
<ATag :color="item.active? 'blue': 'red'" class="!py-1 !px-6 !ml-0">{{
item.active ? 'موفق' : 'نا موفق'
}}
<ATag :color="item.active ? 'blue' : 'red'" class="!py-1 !px-6 !ml-0">
{{ item.active ? 'موفق' : 'نا موفق' }}
</ATag>
</div>
</div>
@@ -79,43 +73,24 @@
</div>
</BasicActionSheet>
</div>
</template>
<script lang="ts" setup>
import Icon from "@/components/BasicPacks/BasicIcon.vue";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import {computed, ref} from "vue";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import type {Transaction} from "@/models/transaction";
import {toJalaliNotime, toTime} from "../../../../../utils/useDateTime";
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { computed, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import type { Transaction } from '@/models/transaction'
import { formatGoldAmount } from '@/utils/usePrice'
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
const infoVisible = ref<boolean>(false)
interface Prop {
item: Transaction
title?: string
}
const props = withDefaults(defineProps<Prop>(), {})
const isUp = computed(() => props.item.type === 3 || props.item.type === 1)
const isGold = computed(() => props.item.type === 1 || props.item.type === 2)
/*const title = computed(() => {
if (isUp.value) {
if (isGold.value) {
return 'واریز ریالی'
} else {
return 'خرید طلا'
}
} else {
if (isGold.value) {
return 'برداشت ریالی'
} else {
return 'فروش طلا'
}
}
})*/
</script>
<style scoped>
</style>
@@ -0,0 +1,93 @@
import { computed, inject, provide, ref, watch, type InjectionKey } from 'vue'
import { useRoute } from 'vue-router'
import router from '@/router'
import { loading } from '@/utils'
import { getFundTransactionTable } from '@/api/fund'
import { getOneProductWallet } from '@/api/products'
import type { Product } from '@/models/products'
import type { TableInput, TableOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
export interface FundInfoPageContext {
info: ReturnType<typeof ref<Product | undefined>>
items: ReturnType<typeof ref<Transaction[]>>
infoId: ReturnType<typeof computed<number | null>>
goBack: () => void
}
export const FUND_INFO_PAGE_KEY: InjectionKey<FundInfoPageContext> = Symbol('fundInfoPage')
function parseRouteProductId(raw: string | string[] | undefined): number | null {
const value = Array.isArray(raw) ? raw[0] : raw
if (value == null || value === '') return null
const id = Number.parseInt(String(value), 10)
return Number.isFinite(id) && id > 0 ? id : null
}
export function useFundInfoPage() {
const route = useRoute()
const info = ref<Product>()
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const pagination = ref<TableInput>({})
const infoId = computed(() => parseRouteProductId(route.params.id))
async function initTable() {
if (!info.value?.wallet?.id) return
try {
loading.value = true
items.value = []
Object.assign(pagination.value, { wallet_id: info.value.wallet.id })
tableOutput.value = await getFundTransactionTable(pagination.value)
items.value.push(...(tableOutput.value?.rows ?? []))
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function initInfo() {
const id = infoId.value
if (id == null) {
console.error('Invalid fund product id in route')
return
}
try {
loading.value = true
info.value = await getOneProductWallet(id)
await initTable()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
watch(infoId, (id) => {
if (id != null) void initInfo()
}, { immediate: true })
function goBack() {
router.back()
}
const context: FundInfoPageContext = {
info,
items,
infoId,
goBack,
}
provide(FUND_INFO_PAGE_KEY, context)
return context
}
export function useFundInfoPageContext() {
const context = inject(FUND_INFO_PAGE_KEY)
if (!context) throw new Error('useFundInfoPageContext must be used within useFundInfoPage')
return context
}
@@ -0,0 +1,61 @@
<template>
<div class="dfund-info">
<div class="dfund-info__container">
<DesktopPageTopbar
eyebrow="جزئیات دارایی"
:title="info?.title ?? '—'"
show-back
sticky
/>
<div class="dfund-info__layout">
<section class="dfund-info__hero dfund-surface">
<a-spin :spinning="loading">
<div class="dfund-info__image">
<ProductImage
v-if="info"
:product="info"
img-class="max-w-full max-h-full object-contain"
/>
</div>
</a-spin>
<div class="dfund-info__balance">
<span class="dfund-info__balance-value">{{ info?.wallet?.gold ?? 0 }}</span>
<span class="dfund-info__balance-unit">{{ info?.unit ?? 'عدد' }}</span>
</div>
<p class="dfund-info__balance-label">موجودی {{ info?.title }}</p>
</section>
<section class="dfund-info__transactions dfund-surface">
<div class="dfund-info__transactions-header">
<h2 class="dfund-info__transactions-title">تراکنش‌ها</h2>
<span class="dfund-info__transactions-count">{{ items.length }} مورد</span>
</div>
<div class="dfund-info__transactions-list">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" :title="info?.title" />
</list-anim>
<TransactionSkeleton v-for="i in 6" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import ProductImage from '@/components/ProductImage.vue'
import { loading } from '@/utils'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import Empty from '@/components/empty.vue'
import Item from '../components/Item.vue'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items } = useFundInfoPageContext()
</script>
<style lang="less" src="../../desktop/styles/desktop-fund.less"></style>
+13 -281
View File
@@ -1,291 +1,23 @@
<template>
<div>
<div class="bg-white wrap-fix py-3 px-3 text-black font-bold drop-shadow flex h-12 z-20 justify-between">
<div class="flex ">
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">{{ info?.title }}</div>
</div>
<div @click="editShow = true">
<BasicIcon icon="vuesax-linear:setting-4" />
</div>
</div>
<div class="pt-20 flex flex-col justify-center items-center">
<a-spin :spinning="loading">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<img v-if="!loading" :src="itemImg" alt="">
</div>
</a-spin>
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-black">{{ info?.wallet[0]?.gold }}</div>
گرم
</div>
<div class="text-gray-400">موجودی قلک {{ info?.title }}</div>
<div class="w-full p-5">
<div class="w-full flex gap-3">
<BasicButton class="w-full !bg-[#F3F0E9] !border-0 !rounded" icon="vuesax-linear:add-square" icon-size="18"
@click="()=> {transactionShow = true; isDeposit= false}">برداشت
</BasicButton>
<BasicButton class="w-full !bg-[#F3F0E9] !border-0 !rounded" icon="vuesax-linear:arrow-up" icon-size="18"
@click="()=> {transactionShow = true; isDeposit= true}">واریز
</BasicButton>
</div>
<div class=" font-bold mt-5">تراکنش ها</div>
<!-- <a-spin :spinning="loading">-->
<div class="mt-5 pb-17 overflow-y-scroll h-[54vh]">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
</list-anim>
<TransactionSkeleton v-for="i in 8" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
<!-- </a-spin>-->
</div>
</div>
<BasicActionSheet v-model:show="transactionShow">
<div class="font-bold text-lg">{{ isDeposit ? 'واریز' : 'برداشت' }}</div>
<a-form ref="formRef" :model="userFormState" class="!mt-5" name="basic" @finish="transaction()">
<a-form-item :rules="[required()]" label="مقدار گرم طلا" name="value" class="!block">
<BasicInput v-model:value="userFormState.value"
addon-before="گرم" place-holder="گرم"
type="text" inputmode="decimal"
size="large" />
<div v-if="isDeposit" class="mt-2 text-xs font-bold leading-5">
<div>موجودی کیف طلا: {{ walletStore?.walletInfo?.gold }} گرم</div>
<div v-if="info?.wallet[0]?.gold !== 0">
{{ `موجودی قابل برداشت قلک: ${info?.wallet[0]?.gold ?? 0} گرم` }}
</div>
</div>
<div v-else class="mt-2 text-xs font-bold">موجودی قلک {{ info.title }}: {{ info?.wallet[0]?.gold }} گرم
</div>
</a-form-item>
<a-form-item>
<BasicButton
:disabled="transButtonDisable"
class=" w-full py-2 mt-2"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>{{ isDeposit ? 'واریز' : 'برداشت' }}</div>
</div>
</BasicButton>
<BasicButton class="mb-20 mt-2 w-full" @click="transactionShow = false">بازگشت</BasicButton>
</a-form-item>
</a-form>
</BasicActionSheet>
<BasicActionSheet v-model:show="editShow">
<div class="font-bold text-lg">قلک {{ info?.title }}</div>
<BasicButton class="mt-12 w-full" icon="vuesax-linear:edit"
icon-size="16" @click="$router.push({name: 'fund_add', query:{id:infoId}})">
<div class="flex w-full justify-right">ویرایش</div>
</BasicButton>
<BasicButton :loading="loading" class="mb-10 mt-2 w-full !text-red-600"
icon="vuesax-linear:trash"
icon-size="16"
@click="deleteFundConfirm">
<div class="flex w-full justify-right">حذف</div>
</BasicButton>
<BasicButton class="mb-20 mt-2 w-full" type="primary" @click="transactionShow = false">بازگشت</BasicButton>
</BasicActionSheet>
<DialogVant v-model:show="showDeleteConfirm" :closeOnClickOverlay="true" :timeOut="-1">
<!-- <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80">
</template>-->
<template v-slot:title>
<div class="p-5 text-sm">
آیا از حذف قلک خود اطمینان دارید؟
</div>
</template>
<template v-slot:button>
<div class="text-center text-red" @click="deleteFundFunction">
حذف
</div>
<div class="text-center" @click="showDeleteConfirm = false">
انصراف
</div>
</template>
</DialogVant>
<div class="fund-info-page-root">
<DesktopFundInfoView v-show="isDesktop" />
<MobileFundInfoView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundInfoPage } from './composables/useFundInfoPage'
import DesktopFundInfoView from './desktop/DesktopFundInfoView.vue'
import MobileFundInfoView from './mobile/MobileFundInfoView.vue'
import router from '@/router'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Item from './components/Item.vue'
import homeImg from '@/assets/img/cash_home.svg'
import giftImg from '@/assets/img/cash_gift.svg'
import circulationImg from '@/assets/img/cash_circulation.svg'
import loveImg from '@/assets/img/cash_love.svg'
import repairsImg from '@/assets/img/cash_repairs.svg'
import savingImg from '@/assets/img/cash_saving.svg'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { deleteFund, fundDeposit, fundWithdraw, getFundById, getFundTransactionTable } from '@/api/fund'
import type { Fund, FundTransaction } from '@/models/fund'
import type { TableInput, TableOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
import Empty from '@/components/empty.vue'
import { required } from '@/utils/inputRules'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { useWalletStore } from '@/store/walletStore'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import DialogVant from '@/components/DialogVant.vue'
import { useFundUserStore } from '@/store/fundStore'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
const info = ref<Fund>()
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const pagination = ref<TableInput>({/*limit: 100, page: 0*/ })
const transactionShow = ref<boolean>()
const editShow = ref<boolean>()
const showDeleteConfirm = ref<boolean>()
const isDeposit = ref<boolean>()
const formRef = ref<FormInstance>()
let fundStore = useFundUserStore()
let walletStore = useWalletStore()
onMounted(() => {
initInfo()
// initTable()
})
function deleteFundConfirm() {
if (info.value?.wallet[0] === undefined || info.value?.wallet[0]?.gold === 0) {
showDeleteConfirm.value = true
} else {
message.error('ابتدا قلک خود را خالی کنید')
editShow.value = false
}
}
async function deleteFundFunction() {
try {
loading.value = true
showDeleteConfirm.value = false
await deleteFund(infoId)
await fundStore.getMyFund()
router.back()
message.success('قلک با موفقیت حذف شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
const infoId: number = parseInt(<string>router.currentRoute.value.params.id)
const userFormState = reactive<FundTransaction>({
value: undefined,
fund_id: infoId
})
watch(transactionShow, (n) => !n ? userFormState.value = undefined : '')
const transButtonDisable = computed(() => {
if (!loading.value) {
if (userFormState.value !== undefined) {
if (isDeposit.value) {
return userFormState.value > walletStore?.walletInfo?.gold
} else {
return userFormState.value > info.value?.wallet[0]?.gold
}
// return isDeposit.value ? userFormState.value > walletStore?.walletInfo?.gold : userFormState.value > info.value?.wallet[0]?.gold
} else return true
} else {
return true
}
}
)
async function initInfo() {
try {
loading.value = true
info.value = await getFundById(infoId)
//pagination.value.page=0
await initTable()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function initTable() {
if (info.value?.wallet)
try {
loading.value = true
items.value = []
Object.assign(pagination.value, { wallet_id: info.value?.wallet[0]?.id })
// pagination.value.fund_id = infoId
tableOutput.value = await getFundTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
// console.log(tableOutput.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function transaction() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
userFormState.fund_id = infoId
isDeposit.value ? await fundDeposit(userFormState) : await fundWithdraw(userFormState)
await initInfo()
// await initTable()
await fundStore.getMyFund()
await walletStore.getMyWallet()
fundStore = useWalletStore()
walletStore = useWalletStore()
message.success(` با موفقیت${isDeposit.value ? ' واریز' : ' برداشت'} شد`)
transactionShow.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const itemImg = computed(() => {
switch (info.value?.title) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
const isDesktop = useDesktopBreakpoint()
useFundInfoPage()
</script>
<style scoped>
.fund-info-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,51 @@
<template>
<div>
<div class="bg-white wrap-fix py-3 px-3 text-black font-bold drop-shadow flex h-12 z-20">
<div class="flex">
<div class="cursor-pointer" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">{{ info?.title }}</div>
</div>
</div>
<div class="pt-20 flex flex-col justify-center items-center">
<a-spin :spinning="loading">
<div class="bg-gray-100 w-[120px] aspect-square flex justify-center items-center rounded-xl p-2">
<ProductImage
v-if="info"
:product="info"
img-class="max-h-full max-w-full object-contain"
/>
</div>
</a-spin>
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-black">{{ info?.wallet?.gold ?? 0 }}</div>
{{ info?.unit ?? 'عدد' }}
</div>
<div class="text-gray-400">موجودی {{ info?.title }}</div>
<div class="w-full p-5">
<div class="font-bold mt-5">تراکنش ها</div>
<div class="mt-5 pb-17 overflow-y-scroll h-[54vh]">
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" :title="info?.title" />
</list-anim>
<TransactionSkeleton v-for="i in 8" v-else :key="i" class="mt-1" />
<empty v-if="items.length === 0 && !loading" text="هیچ تراکنشی یافت نشد" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import ProductImage from '@/components/ProductImage.vue'
import { loading } from '@/utils'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import Empty from '@/components/empty.vue'
import Item from '../components/Item.vue'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items, goBack } = useFundInfoPageContext()
</script>
@@ -0,0 +1,114 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-black font-bold drop-shadow fixed md:absolute top-0 flex h-12 z-20">
<div @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">دارایی ها</div>
</div>
<div class="bg-[#EFCED9] w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden">
<img
:src="fundHeaderBackground"
alt=""
class="mt-[-4rem] mr-10 max-w-[22rem] self-end pointer-events-none absolute bottom-0 left-0 opacity-20"
/>
<div class="pt-15 flex flex-col relative z-10">
<div class="text-gray-700 font-bold mr-5 z-20">موجودی دارایی ها</div>
<div
v-if="meta?.has_count_based_products"
class="flex justify-center mt-4 w-full z-20"
>
محصولات:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="meta?.single_metal && primaryMetalTotal">
<div class="flex justify-center mt-2 w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
</div>
</template>
<template v-else>
<div
v-for="metal in metalTotals"
:key="`metal-${metal.metal_id}`"
class="flex justify-center mt-2 w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-4 font-bold">{{ formatMetalTotal(metal) }}</div>
</div>
</template>
<div
v-if="meta?.has_count_based_products"
class="flex justify-center mt-2 w-full z-20"
>
مجموع موجودی:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی ریالی:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
معادل ریالی مجموع دارایی ها:
<div class="text-l mr-4 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
</div>
</div>
</div>
<div class="pt-[20rem]">
<div
class="bg-gray-100 w-screen md:w-full z-20 relative rounded-t-3xl h-[calc(100vh-20rem-70px)] overflow-y-auto pb-15"
style="box-shadow: 0px -2px 4px -2px #0000001a"
>
<div
v-if="inventoryCards.length === 0 && !loading"
class="w-full h-full flex justify-center items-center"
>
<div>
<img :src="fundEmptyImage" alt="" class="w-70" />
<div class="text-center text-lg">{{ fundEmptyText }}</div>
</div>
</div>
<a-spin :spinning="loading">
<div :class="loading ? 'h-[40vh]' : ''" class="grid grid-cols-2 gap-3 p-5">
<Item
v-for="item in inventoryCards"
:key="item.product_id"
:item="item"
@click="openFundInfo(item.product_id)"
/>
</div>
</a-spin>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const {
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
@@ -0,0 +1,41 @@
<template>
<a-form :model="form" class="!mt-0" name="gift-card-form" @finish="registerGiftCard">
<a-form-item :rules="[required()]" label="کد کارت هدیه" name="code">
<BasicInput
v-model:value="form.code"
place-holder="کد کارت هدیه را وارد کنید"
size="large"
type="text"
/>
</a-form-item>
<p :class="variant === 'desktop' ? 'dgift-form__hint' : 'font-bold text-xs mt-1'">
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
</p>
<p :class="variant === 'desktop' ? 'dgift-form__case-note' : 'font-bold text-xs mt-3'">
*حساس به حروف بزرگ و کوچک
</p>
<a-form-item>
<BasicButton
:disabled="disableButton"
:class="variant === 'desktop' ? 'dgift-form__submit' : 'w-full py-2 mt-5'"
html-type="submit"
type="primary"
>
<a-spin v-if="loading" size="small" />
<span v-else>ثبت کد</span>
</BasicButton>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { loading } from '@/utils'
import { required } from '@/utils/inputRules'
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
const { form, disableButton, registerGiftCard } = useGiftCardPageContext()
</script>
@@ -0,0 +1,42 @@
import { computed, inject, provide, ref, type InjectionKey } from 'vue'
import { message } from 'ant-design-vue'
import { registerCodeGiftCard } from '@/api/giftCard'
import { loading } from '@/utils'
export interface GiftCardPageContext {
form: ReturnType<typeof ref<{ code: string }>>
disableButton: ReturnType<typeof computed<boolean>>
registerGiftCard: () => Promise<void>
}
export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> = Symbol('giftCardPageContext')
export function useGiftCardPage() {
const form = ref({ code: '' })
const disableButton = computed(() => {
if (loading.value) return true
return !form.value.code || form.value.code.trim().length === 0
})
async function registerGiftCard() {
try {
loading.value = true
await registerCodeGiftCard(form.value.code)
message.success('کد کارت هدیه با موفقیت ثبت شد')
form.value.code = ''
} finally {
loading.value = false
}
}
const context: GiftCardPageContext = { form, disableButton, registerGiftCard }
provide(GIFT_CARD_PAGE_CONTEXT_KEY, context)
return context
}
export function useGiftCardPageContext() {
const context = inject(GIFT_CARD_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useGiftCardPageContext must be used within useGiftCardPage')
return context
}
@@ -0,0 +1,43 @@
<template>
<div class="dgift gift-card-page">
<div class="dgift__container">
<DesktopPageTopbar eyebrow="مالی" title="کارت هدیه" show-back sticky />
<div class="dgift-layout">
<section class="dgift-surface dgift-form">
<div class="dgift-form__header">
<div class="dgift-form__icon">
<BasicIcon icon="vuesax-linear:gift" size="28" />
</div>
<div>
<h2 class="dgift-form__title">ثبت کد کارت هدیه</h2>
<p class="dgift-form__subtitle">کد دریافتی خود را وارد کنید تا اعتبار به حساب شما اضافه شود</p>
</div>
</div>
<GiftCardForm variant="desktop" />
</section>
<aside class="dgift-surface dgift-guide">
<h3 class="dgift-guide__title">راهنمای استفاده</h3>
<ol class="dgift-guide__steps">
<li>کد کارت هدیه را از پیامک یا کارت فیزیکی دریافت کنید.</li>
<li>کد را بدون فاصله اضافی در فیلد مربوطه وارد کنید.</li>
<li>روی «ثبت کد» بزنید و پیام تأیید را بررسی کنید.</li>
</ol>
<div class="dgift-guide__note">
<BasicIcon icon="vuesax-linear:info-circle" size="18" />
<p>هر کد فقط یک‌بار قابل استفاده است و پس از ثبت موفق، موجودی کیف پول به‌روزرسانی می‌شود.</p>
</div>
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import GiftCardForm from '../components/GiftCardForm.vue'
</script>
<style lang="less" src="./styles/desktop-gift-card.less"></style>
@@ -0,0 +1,144 @@
/* Premium desktop gift card page */
.dgift {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dgift__container {
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
box-sizing: border-box;
}
.dgift-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.dgift-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dgift-form {
padding: 28px;
&__header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(167, 43, 119, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 6px;
}
&__subtitle {
font-size: 13px;
line-height: 1.6;
color: #6b7280;
margin: 0;
}
&__hint {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 8px;
}
&__case-note {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
margin: 0 0 16px;
}
&__submit {
width: 100%;
min-height: 44px;
margin-top: 8px;
}
}
.dgift-guide {
padding: 24px;
position: sticky;
top: 88px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 16px;
}
&__steps {
margin: 0 0 20px;
padding: 0 20px 0 0;
display: flex;
flex-direction: column;
gap: 12px;
font-size: 13px;
line-height: 1.7;
color: #4b5563;
}
&__note {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 14px;
border-radius: 12px;
background: #f9fafb;
border: 1px solid #eef0f3;
color: #6b7280;
p {
margin: 0;
font-size: 12px;
line-height: 1.7;
}
.basic-icon {
color: @App-primary-color;
flex-shrink: 0;
margin-top: 2px;
}
}
}
@media (max-width: 1023.98px) {
.dgift-layout {
grid-template-columns: 1fr;
}
.dgift-guide {
position: static;
}
}
+13 -71
View File
@@ -1,81 +1,23 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-black font-bold drop-shadow z-[1999] wrap-fix">کارت هدیه</div>
<div class="px-5 pt-26 pb-2">
<a-form :model="form" class="!mt-5" name="basic" @finish="registerGiftCard">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative">
<bg_main/>
<div class="z-100 relative">
<a-form-item :rules="[required()]" label="کد کارت هدیه" name="code">
<BasicInput
v-model:value="form.code"
place-holder="کد کارت هدیه را وارد کنید"
size="large"
type="text"
/>
</a-form-item>
<div class="font-bold text-xs mt-1">
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
</div>
<div class="font-bold text-xs mt-3">
*حساس به حروف بزرگ و کوچک
</div>
</div>
</div>
<a-form-item>
<BasicButton
:disabled="disableButton"
class="w-full py-2 mt-5"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small"/>
<div v-else>ثبت کد</div>
</div>
</BasicButton>
</a-form-item>
</a-form>
</div>
<div class="gift-card-page-root">
<DesktopGiftCardView v-show="isDesktop" />
<MobileGiftCardView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import {computed, ref} from 'vue'
import {loading} from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import Bg_main from '@/components/bg_main.vue'
import {registerCodeGiftCard} from '@/api/giftCard'
import {message} from 'ant-design-vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import {required} from '@/utils/inputRules'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useGiftCardPage } from './composables/useGiftCardPage'
import DesktopGiftCardView from './desktop/DesktopGiftCardView.vue'
import MobileGiftCardView from './mobile/MobileGiftCardView.vue'
const form = ref<{ code: string }>({
code: ''
})
const disableButton = computed(() => {
if (!loading.value) {
return !form.value.code || form.value.code.trim().length === 0
} else {
return true
}
})
async function registerGiftCard() {
try {
loading.value = true
await registerCodeGiftCard(form.value.code)
message.success('کد کارت هدیه با موفقیت ثبت شد')
form.value.code = ''
} catch (e: any) {
// message.error(e?.response?.data?.message || 'خطا در ثبت کد کارت هدیه')
} finally {
loading.value = false
}
}
const isDesktop = useDesktopBreakpoint()
useGiftCardPage()
</script>
<style scoped>
.gift-card-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,48 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-black font-bold drop-shadow z-[1999] wrap-fix">کارت هدیه</div>
<div class="px-5 pt-26 pb-2">
<a-form :model="form" class="!mt-5" name="basic" @finish="registerGiftCard">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative">
<bg_main />
<div class="z-100 relative">
<a-form-item :rules="[required()]" label="کد کارت هدیه" name="code">
<BasicInput
v-model:value="form.code"
place-holder="کد کارت هدیه را وارد کنید"
size="large"
type="text"
/>
</a-form-item>
<div class="font-bold text-xs mt-1">
کد کارت هدیه خود را در فیلد بالا وارد کنید و سپس دکمه ثبت را بزنید.
</div>
<div class="font-bold text-xs mt-3">*حساس به حروف بزرگ و کوچک</div>
</div>
</div>
<a-form-item>
<BasicButton
:disabled="disableButton"
class="w-full py-2 mt-5"
html-type="submit"
type="primary"
>
<a-spin v-if="loading" size="small" />
<div v-else>ثبت کد</div>
</BasicButton>
</a-form-item>
</a-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { loading } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import Bg_main from '@/components/bg_main.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { required } from '@/utils/inputRules'
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
const { form, disableButton, registerGiftCard } = useGiftCardPageContext()
</script>
@@ -0,0 +1,31 @@
<template>
<header class="dhome-topbar">
<div>
<p class="dhome-topbar__eyebrow">نمای کلی</p>
<h1 class="dhome-topbar__title">{{ userStore.userInfo?.name ?? 'کاربر' }}</h1>
</div>
<div class="dhome-topbar__actions">
<button type="button" class="dhome-btn-ghost" @click="router.push({ name: 'user' })">
<BasicIcon icon="vuesax-linear:user" size="16" />
<span>حساب کاربری</span>
</button>
<button type="button" class="dhome-btn-ghost" @click="$emit('logout')">
<BasicIcon icon="vuesax-linear:logout" size="16" />
<span>خروج</span>
</button>
</div>
</header>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import router from '@/router'
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
</script>
+46 -55
View File
@@ -1,63 +1,54 @@
<template>
<div ref="skeletonRatio" class="h-fit">
<content-loader :width="skeletonSize.width" class="bg-white" height="100vh">
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="0"/>
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="84"/>
<rect :width="5" height="85" rx="3" ry="3" x="0" y="0"/>
<rect :width="5" :x="skeletonSize.width-5" height="85" rx="3" ry="3" y="0"/>
<rect :width="2" :x="skeletonSize.width/2" height="60" rx="3" ry="3" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="skeletonSize.width/2 + 10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-40" :x="20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-40" :x="skeletonSize.width/2 + 20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-30" :x="15" height="18" rx="10" ry="10" y="60"/>
<rect :width="skeletonSize.width/2-30" :x="skeletonSize.width/2 + 15" height="18" rx="10" ry="10" y="60"/>
<circle :cx="skeletonSize.width/4-skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/4+skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/2+ skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width - skeletonSize.width/8" cy="145" r="20"/>
<rect :width="50" :x="skeletonSize.width/4-skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/4+skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/2+ skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width - skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="skeletonSize.width - 45" :x="45" height="20" rx="3" ry="3" y="230"/>
<rect :width="skeletonSize.width" :x="0" height="80" rx="3" ry="3" y="260"/>
<rect :width="skeletonSize.width/2 - 5" :x="0" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width/2 - 5" :x="skeletonSize.width/2 + 5" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width" :x="0" height="170" rx="3" ry="3" y="440"/>
<!-- <rect height="20" rx="3" ry="3" width="100" x="0" y="0"/>
<rect :width="skeletonSize.width/4 + 40" :x="skeletonSize.width/2" height="20" rx="3" ry="3" y="0"/>
<rect height="20" rx="3" ry="3" width="80" x="0" y="30"/>
<rect :x="skeletonSize.width - 40" height="40" rx="12" ry="12" width="40" y="0"/>
<rect :width="skeletonSize.width" :x="0" :y="skeletonSize.height-5" height="2" rx="12" ry="12"/>-->
<!-- <circle :cx="skeletonSize.width - 20" :cy="skeletonSize.height/2 - 10" r="20" />-->
<!-- <rect :x="10" y="20" rx="3" ry="3" :width="skeletonSize.width - 20" height="150"/>
<circle :cx="skeletonSize.width/2" cy="170" r="15" />
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 -10" y="190" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 + 20" height="20"/>
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 +20" y="220" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 - 40" height="20"/>-->
<!-- <rect x="90" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="180" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="270" y="0" rx="3" ry="3" width="80" height="20" ♥/>-->
<div ref="skeletonRatio" class="h-fit w-full min-h-[620px]">
<content-loader
:width="skeletonWidth"
class="bg-white"
:height="skeletonHeight"
>
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="0" />
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="84" />
<rect width="5" height="85" rx="3" ry="3" x="0" y="0" />
<rect width="5" :x="skeletonWidth - 5" height="85" rx="3" ry="3" y="0" />
<rect width="2" :x="skeletonWidth / 2" height="60" rx="3" ry="3" y="15" />
<rect :width="halfWidth(20)" x="10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(20)" :x="skeletonWidth / 2 + 10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(40)" x="20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(40)" :x="skeletonWidth / 2 + 20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(30)" x="15" height="18" rx="10" ry="10" y="60" />
<rect :width="halfWidth(30)" :x="skeletonWidth / 2 + 15" height="18" rx="10" ry="10" y="60" />
<circle :cx="skeletonWidth / 4 - skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 4 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 2 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth - skeletonWidth / 8" cy="145" r="20" />
<rect width="50" :x="skeletonWidth / 4 - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 4 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 2 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="fullWidth(45)" x="45" height="20" rx="3" ry="3" y="230" />
<rect :width="skeletonWidth" x="0" height="80" rx="3" ry="3" y="260" />
<rect :width="halfWidth(10)" x="0" height="50" rx="3" ry="3" y="360" />
<rect :width="halfWidth(10)" :x="skeletonWidth / 2 + 5" height="50" rx="3" ry="3" y="360" />
<rect :width="skeletonWidth" x="0" height="170" rx="3" ry="3" y="440" />
</content-loader>
</div>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from "vue";
import {useElementSize} from "@vueuse/core";
import {ContentLoader} from "vue-content-loader";
import { computed, ref } from 'vue'
import { useElementSize } from '@vueuse/core'
import { ContentLoader } from 'vue-content-loader'
const skeletonRatio = ref(null)
const skeletonSize = reactive(
useElementSize(skeletonRatio, {width: 0, height: 0}, {box: 'border-box'})
)
/*watch(skeletonSize, () => {
console.log(skeletonSize)
})*/
const skeletonHeight = 620
const skeletonRatio = ref<HTMLElement | null>(null)
const { width } = useElementSize(skeletonRatio, { width: 0, height: 0 }, { box: 'border-box' })
const skeletonWidth = computed(() => Math.max(width.value, 320))
function halfWidth(padding: number) {
return Math.max(skeletonWidth.value / 2 - padding, 0)
}
function fullWidth(padding: number) {
return Math.max(skeletonWidth.value - padding, 0)
}
</script>
<style scoped>
</style>
@@ -0,0 +1,102 @@
import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
import { useSettingsStore } from '@/store/settingsStore'
function parseJsonArray(raw: unknown): unknown[] {
if (raw == null || raw === '') {
return []
}
if (Array.isArray(raw)) {
return raw
}
if (typeof raw !== 'string') {
return []
}
const trimmed = raw.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/** Module-level banner state — shared by mobile and desktop home layouts. */
const banners = ref<unknown[]>([])
const bannersLoading = ref(false)
let bannersPromise: Promise<void> | null = null
let bannersLoaded = false
async function fetchBanners(force = false) {
const settingsStore = useSettingsStore()
if (!settingsStore.isSettingTruthy('banner_enabled')) {
banners.value = []
bannersLoaded = true
return
}
if (!force && bannersLoaded) {
return
}
if (!force && bannersPromise) {
return bannersPromise
}
bannersPromise = (async () => {
try {
bannersLoading.value = true
const response = await getConstByKey('header_banner')
if (!response) {
banners.value = []
return
}
let list: unknown[] = []
if (typeof response === 'string') {
list = parseJsonArray(response)
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
list = parseJsonArray((response as { value?: unknown }).value)
} else if (Array.isArray(response) && response.length > 0) {
const first = response[0] as { value?: unknown; icon?: unknown }
if (first.value != null) {
list = parseJsonArray(first.value)
} else if (first.icon) {
list = response
}
}
banners.value = list.filter((banner) => (banner as { show?: boolean }).show === true)
bannersLoaded = true
} catch (error) {
console.error('Error loading banners:', error)
banners.value = []
} finally {
bannersLoading.value = false
bannersPromise = null
}
})()
return bannersPromise
}
export function useBanners() {
return {
banners,
loading: bannersLoading,
loadBanners: fetchBanners,
}
}
export function loadHomeBanners(force = false) {
return fetchBanners(force)
}
export function resetHomeBanners() {
banners.value = []
bannersLoaded = false
bannersPromise = null
}
@@ -0,0 +1,81 @@
import { ref } from 'vue'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
import { loadHomeBanners, resetHomeBanners } from './useBanners'
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
/** فقط لودینگ صفحه اصلی — نه loading سراسری @/utils */
const homePageLoading = ref(false)
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export function useHomePageLoading() {
return { homePageLoading }
}
export function isHomePageDataInitialized() {
return initialized.value
}
export async function initHomePageData(force = false) {
if (!force && initialized.value) {
startLastPricePolling()
if (getHomeLayoutId() === 'boursecu') {
void loadHomeMaftolOffset(false)
}
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
const { loadWalletAssets } = useWalletAssets()
await Promise.all([
loadHomeFunds(force),
loadWalletAssets(force),
getLastPrices(),
fetchHomeChartPrices(force),
startLastPricePolling(),
loadHomeBanners(force),
])
if (getHomeLayoutId() === 'boursecu') {
await loadHomeMaftolOffset(force)
}
initialized.value = true
} catch (error) {
console.error('Home page initialization error:', error)
} finally {
initPromise = null
}
})()
return initPromise
}
/** Clears homepage caches — call on logout or when a full refresh is required. */
export function resetHomePageData() {
initialized.value = false
initPromise = null
homePageLoading.value = false
stopLastPricePolling()
resetHomeBanners()
resetHomeFunds()
resetHomePriceChart()
clearProductsCatalog()
}
export function useHomePageInitState() {
return { initialized }
}
@@ -0,0 +1,300 @@
import { computed, ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
import type { Product } from '@/models/products'
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
import { isKatodMesGroup, isMaftolMesGroup, isShamshMesGroup } from '@/utils/productDealMethod'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
type ChartPoint = [number, number]
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const catalogProducts = getProductsCatalogRef()
function resolveChartProducts(products: Product[]) {
return {
katod: findKatodProduct(products),
maftol: findMaftolProduct(products),
shamsh: findShamshProduct(products),
}
}
/** نسبت مفتول به کاتد — reactive به قیمت لحظه‌ای و کاتالوگ محصولات */
const maftolChartRatio = computed(() => {
if (getHomeLayoutId() !== 'boursecu') {
return 0
}
void lastPrice.value?.price_last
const { katod, maftol } = resolveChartProducts(catalogProducts.value)
return deriveMetalChartRatio(maftol, katod)
})
/** نسبت شمش مس به کاتد */
const shamshChartRatio = computed(() => {
if (getHomeLayoutId() !== 'boursecu') {
return 0
}
void lastPrice.value?.price_last
const { katod, shamsh } = resolveChartProducts(catalogProducts.value)
return deriveMetalChartRatio(shamsh, katod)
})
const selectedMetalChartRatio = computed(() => {
if (selectedChartMetal.value === 'maftol') {
return maftolChartRatio.value
}
if (selectedChartMetal.value === 'shamsh') {
return shamshChartRatio.value
}
return 0
})
let chartFetchPromise: Promise<void> | null = null
let chartInitialized = false
let metalOffsetPromise: Promise<void> | null = null
let metalOffsetLoaded = false
function findKatodProduct(products: Product[]) {
return (
products.find((product) => isKatodMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('کاتد'))
)
}
function findMaftolProduct(products: Product[]) {
return (
products.find((product) => isMaftolMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('مفتول'))
)
}
function findShamshProduct(products: Product[]) {
return products.find((product) =>
isShamshMesGroup(product.group_type, product.title)
)
}
function applyChartRatio(basePrice: number, ratio: number) {
if (!ratio) {
return basePrice
}
return Math.round(basePrice + basePrice * ratio)
}
/** بالاترین/پایین‌ترین امروز — برای boursecu با نسبت مفتول/شمش نسبت به کاتد */
export function resolveMetalDayRangePrices(options: {
price: { up?: { buy?: number; sell?: number }; buy?: number; sell?: number; price_last?: number } | null | undefined
metal: ChartMetalType
isBoursecuLayout: boolean
metalRatio: number
}) {
const baseHigh = options.price?.up?.buy ?? getTradeBuyPrice(options.price as Parameters<typeof getTradeBuyPrice>[0])
const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters<typeof getTradeSellPrice>[0])
if (!options.isBoursecuLayout || options.metal === 'katod') {
return { high: baseHigh, low: baseLow }
}
return {
high: applyChartRatio(baseHigh, options.metalRatio),
low: applyChartRatio(baseLow, options.metalRatio),
}
}
function deriveMetalChartRatio(metalProduct?: Product, katodProduct?: Product) {
const metalOffset = resolvePriceOffsetRatio(metalProduct?.price_offset_percent)
if (metalOffset) {
return metalOffset
}
if (!metalProduct) {
return 0
}
const liveBuy = getTradeBuyPrice(lastPrice.value)
const katodLive = katodProduct ? getProductBuyPrice(katodProduct) : liveBuy
const metalLive = getProductBuyPrice(metalProduct)
const katodStatic = Number(katodProduct?.price ?? 0) + Number(katodProduct?.price_profit ?? 0)
const metalStatic = Number(metalProduct.price ?? 0) + Number(metalProduct.price_profit ?? 0)
const referencePrice = katodLive || liveBuy || katodStatic
const derivedMetalPrice = metalLive || metalStatic
if (!referencePrice || !derivedMetalPrice || referencePrice === derivedMetalPrice) {
return 0
}
return (derivedMetalPrice - referencePrice) / referencePrice
}
function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
}
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
if (!response || typeof response !== 'object') {
return null
}
const body = response as Record<string, unknown>
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
}
return null
}
function buildChartPoints(items: Array<{ timestamp: number; value: number }>): ChartPoint[] {
return items.map((item) => [
normalizeChartTimestamp(item.timestamp),
item.value - 1,
])
}
function syncChartSeries() {
const ratio = selectedMetalChartRatio.value
const data =
getHomeLayoutId() === 'boursecu' && selectedChartMetal.value !== 'katod'
? rawChartPoints.value.map(
([timestamp, value]) =>
[timestamp, applyChartRatio(value, ratio)] as ChartPoint
)
: rawChartPoints.value
priceChartSeries.value[0].data = data
}
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
syncChartSeries()
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
return
}
if (!force && chartFetchPromise) {
return chartFetchPromise
}
chartFetchPromise = (async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
chartLoading.value = false
chartFetchPromise = null
}
})()
return chartFetchPromise
}
async function loadMetalChartOffsets(force = false) {
if (getHomeLayoutId() !== 'boursecu') {
syncChartSeries()
return
}
if (!force && metalOffsetLoaded && catalogProducts.value.length > 0) {
return
}
if (!force && metalOffsetPromise) {
return metalOffsetPromise
}
metalOffsetPromise = (async () => {
try {
await getProductsCatalog(force)
syncChartSeries()
metalOffsetLoaded = true
} catch (error) {
console.error('Error loading boursecu chart metal ratios:', error)
syncChartSeries()
} finally {
metalOffsetPromise = null
}
})()
return metalOffsetPromise
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
void fetchPrices(true)
}
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
if (metal !== 'katod' && getHomeLayoutId() === 'boursecu' && !catalogProducts.value.length) {
void loadMetalChartOffsets(true)
}
}
export function usePriceChart() {
return {
priceChartSeries,
chartSelection,
chartLoading,
selectedChartMetal,
maftolChartRatio,
shamshChartRatio,
selectedMetalChartRatio,
updateChartData,
fetchPrices,
loadMaftolOffset: loadMetalChartOffsets,
loadMetalChartOffsets,
setChartMetal,
}
}
export function fetchHomeChartPrices(force = false) {
return fetchPrices(force)
}
export function loadHomeMaftolOffset(force = false) {
return loadMetalChartOffsets(force)
}
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
chartInitialized = false
chartFetchPromise = null
metalOffsetLoaded = false
metalOffsetPromise = null
}
@@ -0,0 +1,287 @@
<template>
<div class="dhome">
<div class="dhome__container">
<DesktopHomeTopbar @logout="$emit('logout')" />
<HomeSkeleton v-if="loading && !userStore.userInfo" />
<template v-else-if="userStore.userInfo">
<section class="dhome-hero-row">
<div class="dhome-surface dhome-hero-price">
<span class="dhome-label">قیمت خرید از کشکول گلد</span>
<div class="dhome-hero-price__value-row">
<span
v-if="percentPriceChanged() !== 0"
class="dhome-hero-price__delta"
:class="percentPriceChanged() >= 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
>
<BasicIcon
:icon="percentPriceChanged() >= 0 ? 'vuesax-linear:arrow-up-2' : 'vuesax-linear:arrow-down-2'"
size="14"
/>
<span class="dhome-hero-price__delta-value">{{ useTowDecimal(percentPriceChanged()) }}%</span>
</span>
<Vue3Odometer
:value="goldVal ?? 0"
class="dhome-hero-price__odometer o-text"
/>
<span class="dhome-hero-price__unit">ریال</span>
</div>
</div>
<div class="dhome-surface dhome-price-range">
<article class="dhome-price-range__item">
<span class="dhome-price-range__label">بیشترین امروز</span>
<p class="dhome-price-range__value dhome-price-range__value--high">
{{ usePriceWithoutType(price?.buy ?? 0) }} ریال
</p>
</article>
<article class="dhome-price-range__item">
<span class="dhome-price-range__label">کمترین امروز</span>
<p class="dhome-price-range__value dhome-price-range__value--low">
{{ usePriceWithoutType(price?.sell ?? 0) }} ریال
</p>
</article>
</div>
</section>
<div class="dhome-default-grid">
<div class="dhome-default-grid__main">
<section class="dhome-surface">
<header class="dhome-section__header">
<h2 class="dhome-section__title">عملیات</h2>
</header>
<div class="dhome-default-actions">
<button
v-for="action in mainActions"
:key="action.name"
type="button"
class="dhome-default-action"
@click="action.handler()"
>
<span class="dhome-default-action__icon">
<BasicIcon :icon="action.icon" size="20" />
</span>
<span class="dhome-default-action__label">{{ action.label }}</span>
</button>
</div>
</section>
<section class="dhome-surface dhome-chart-banner-combined">
<div class="dhome-chart-banner-combined__grid">
<div class="dhome-chart-panel">
<header class="dhome-section__header dhome-chart-panel__header">
<h2 class="dhome-section__title">نمودار قیمت</h2>
</header>
<div class="dhome-chart-panel__tabs">
<button
type="button"
class="dhome-chart-panel__tab"
:class="{ 'dhome-chart-panel__tab--active': selection === 'DAILY' }"
@click="updateData('DAILY')"
>
24 ساعت اخیر
</button>
<button
type="button"
class="dhome-chart-panel__tab"
:class="{ 'dhome-chart-panel__tab--active': selection === 'WEEKLY' }"
@click="updateData('WEEKLY')"
>
هفته گذشته
</button>
<button
type="button"
class="dhome-chart-panel__tab"
:class="{ 'dhome-chart-panel__tab--active': selection === 'MONTHLY' }"
@click="updateData('MONTHLY')"
>
ماه گذشته
</button>
</div>
<apexchart
type="area"
height="280"
:options="priceChartOptions"
:series="priceChartSeries"
/>
</div>
<div class="dhome-banner-panel">
<img :src="bannerImg" alt="" class="dhome-banner-panel__img" />
</div>
</div>
</section>
</div>
<aside class="dhome-default-grid__side">
<section class="dhome-surface dhome-assets-card">
<h3 class="dhome-assets-card__title">دارایی‌ها</h3>
<div class="dhome-assets-card__rows">
<div class="dhome-assets-card__row">
<span>دارایی طلایی</span>
<strong>{{ balanceLabel }}</strong>
</div>
<div class="dhome-assets-card__row">
<span>دارایی ریالی</span>
<strong class="number-ltr">{{ usePriceWithoutType(totalRialDisplay) }} ریال</strong>
</div>
</div>
</section>
<section v-if="userStore.userInfo?.referral_id" class="dhome-surface dhome-referral">
<h3 class="dhome-referral__title">دعوت از دوستان</h3>
<p class="dhome-referral__code">{{ userStore.userInfo.referral_id }}</p>
<div class="dhome-referral__actions">
<button type="button" class="dhome-referral__btn" @click="shareReferral">
<BasicIcon icon="vuesax-linear:share" size="16" />
<span>دعوت</span>
</button>
<button type="button" class="dhome-referral__btn" @click="copyReferralCode">
<BasicIcon icon="vuesax-linear:copy" size="16" />
<span>رونوشت</span>
</button>
</div>
</section>
</aside>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import router from '@/router'
import { getAllPrices } from '@/api/home'
import { loading } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { resolveAssetsRialValue } from '@/utils/productAssets'
import { usePrice, usePriceWithoutType, useTowDecimal } from '@/utils/usePrice'
import {
getLastPrices,
getTradeBuyPrice,
getUpPrice,
percentPriceChanged,
price,
setIntervalGetAllPrice,
} from '@/utils/price'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeTopbar from '@/views/base/home/components/DesktopHomeTopbar.vue'
import HomeSkeleton from '@/views/base/home/components/HomeSkeleton.vue'
import bannerImg from '@/assets/img/banner/2.png'
import { message } from 'ant-design-vue'
const emit = defineEmits<{ logout: []; openBalance: [] }>()
const userStore = useUserStore()
const walletStore = useWalletStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const goldVal = ref<number | undefined>()
const selection = ref<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY')
const priceChartSeries = ref([{ name: 'قیمت', data: [] as [number, number][] }])
const priceChartOptions = ref({
chart: { toolbar: { show: false }, zoom: { autoScaleYaxis: true } },
stroke: { width: 2 },
dataLabels: { enabled: false },
yaxis: { show: false },
xaxis: { type: 'datetime', labels: { show: false }, axisBorder: { show: false } },
colors: ['#a72b77'],
fill: {
type: 'gradient',
gradient: { shadeIntensity: 1, opacityFrom: 0.7, opacityTo: 0.9, stops: [0, 100] },
},
tooltip: { x: { format: 'dd/MM/yy HH:mm' } },
})
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
const primaryMetalTotal = computed(() => assetsSummary.value?.metal_totals?.[0])
const balanceLabel = computed(() => {
const meta = assetsSummary.value?.meta
if (meta?.has_count_based_products && countBasedInventory.value > 0) {
return `${countBasedInventory.value} عدد`
}
const metal = primaryMetalTotal.value
if (metal && Number(metal.total_weight) > 0) {
return `${Number(metal.total_weight).toFixed(3)} ${metal.unit}`
}
const fallback = walletStore.walletInfo?.gold_sum ?? userStore.userInfo?.wallet?.count_gold ?? 0
return `${Number(fallback).toFixed(3)} گرم`
})
const totalRialDisplay = computed(() =>
resolveAssetsRialValue(assetsSummary.value, getTradeBuyPrice(price.value) || Number(goldVal.value ?? 0))
)
const mainActions = [
{ name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) },
{ name: 'deposit', icon: 'vuesax-linear:card-add', label: 'واریز نقدی', handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }) },
{ name: 'balance', icon: 'vuesax-linear:receipt-item', label: 'موجودی', handler: () => emitBalance() },
{ name: 'fund', icon: 'vuesax-linear:archive-1', label: 'دارایی ها', handler: () => router.push({ name: 'fund' }) },
{ name: 'earn', icon: 'vuesax-linear:dollar-circle', label: 'کسب درآمد', handler: () => router.push({ name: 'earnMoney' }) },
]
function emitBalance() {
emit('openBalance')
}
async function fetchPrices() {
try {
const response = await getAllPrices()
const data = response?.data?.prices ?? {}
const selectedData = data[selection.value] ?? []
priceChartSeries.value[0].data = selectedData.map((item: { timestamp: number; value: number }) => [
item.timestamp,
item.value - 1,
])
} catch (error) {
console.error(error)
}
}
function updateData(timeline: 'DAILY' | 'WEEKLY' | 'MONTHLY') {
selection.value = timeline
void fetchPrices()
}
function copyReferralCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
function shareReferral() {
const text = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" برای شما ارسال شده.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({ title: 'کشکول گلد', text })
}
onMounted(async () => {
try {
loading.value = true
await Promise.all([
getLastPrices(),
getUpPrice(),
setIntervalGetAllPrice(),
fetchPrices(),
loadWalletAssets(true),
userStore.userInfo ? Promise.resolve() : userStore.getMyInfo(),
])
goldVal.value = price.value?.price_last
} finally {
loading.value = false
}
})
</script>
<style lang="less" src="./styles/desktop-home.less"></style>
@@ -0,0 +1,17 @@
<template>
<BoursecuDesktopHome v-if="layoutId === 'boursecu'" @logout="$emit('logout')" />
<DefaultDesktopHome v-else @logout="$emit('logout')" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { getHomeLayoutId } from '../layouts/registry'
import BoursecuDesktopHome from './layouts/BoursecuDesktopHome.vue'
import DefaultDesktopHome from './layouts/DefaultDesktopHome.vue'
defineEmits<{
logout: []
}>()
const layoutId = computed(() => getHomeLayoutId())
</script>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import AssetsSection from '../../components/AssetsSection.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import type { WalletMetalTotal } from '@/models/walletAssets'
defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
</script>
<template>
<DesktopHomeCard title="دارایی‌های من">
<AssetsSection
plain
class="home-desktop-assets"
@open-gold-modal="$emit('openGoldModal', $event)"
@open-rial-modal="$emit('openRialModal')"
/>
</DesktopHomeCard>
</template>
<style scoped>
:deep(.home-desktop-assets) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
:deep(.home-desktop-assets > div) {
margin-top: 0 !important;
justify-content: flex-start !important;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: #eff5f3;
border: 1px solid #d4e3de;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BoursecuAssetsSummary from '../../components/BoursecuAssetsSummary.vue'
import { formatWeightAmount, usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight } from '@/utils/productAssets'
import { useHomePageContext } from '../../composables/useHomePage'
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal, modalText?: string, modalTitle?: string, modalContent?: string]
openRialModal: []
}>()
const { assetsSummary } = useWalletAssets()
const ctx = useHomePageContext()
const isBoursecuLayout = computed(() => ctx.isBoursecuHomeLayout.value)
const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0),
)
type MetricItem = {
id: string
label: string
value: string
metal?: WalletMetalTotal
showInfo?: boolean
onInfo?: 'gold' | 'rial'
}
const metrics = computed<MetricItem[]>(() => {
const items: MetricItem[] = []
if (meta.value?.has_count_based_products) {
items.push({
id: 'products',
label: 'محصولات',
value: `${usePriceWithoutType(countBasedInventory.value, false)} عدد`,
})
}
if (meta.value?.single_metal && metalTotals.value[0]) {
const metal = metalTotals.value[0]
items.push({
id: `metal-${metal.metal_id}`,
label: metal.label,
value: formatMetalTotal(metal),
metal,
showInfo: true,
onInfo: 'gold',
})
} else {
metalTotals.value.forEach((metal, index) => {
items.push({
id: `metal-${metal.metal_id}`,
label: metal.label,
value: formatMetalTotal(metal),
metal,
showInfo: index === 0,
onInfo: 'gold',
})
})
}
items.push({
id: 'rial',
label: 'دارایی ریالی',
value: `${usePriceWithoutType(cashWallet.value)} ریال`,
showInfo: true,
onInfo: 'rial',
})
return items
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${formatWeightAmount(resolveSummaryMetalWeight(metal, inventoryCards.value))}${metal.unit}`
}
function handleInfo(metric: MetricItem) {
if (metric.onInfo === 'rial') {
emit('openRialModal')
return
}
if (metric.metal) {
emit('openGoldModal', metric.metal)
}
}
</script>
<template>
<BoursecuAssetsSummary
v-if="isBoursecuLayout"
variant="desktop"
icon-color="tertiary"
/>
<div v-else class="dhome-metrics">
<article v-for="metric in metrics" :key="metric.id" class="dhome-metric-tile">
<div class="dhome-metric-tile__head">
<span class="dhome-metric-tile__label">{{ metric.label }}</span>
<button
v-if="metric.showInfo"
type="button"
class="dhome-metric-tile__info"
aria-label="اطلاعات بیشتر"
@click="handleInfo(metric)"
>
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
</button>
</div>
<p class="dhome-metric-tile__value">{{ metric.value }}</p>
</article>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { inject } from 'vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import BannerSlider from '../../components/BannerSlider.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
</script>
<template>
<component
:is="embedded ? 'div' : DesktopHomeSection"
v-if="ctx.bannerEnabled.value && ctx.banners.value?.length"
:title="embedded ? undefined : 'بنرها'"
class="dhome-banners"
:class="{ 'dhome-banners--embedded': embedded }"
>
<BannerSlider
:banners="ctx.banners.value"
@bannerClick="ctx.handleBannerClick"
/>
</component>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import PriceChart from '../../components/PriceChart.vue'
import DesktopHomeCard from '../components/DesktopHomeCard.vue'
const ctx = useHomePageContext()
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
<template>
<DesktopHomeCard title="نمودار قیمت">
<PriceChart
variant="desktop"
:price-chart-series="priceChartSeries"
:layout="chartLayout"
:chart-selection="chartSelection"
:selected-metal="selectedChartMetal"
@update-data="ctx.updateChartData"
@update:selected-metal="ctx.setChartMetal"
/>
</DesktopHomeCard>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import PriceChart from '../../components/PriceChart.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
const props = withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false,
},
)
const ctx = useHomePageContext()
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
<template>
<component
:is="props.embedded ? 'div' : DesktopHomeSection"
:title="props.embedded ? undefined : 'نمودار قیمت'"
class="dhome-chart"
>
<PriceChart
variant="desktop"
:price-chart-series="priceChartSeries"
:layout="chartLayout"
:chart-selection="chartSelection"
:selected-metal="selectedChartMetal"
@update-data="ctx.updateChartData"
@update:selected-metal="ctx.setChartMetal"
/>
</component>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { toValue } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { useHomePageContext } from '../../composables/useHomePage'
import type { HomeActionItem } from '../../layouts/types'
const ctx = useHomePageContext()
function runAction(action: HomeActionItem) {
action.handler()
}
</script>
<template>
<DesktopHomeSection title="عملیات">
<div class="dhome-default-actions">
<button
v-for="action in toValue(ctx.mainActions)"
:key="action.name"
type="button"
class="dhome-default-action"
@click="runAction(action)"
>
<span class="dhome-default-action__icon">
<BasicIcon :icon="action.icon" size="20" />
</span>
<span class="dhome-default-action__label">{{ action.label }}</span>
</button>
</div>
</DesktopHomeSection>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useUserStore } from '@/store/userStore'
import { usePriceWithoutType } from '@/utils/usePrice'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const userStore = useUserStore()
</script>
<template>
<DesktopHomeSection v-if="ctx.isGaroosi.value" title="دارایی‌ها">
<div class="dhome-garoosi-assets">
<div class="dhome-garoosi-assets__item">
<span class="dhome-garoosi-assets__label">دارایی طلایی</span>
<span class="dhome-garoosi-assets__value">
{{ (userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3) }} گرم
</span>
</div>
<div class="dhome-garoosi-assets__divider" />
<div class="dhome-garoosi-assets__item">
<span class="dhome-garoosi-assets__label">دارایی ریالی</span>
<span class="dhome-garoosi-assets__value">
{{ usePriceWithoutType(userStore?.userInfo?.wallet?.count_cash ?? 0) }} ریال
</span>
</div>
</div>
</DesktopHomeSection>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useUserStore } from '@/store/userStore'
import { usePriceWithoutType } from '@/utils/usePrice'
import { isBoursecuProject } from '@/utils/config'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const userStore = useUserStore()
const isBoursecu = isBoursecuProject()
const showLimitedPrice = computed(
() =>
ctx.limitedPrice.value !== undefined &&
userStore.userInfo?.trade_active !== 1,
)
const limitedPriceText = computed(() =>
usePriceWithoutType(Number(ctx.limitedPrice.value?.value ?? 0)),
)
</script>
<template>
<div v-if="showLimitedPrice || ctx.showClosedMessage.value" class="dhome-alerts">
<div v-if="showLimitedPrice" class="dhome-alert dhome-alert--warning">
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
<span>
حداقل مبلغ اولیه برای ثبت سفارش {{ limitedPriceText }} ریال می‌باشد.
</span>
</div>
<div
v-if="!isBoursecu && ctx.showClosedMessage.value"
class="dhome-alert dhome-alert--info"
v-html="ctx.closedMessage.value"
/>
</div>
</template>
@@ -0,0 +1,12 @@
<template>
<section class="rounded-xl border border-[#d4e3de] bg-white p-5 shadow-sm">
<h2 v-if="title" class="mb-4 text-base font-bold text-primary">{{ title }}</h2>
<slot />
</section>
</template>
<script lang="ts" setup>
defineProps<{
title?: string
}>()
</script>
@@ -0,0 +1,49 @@
<template>
<header class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-gray-500">خوش آمدید</p>
<h1 class="text-xl font-bold text-gray-900">
{{ userStore.userInfo?.name ?? 'کاربر' }}
</h1>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="flex items-center gap-2 rounded-lg border border-[#d4e3de] bg-[#eff5f3] px-4 py-2 text-sm font-bold text-primary transition hover:bg-[#e4efea]"
@click="router.push({ name: 'user' })"
>
<BasicIcon icon="vuesax-linear:user" size="18" />
<span>حساب کاربری</span>
</button>
<button
v-if="showLogout"
type="button"
class="flex items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm font-bold text-gray-600 transition hover:bg-gray-50"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</header>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import router from '@/router'
withDefaults(
defineProps<{
showLogout?: boolean
}>(),
{ showLogout: true },
)
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
</script>
@@ -0,0 +1,15 @@
<template>
<section class="dhome-surface">
<header v-if="title" class="dhome-section__header">
<h2 class="dhome-section__title">{{ title }}</h2>
<slot name="header-extra" />
</header>
<slot />
</section>
</template>
<script lang="ts" setup>
defineProps<{
title?: string
}>()
</script>
@@ -0,0 +1,15 @@
<template>
<div class="dhome" :class="{ 'dhome--boursecu': isBoursecu }">
<div class="dhome__container">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import { isBoursecuProject } from '@/utils/config'
const isBoursecu = isBoursecuProject()
</script>
<style lang="less" src="../styles/desktop-home.less"></style>
@@ -0,0 +1,43 @@
<template>
<header class="dhome-topbar">
<div>
<p class="dhome-topbar__eyebrow">نمای کلی</p>
<h1 class="dhome-topbar__title">{{ userStore.userInfo?.name ?? 'کاربر' }}</h1>
</div>
<div class="dhome-topbar__actions">
<button type="button" class="dhome-btn-ghost" @click="router.push({ name: 'user' })">
<BasicIcon icon="vuesax-linear:user" size="16" />
<span>حساب کاربری</span>
</button>
<button
v-if="showLogout"
type="button"
class="dhome-btn-ghost"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="16" />
<span>خروج</span>
</button>
</div>
</header>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import router from '@/router'
withDefaults(
defineProps<{
showLogout?: boolean
}>(),
{ showLogout: true },
)
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
</script>
@@ -0,0 +1,169 @@
<template>
<div
class="dhome-surface dhome-hero-price"
:class="{
'dhome-hero-price--boursecu-open': isBoursecu && displayState === 'open',
'dhome-hero-price--boursecu-closed': isBoursecu && displayState !== 'open',
}"
>
<template v-if="displayState !== 'open'">
<div class="dhome-hero-price__closed" :class="{ 'dhome-hero-price__closed--default': !isBoursecu }">
<div class="dhome-hero-price__closed-icon">
<BasicIcon
:icon="displayState === 'closed' ? 'vuesax-linear:lock' : 'vuesax-linear:info-circle'"
size="22"
:color="isBoursecu ? '#ffffff' : '#64748b'"
/>
</div>
<p class="dhome-hero-price__closed-title">{{ statusTitle }}</p>
<p class="dhome-hero-price__closed-hint">{{ statusHint }}</p>
</div>
</template>
<template v-else>
<span class="dhome-label" :class="{ 'dhome-label--boursecu': isBoursecu }">
{{ isBoursecu ? 'قیمت لحظه‌ای کاتد مس' : 'قیمت لحظه‌ای' }}
</span>
<p v-if="!isBoursecu" class="dhome-hero-price__brand">{{ config.config?.name_fa }}</p>
<div class="dhome-hero-price__value-row">
<span
v-if="percentChange !== 0"
class="dhome-hero-price__delta"
:class="percentChange > 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
>
<BasicIcon
:icon="percentChange > 0 ? 'vuesax-linear:arrow-up-2' : 'vuesax-linear:arrow-down-2'"
size="14"
/>
<span class="dhome-hero-price__delta-value">{{ formatSignedPercentChange(percentChange) }}</span>
</span>
<Vue3Odometer
:value="displayGoldVal"
class="dhome-hero-price__odometer o-text"
:class="{ 'dhome-hero-price__odometer--boursecu': isBoursecu }"
/>
<span class="dhome-hero-price__unit" :class="{ 'dhome-hero-price__unit--boursecu': isBoursecu }">ریال</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useConfigStore } from '@/store/configStore'
import { useUserStore } from '@/store/userStore'
import { isBoursecuProject } from '@/utils/config'
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
import {
NO_QUOTE_MESSAGE,
getPriceDisplayState,
getStoreClosedMessage,
} from '@/utils/storeHours'
import { formatSignedPercentChange } from '@/utils/usePrice'
const config = useConfigStore()
const userStore = useUserStore()
const isBoursecu = isBoursecuProject()
const displayState = computed(() => getPriceDisplayState())
const statusTitle = computed(() =>
displayState.value === 'closed' ? 'فروشگاه بسته است' : NO_QUOTE_MESSAGE
)
const statusHint = computed(() => {
if (displayState.value === 'closed') {
const custom = userStore.getValueByKey('closed_message')
if (typeof custom === 'string' && custom.trim()) {
return custom.replace(/<[^>]*>/g, '').trim()
}
return getStoreClosedMessage()
}
return 'در حال حاضر مظنه‌ای برای نمایش وجود ندارد.'
})
const displayGoldVal = computed(() => {
if (displayState.value !== 'open') return 0
return getTradeBuyPrice(lastPrice.value)
})
const percentChange = computed(() => {
if (!getTradeBuyPrice(lastPrice.value)) return 0
return percentPriceChanged()
})
</script>
<style scoped lang="less">
.dhome-hero-price--boursecu-open {
background: linear-gradient(135deg, #c98a4a 0%, #a86528 100%);
border-color: transparent;
box-shadow: 0 4px 14px rgba(168, 101, 40, 0.28);
}
.dhome-hero-price--boursecu-closed {
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
border-color: transparent;
box-shadow: 0 4px 14px rgba(45, 55, 72, 0.22);
}
.dhome-label--boursecu {
color: rgba(255, 255, 255, 0.92);
}
.dhome-hero-price__odometer--boursecu {
color: #ffffff !important;
}
.dhome-hero-price__unit--boursecu {
color: rgba(255, 255, 255, 0.85);
}
.dhome-hero-price__closed {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.dhome-hero-price__closed-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
margin-bottom: 4px;
}
.dhome-hero-price__closed-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #ffffff;
}
.dhome-hero-price__closed-hint {
margin: 0;
font-size: 12px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.78);
}
.dhome-hero-price__closed--default {
.dhome-hero-price__closed-icon {
background: #f1f5f9;
}
.dhome-hero-price__closed-title {
color: #334155;
}
.dhome-hero-price__closed-hint {
color: #64748b;
}
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import CurrentPrice from '../../components/CurrentPrice.vue'
import PriceStats from '../../components/PriceStats.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
</script>
<template>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<DesktopHomeCard title="قیمت لحظه‌ای">
<CurrentPrice class="home-desktop-current-price" />
</DesktopHomeCard>
<DesktopHomeCard title="بازه قیمت امروز">
<PriceStats variant="desktop" />
</DesktopHomeCard>
</div>
</template>
<style scoped>
:deep(.home-desktop-current-price) {
margin-top: 0;
border: none;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { price } from '@/utils/price'
import { usePriceWithoutType } from '@/utils/usePrice'
import { getHomeLayoutId } from '../../layouts/registry'
import { resolveMetalDayRangePrices, usePriceChart } from '../../composables/usePriceChart'
const { selectedChartMetal, selectedMetalChartRatio } = usePriceChart()
const isBoursecuLayout = computed(() => getHomeLayoutId() === 'boursecu')
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: price.value,
metal: selectedChartMetal.value,
isBoursecuLayout: isBoursecuLayout.value,
metalRatio: selectedMetalChartRatio.value,
})
)
const highPrice = computed(() => rangePrices.value.high)
const lowPrice = computed(() => rangePrices.value.low)
</script>
<template>
<div class="dhome-surface dhome-price-range">
<div class="dhome-price-range__item">
<span class="dhome-label">بیشترین امروز</span>
<span class="dhome-price-range__value dhome-price-range__value--high">
{{ usePriceWithoutType(highPrice) }}
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
</span>
</div>
<div class="dhome-price-range__divider" />
<div class="dhome-price-range__item">
<span class="dhome-label">کمترین امروز</span>
<span class="dhome-price-range__value dhome-price-range__value--low">
{{ usePriceWithoutType(lowPrice) }}
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
</span>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import router from '@/router'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
const props = withDefaults(
defineProps<{
actions?: HomeQuickActionItem[]
}>(),
{
actions: () => boursecuQuickActions,
},
)
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
}
</script>
<template>
<DesktopHomeCard title="دسترسی سریع">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<HomeQuickActionBox
v-for="action in props.actions"
:key="action.id"
:action="action"
class="home-desktop-quick-action"
@click="handleClick(action)"
/>
</div>
</DesktopHomeCard>
</template>
<style scoped>
:deep(.home-desktop-quick-action) {
min-height: 108px;
}
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
const props = withDefaults(
defineProps<{
actions?: HomeQuickActionItem[]
title?: string
}>(),
{
actions: () => boursecuQuickActions,
title: 'میانبرها',
},
)
function navigate(action: HomeQuickActionItem) {
router.push(action.route)
}
</script>
<template>
<DesktopHomeSection :title="title">
<nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی">
<button
v-for="action in props.actions"
:key="action.id"
type="button"
class="dhome-action"
@click="navigate(action)"
>
<span class="dhome-action__icon">
<img
v-if="action.iconSrc"
:src="action.iconSrc"
alt=""
class="dhome-action__icon-image"
/>
<BasicIcon v-else :icon="action.icon" size="18" />
</span>
<span class="dhome-action__label">{{ action.label }}</span>
<BasicIcon icon="vuesax-linear:arrow-left" size="14" class="dhome-action__chevron" />
</button>
</nav>
</DesktopHomeSection>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { inject } from 'vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import ReferralSection from '../../components/ReferralSection.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
</script>
<template>
<DesktopHomeSection v-if="ctx.isGaroosi.value" title="دعوت از دوستان">
<ReferralSection />
</DesktopHomeSection>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/userStore'
import { getToken } from '@/utils'
import { useHomePageContext } from '../../composables/useHomePage'
import HomeSkeleton from '../../components/HomeSkeleton.vue'
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
import DesktopHomeTopbar from '../components/DesktopHomeTopbar.vue'
import DesktopHomeAlerts from '../components/DesktopHomeAlerts.vue'
import DesktopLivePrice from '../components/DesktopLivePrice.vue'
import DesktopPriceRange from '../components/DesktopPriceRange.vue'
import DesktopAssetsMetrics from '../components/DesktopAssetsMetrics.vue'
import DesktopChartSection from '../components/DesktopChartSection.vue'
import DesktopQuickActionsPanel from '../components/DesktopQuickActionsPanel.vue'
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const ctx = useHomePageContext()
const showContent = computed(() => Boolean(userInfo.value))
const showSkeleton = computed(() => Boolean(getToken()) && !userInfo.value)
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-if="showContent">
<DesktopAssetsMetrics
@open-gold-modal="ctx.openGoldModal"
@open-rial-modal="ctx.openRialModal"
/>
<section class="dhome-hero-row">
<DesktopLivePrice />
<DesktopPriceRange />
</section>
<section class="dhome-main-grid">
<DesktopChartSection />
<DesktopQuickActionsPanel />
</section>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/userStore'
import { getToken } from '@/utils'
import { useHomePageContext } from '../../composables/useHomePage'
import HomeSkeleton from '../../components/HomeSkeleton.vue'
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
import DesktopHomeTopbar from '../components/DesktopHomeTopbar.vue'
import DesktopHomeAlerts from '../components/DesktopHomeAlerts.vue'
import DesktopLivePrice from '../components/DesktopLivePrice.vue'
import DesktopPriceRange from '../components/DesktopPriceRange.vue'
import DesktopAssetsMetrics from '../components/DesktopAssetsMetrics.vue'
import DesktopChartSection from '../components/DesktopChartSection.vue'
import DesktopDefaultActions from '../components/DesktopDefaultActions.vue'
import DesktopGaroosiAssets from '../components/DesktopGaroosiAssets.vue'
import DesktopReferralPanel from '../components/DesktopReferralPanel.vue'
import DesktopBannersPanel from '../components/DesktopBannersPanel.vue'
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const ctx = useHomePageContext()
const showContent = computed(() => Boolean(userInfo.value))
const showSkeleton = computed(() => Boolean(getToken()) && !userInfo.value)
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-if="showContent">
<section class="dhome-hero-row">
<DesktopLivePrice />
<DesktopPriceRange />
</section>
<DesktopAssetsMetrics
v-if="!ctx.isGaroosi.value"
@open-gold-modal="ctx.openGoldModal"
@open-rial-modal="ctx.openRialModal"
/>
<div v-if="ctx.isGaroosi.value" class="dhome-default-grid">
<div class="dhome-default-grid__main">
<DesktopDefaultActions />
<section
v-if="ctx.bannerEnabled.value && ctx.banners.value?.length"
class="dhome-surface dhome-chart-banner-combined"
>
<header class="dhome-section__header">
<h2 class="dhome-section__title">نمودار قیمت</h2>
</header>
<div class="dhome-chart-banner-combined__grid">
<DesktopChartSection embedded />
<DesktopBannersPanel embedded />
</div>
</section>
<DesktopChartSection v-else />
</div>
<aside class="dhome-default-grid__side">
<DesktopGaroosiAssets />
<DesktopReferralPanel />
</aside>
</div>
<template v-else>
<DesktopDefaultActions />
<section
v-if="ctx.bannerEnabled.value && ctx.banners.value?.length"
class="dhome-surface dhome-chart-banner-combined"
>
<header class="dhome-section__header">
<h2 class="dhome-section__title">نمودار قیمت</h2>
</header>
<div class="dhome-chart-banner-combined__grid">
<DesktopChartSection embedded />
<DesktopBannersPanel embedded />
</div>
</section>
<DesktopChartSection v-else />
</template>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,714 @@
/* Premium desktop home — SaaS dashboard design tokens */
.dhome {
min-height: 100%;
background: #f6f7f9;
color: #111827;
font-feature-settings: 'ss01' on;
}
.dhome__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dhome-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: #9ca3af;
margin-bottom: 6px;
}
.dhome-surface {
background: #ffffff;
border-radius: 14px;
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-width: 0;
overflow: hidden;
.home-referral {
margin-top: 0;
}
}
.dhome-section {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #374151;
letter-spacing: -0.01em;
}
}
/* Topbar */
.dhome-topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin-bottom: 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
line-height: 1.2;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.dhome-btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: #111827;
background: rgba(15, 23, 42, 0.04);
}
}
/* Alerts */
.dhome-alerts {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.dhome-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
&--warning {
background: #fef3f2;
color: #b42318;
}
&--info {
background: #f0f4ff;
color: #344054;
}
}
/* Hero price */
.dhome-hero-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
margin-bottom: 16px;
}
.dhome-hero-price {
&__brand {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px;
}
&__value-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
}
&__odometer {
font-size: 32px !important;
font-weight: 600 !important;
letter-spacing: -0.03em;
color: #111827;
direction: ltr;
line-height: 1;
}
&__unit {
font-size: 14px;
font-weight: 500;
color: #9ca3af;
}
&__delta {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
&-value {
direction: ltr;
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
}
&--up {
background: #ecfdf3;
color: #027a48;
}
&--down {
background: #fef3f2;
color: #b42318;
}
}
}
.dhome-price-range {
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
height: 100%;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__value {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
direction: ltr;
text-align: right;
}
&__value--high {
color: #027a48;
}
&__value--low {
color: #b42318;
}
&__divider {
height: 1px;
background: rgba(15, 23, 42, 0.06);
}
}
/* Asset metrics */
.dhome-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.dhome-metric-tile {
background: #ffffff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
&__label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
&__info {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: #f3f4f6;
color: #374151;
}
}
&__value {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
line-height: 1.2;
}
}
/* Main dashboard grid */
.dhome-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 272px;
gap: 16px;
align-items: start;
}
/* Chart + banners in one card */
.dhome-chart-banner-combined {
&__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 38%);
gap: 20px;
align-items: start;
}
}
.dhome-banners {
min-width: 0;
&--embedded {
align-self: center;
border-radius: 12px;
overflow: hidden;
background: #f9fafb;
}
:deep(.home_appear_fade_in) {
margin-top: 0;
}
:deep(.banner-swiper) {
width: 100%;
aspect-ratio: 2.8 / 1;
max-height: 13rem;
border-radius: 12px;
overflow: hidden;
}
:deep(.swiper-slide),
:deep(.banner-img) {
width: 100%;
height: 100%;
}
:deep(.banner-img) {
object-fit: cover;
}
}
.dhome-chart {
:deep(.bg-gray-50) {
background: transparent !important;
border-radius: 0;
padding-top: 0;
padding-bottom: 0;
}
:deep(.w-full.text-center.pt-2) {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
:deep(.chart-metal-segment) {
display: inline-flex;
background: #f3f4f6;
border-radius: 8px;
padding: 3px;
margin-bottom: 0 !important;
flex-shrink: 0;
}
:deep(.chart-metal-segment__item) {
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
:deep(.chart-timeline-group) {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
width: 100%;
margin-bottom: 4px;
}
:deep(.chart-timeline-group .ant-radio-button-wrapper) {
margin: 0;
}
}
/* Quick actions */
.dhome-actions {
display: flex;
flex-direction: column;
gap: 2px;
}
.dhome-action {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 11px 10px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: background 0.15s ease;
&:hover {
background: #f9fafb;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #f3f4f6;
color: @App-primary-color;
flex-shrink: 0;
transition: background 0.15s ease;
overflow: visible;
}
&__icon-image {
width: 18px;
height: 18px;
object-fit: contain;
transform: scale(2.2);
}
&:hover &__icon {
background: #eef2f6;
}
&__label {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #374151;
}
&__chevron {
color: #d1d5db;
flex-shrink: 0;
}
}
/* Default layout grid */
.dhome-default-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
align-items: start;
}
.dhome-default-grid__main,
.dhome-default-grid__side {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.dhome-default-actions {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.dhome-default-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 18px 12px;
border: none;
border-radius: 12px;
background: #f9fafb;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, transform 0.15s ease;
&:hover {
background: #f3f4f6;
transform: translateY(-1px);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #ffffff;
color: @App-primary-color;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
&__label {
font-size: 12px;
font-weight: 600;
color: #374151;
text-align: center;
line-height: 1.4;
}
}
/* Sidebar: assets + referral */
.dhome-assets-card {
padding: 20px;
&__title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin: 0 0 16px;
}
&__rows {
display: flex;
flex-direction: column;
gap: 14px;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: #6b7280;
strong {
font-size: 15px;
font-weight: 600;
color: #111827;
text-align: left;
}
}
}
.dhome-referral {
padding: 20px;
&__title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin: 0 0 12px;
}
&__code {
display: block;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
text-align: center;
color: #111827;
margin: 0 0 16px;
direction: ltr;
}
&__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 10px 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
background: #f9fafb;
color: #374151;
font-family: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: #f3f4f6;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dhome-chart-panel {
min-width: 0;
&__tabs {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
background: #f3f4f6;
border-radius: 8px;
padding: 3px;
margin-bottom: 14px;
}
&__tab {
border: none;
background: transparent;
padding: 8px 14px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
&--active {
background: @App-primary-color;
color: #fff;
font-weight: 600;
}
}
}
.dhome-banner-panel {
align-self: stretch;
border-radius: 12px;
overflow: hidden;
background: #f9fafb;
min-height: 220px;
&__img {
display: block;
width: 100%;
height: 100%;
min-height: 220px;
object-fit: cover;
}
}
/* Garoosi assets strip */
.dhome-garoosi-assets {
display: flex;
flex-direction: column;
gap: 14px;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
&__value {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
direction: ltr;
text-align: right;
}
&__divider {
height: 1px;
background: rgba(15, 23, 42, 0.06);
}
}
/* Boursecu subtle accent */
.dhome--boursecu {
.dhome-action__icon {
background: rgba(19, 48, 80, 0.06);
color: #133050;
}
.dhome-hero-price__odometer {
color: #133050;
}
}
@media (max-width: 1024px) {
.dhome__container {
padding: 24px 28px 36px;
}
.dhome-hero-row,
.dhome-main-grid,
.dhome-chart-banner-combined__grid,
.dhome-default-grid {
grid-template-columns: 1fr;
}
.dhome-default-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+126 -123
View File
@@ -1,12 +1,14 @@
<template>
<div class=" mt-20">
<!-- <div class="flex gap-7 overflow-x-scroll px-5">
<ProductInventory v-for="item in productInventorys" :ProductItem="item" class="shadow-lg my-5" :key="item.id"></ProductInventory>
</div>-->
<div>
<div class="home-page-root">
<GhatrehHomeDesktopView
v-show="isDesktop"
@logout="showLogoutConfirm = true"
@open-balance="openWholeBalanceVisible()"
/>
<div v-show="!isDesktop" class="mt-20">
<div
v-if="userStore.userInfo && !loading"
class="bg-white w-screen md:w-auto h-fit px-5 m-[-1px]"
v-if="!loading"
class="bg-white w-full md:w-auto h-fit px-5 m-[-1px]"
>
<div
v-if="limitedPrice !== undefined && userStore?.userInfo.trade_active !== 1"
@@ -14,7 +16,7 @@
>
<Icon icon="vuesax-linear:info-circle" size="16" />
حداقل مبلغ اولیه برای ثبت سفارش
{{ usePriceWithoutType(limitedPrice.value as number ?? 0) }} ریال می باشد.
{{ usePriceWithoutType(parseInt(limitedPrice.value) ?? 0) }} ریال می باشد.
</div>
<div
@@ -34,7 +36,7 @@
</div>
<div class="flex justify-between items-end mb-3">
<div class="text-sm "></div>
<div class="text-sm" />
<div>
<div class="text-xs opacity-75" style="justify-self: end;">شناسه شما در کشکول گلد</div>
<div class="flex">
@@ -53,14 +55,13 @@
<div class="flex justify-between items-center font-semibold text-base">
<div>
<div class="text-xs opacity-75 mb-1">موجودی</div>
<div>{{ (userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3) }} گرم</div>
<div>{{ balanceLabel }}</div>
</div>
<div class="w-px h-6 bg-white/30 mx-3"></div>
<div>
<div class="text-xs opacity-75 mb-1" style="justify-self: end;">معادل ریالی</div>
<div>{{ usePriceWithoutType( ((userStore?.userInfo?.wallet?.count_gold ?? 0) * (goldVal ?? 0)).toFixed(0) ) }} ریال</div>
<div>{{ usePriceWithoutType(totalRialDisplay) }} ریال</div>
</div>
</div>
</div>
</div>
@@ -141,7 +142,7 @@
>
<Icon class="text-white font-bold" icon="vuesax-linear:archive-1" />
</div>
<div class="text-primary">قلک</div>
<div class="text-primary">دارایی ها</div>
</div>
<div
class="text-center w-full flex-flex-col items-center home_appear_fade_in_down"
@@ -278,13 +279,20 @@
</div>
</div>
<swiper
:slides-per-view="1"
:space-between="5"
>
<swiper-slide><img class="img rounded-lg" :src="img2" /></swiper-slide>
<swiper-slide><img class="img rounded-lg" :src="img2" /></swiper-slide>
</swiper>
<div class="home-banner w-full mt-5 overflow-hidden rounded-lg">
<swiper
:slides-per-view="1"
:space-between="5"
class="home-banner__swiper"
>
<swiper-slide class="home-banner__slide">
<img class="home-banner__img rounded-lg" :src="img2" alt="" />
</swiper-slide>
<swiper-slide class="home-banner__slide">
<img class="home-banner__img rounded-lg" :src="img2" alt="" />
</swiper-slide>
</swiper>
</div>
<div class="flex mt-5 gap-1 bg-primary text-white font-bold justify-center cursor-pointer rounded-lg py-5"
@@ -352,24 +360,12 @@
</div>
</div>
<div v-else class="bg-white w-screen md:w-auto h-[80vh] px-5 m-[-1px] flex justify-center ">
<div v-else class="bg-white w-full md:w-auto h-[80vh] px-5 m-[-1px] flex justify-center">
<HomeSkeleton class="w-full" />
</div>
<!-- <div class="relative pt-15 overflow-hidden ">-->
<!-- <div class="absolute left&#45;&#45;12 top-0">-->
<!-- <img :src="image">-->
<!-- </div>-->
<!-- <div class="bg-primary pb-30"-->
<!-- style="background: linear-gradient(to bottom, #fff,#fff, #a72b77, #a72b77);"-->
<!-- >-->
<!-- <div class=" text-tertiary text-2xl pt-6 pr-5 pl-50" style="font-size: 26px;font-weight: 600; color: #a72b77" >ارسال کالا بعد از ثبت سفارش</div>-->
<!-- <div class="pr-5 pl-8 pt-10 text-tertiary ">میتوانید با خرید محصولات بعد از ثبت سفارش درخواست ارسال را-->
<!-- ثبت کرده و زیر 24 ساعت خرید خود را دریافت کنید-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div>
<div
v-if="!isDesktop"
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
>
@@ -417,7 +413,6 @@
<div class="text-center" @click="showLogoutConfirm = false">انصراف</div>
</template>
</DialogVant>
</div>
<BasicActionSheet v-model:show="wholeBalanceVisible">
<div class="pb-20">
<div>
@@ -428,30 +423,22 @@
class="flex justify-between"
title="مجموعه کل دارایی طلایی"
is-line
:info="`در این قسمت مجموع کل وزن طلای آبشده (${(walletStore.walletInfo?.gold ?? 0)?.toFixed(3)}) + ارزش دارایی ها (${(walletStore.walletInfo?.treasure_sum ?? 0)?.toFixed(3)} ) را مشاهده میفرمایید.`"
infoHead="مجموع کل دارایی طلایی"
:value="parseFloat((walletStore.walletInfo?.gold_sum ?? 0).toFixed(2)) + ' گرم'"
:value="balanceLabel"
/>
<!-- <a-divider class="!my-1" />
<BasicInfoItem
class="flex justify-between"
title="میزان طلای آبشده"
is-line
:value="parseFloat((walletStore.walletInfo?.gold ?? 0)?.toFixed(3)) + ' گرم'"
/>
<a-divider class="!my-1" />
<BasicInfoItem
class="flex justify-between"
title="ارزش گنج ها"
is-line
:value="parseFloat((walletStore.walletInfo?.treasure_sum ?? 0)?.toFixed(3)) + ' گرم'"
/>-->
<a-divider class="!my-1" />
<BasicInfoItem
v-if="countBasedInventory > 0"
class="flex justify-between"
title="تعداد محصولات"
is-line
:value="`${countBasedInventory} عدد`"
/>
<a-divider v-if="countBasedInventory > 0" class="!my-1" />
<BasicInfoItem
class="flex justify-between"
title="دارایی کیف پول ریالی"
is-line
:value="usePrice(walletStore.walletInfo?.cash ?? 0)"
:value="usePrice(assetsSummary?.cash_wallet ?? walletStore.walletInfo?.cash ?? 0)"
/>
<a-divider class="!my-1" />
<BasicInfoItem
@@ -468,14 +455,7 @@
class="flex justify-between"
title="ارزش کل دارایی شما"
is-line
:value="
usePrice(
(
(walletStore.walletInfo?.gold_sum ?? 0) * (goldVal ?? 0) +
(walletStore.walletInfo?.cash ?? 0)
).toFixed(0)
)
"
:value="usePrice(totalRialDisplay)"
/>
</div>
</div>
@@ -502,7 +482,7 @@
</a-button>
</template>
</BasicModal>
</div>
</template>
<script lang="tsx" setup>
@@ -511,7 +491,7 @@ import 'swiper/swiper-bundle.css';
import img2 from '@/assets/img/banner/2.png'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { usePrice, usePriceWithoutType, useTowDecimal } from '../../../utils/usePrice'
import {computed, createVNode, onMounted, onUnmounted, ref, watch} from 'vue'
import {computed, createVNode, onActivated, onMounted, onUnmounted, ref, watch} from 'vue'
import router from '@/router'
import * as echarts from 'echarts'
import { useIntersectionObserver, useWindowScroll } from '@vueuse/core'
@@ -523,8 +503,10 @@ import { getAllPrices, getChart } from '@/api/home'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import HomeSkeleton from '@/views/base/home/components/HomeSkeleton.vue'
import GhatrehHomeDesktopView from '@/views/base/home/desktop/GhatrehHomeDesktopView.vue'
import {
getLastPrices,
getTradeBuyPrice,
getUpPrice,
lastPrice,
percentPriceChanged,
@@ -537,9 +519,9 @@ import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import ProductInventory from '@/views/base/home/components/ProductInventory.vue'
import type { PInventory, Product } from '@/models/products'
import type { Fund } from '@/models/fund'
import type { TableOutput } from '@/models/table'
import { useFundUserStore } from '@/store/fundStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { resolveAssetsRialValue } from '@/utils/productAssets'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { message, Modal } from 'ant-design-vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
@@ -547,6 +529,7 @@ import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
const userStore = useUserStore()
const walletStore = useWalletStore()
const isDesktop = useDesktopBreakpoint()
const chartLoading = ref<boolean>(false)
const openModal = ref<boolean>(false)
const wholeBalanceVisible = ref<boolean>(false)
@@ -557,23 +540,41 @@ const main = ref(null)
const logoutLoading = ref(false)
const productInventorys = ref<PInventory[] | undefined>()
const value = ref<string>('a')
const items = ref<Fund[]>([])
const tableOutput = ref<TableOutput<Fund> | null>(null)
const inventory = ref<number>(0)
const sumGold = ref<number>(0)
const fundStore = useFundUserStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
async function initTable() {
try {
loading.value = true
tableOutput.value = fundStore.myFunds ?? await fundStore.getMyFund() ?? null
items.value.push(...tableOutput.value?.rows ?? [])
} catch (e) {
console.log(e)
} finally {
loading.value = false
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce(
(sum, item) => sum + Number(item.quantity ?? 0),
0
)
)
const primaryMetalTotal = computed(() => assetsSummary.value?.metal_totals?.[0])
const balanceLabel = computed(() => {
const meta = assetsSummary.value?.meta
if (meta?.has_count_based_products && countBasedInventory.value > 0) {
return `${countBasedInventory.value} عدد`
}
}
const metal = primaryMetalTotal.value
if (metal && Number(metal.total_weight) > 0) {
return `${Number(metal.total_weight).toFixed(3)} ${metal.unit}`
}
const fallback =
walletStore.walletInfo?.gold_sum ??
userStore.userInfo?.wallet?.count_gold ??
0
return `${Number(fallback).toFixed(3)} گرم`
})
const totalRialDisplay = computed(() =>
resolveAssetsRialValue(
assetsSummary.value,
getTradeBuyPrice(price.value) || Number(goldVal.value ?? 0)
)
)
onUnmounted(() => {
stopPriceInterval()
@@ -588,20 +589,9 @@ onMounted(() => {
const goldVal = ref<number | undefined>()
function openWholeBalanceVisible() {
items.value.forEach(x => {
if (x.title !== 'آبشده نقدی')
inventory.value += x?.wallet?.gold ?? 0
})
wholeBalanceVisible.value = true
}
function sumOfGold() {
items.value.forEach(x => {
if (x.title !== 'آبشده نقدی')
return sumGold.value += x?.wallet?.gold ?? 0
})
}
function update() {
setInterval(() => {
goldVal.value = lastPrice?.value.price_last
@@ -859,42 +849,35 @@ async function initPrice() {
}
}
async function initProductInventory() {
try {
// loading.value = true
// let { rows } = await getProductInventory()
// fundStore.myFunds
// productInventorys.value = fundStore.myFunds?.rows
productInventorys.value = items.value.sort((a, b) => {
if (a.wallet?.gold ?? 0 < b.wallet?.gold ?? 0)
return -1
if (a.wallet?.gold ?? 0 > b.wallet?.gold ?? 0)
return 1
return 0
})
// console.log(productInventorys.value)
} catch (e) {
console.log(e)
} finally {
// loading.value = false
}
}
function closeModal() {
openModal.value = false
localStorage.setItem('hasSeenIntroModal', 'true')
router.push({ name: 'earnMoney' })
}
onActivated(() => {
void loadWalletAssets(true)
if (!userStore.userInfo) {
void userStore.getMyInfo()
}
})
onMounted(async () => {
try {
loading.value = true
await initTable()
await initProductInventory()
// await initChart()
await initPrice()
await fetchPrices()
const tasks: Promise<unknown>[] = [
initPrice(),
fetchPrices(),
loadWalletAssets(true),
]
if (!userStore.userInfo) {
tasks.push(userStore.getMyInfo())
} else if (!walletStore.walletInfo && userStore.userInfo.wallet) {
walletStore.walletInfo = userStore.userInfo.wallet
}
await Promise.all(tasks)
update()
const hasSeenModal = localStorage.getItem('hasSeenIntroModal')
if (!hasSeenModal) {
@@ -957,6 +940,29 @@ function copyCode() {
<style lang="less">
.home-banner {
width: 100%;
&__swiper {
width: 100%;
}
&__slide {
display: flex;
justify-content: center;
align-items: center;
}
&__img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 0 auto;
object-fit: cover;
}
}
.odometer {
font-size: 60px;
text-align: center;
@@ -982,7 +988,4 @@ apexchart {
padding: 0 !important;
margin: 0 !important;
}
</style>
<script setup lang="ts">
</script>
</style>
+51
View File
@@ -0,0 +1,51 @@
import { defineAsyncComponent, type Component } from 'vue'
import { getConfigApp, isBoursecuProject } from '@/utils/config'
import type { HomeLayoutConfig, HomeLayoutId } from './types'
import { defaultHomeLayoutConfig } from './configs/default'
import { boursecuHomeLayoutConfig } from './configs/boursecu'
export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default'
const HOME_LAYOUT_CONFIGS: Record<HomeLayoutId, HomeLayoutConfig> = {
default: defaultHomeLayoutConfig,
boursecu: boursecuHomeLayoutConfig,
}
let cachedLayoutRenderer: Component | null = null
function getLayoutRenderer(): Component {
if (!cachedLayoutRenderer) {
cachedLayoutRenderer = defineAsyncComponent(() => import('./HomeLayoutRenderer.vue'))
}
return cachedLayoutRenderer
}
export function isHomeLayoutId(value: unknown): value is HomeLayoutId {
return typeof value === 'string' && value in HOME_LAYOUT_CONFIGS
}
export function getHomeLayoutId(): HomeLayoutId {
const adminLayout = getConfigApp('home_layout')
if (isHomeLayoutId(adminLayout)) {
return adminLayout
}
if (isBoursecuProject()) {
return 'boursecu'
}
return DEFAULT_HOME_LAYOUT_ID
}
export function getHomeLayoutConfig(layoutId = getHomeLayoutId()): HomeLayoutConfig {
return HOME_LAYOUT_CONFIGS[layoutId] ?? HOME_LAYOUT_CONFIGS[DEFAULT_HOME_LAYOUT_ID]
}
export function resolveHomeLayoutComponent(_layoutId = getHomeLayoutId()): Component {
return getLayoutRenderer()
}
export function registerHomeLayoutConfig(layoutId: HomeLayoutId, config: HomeLayoutConfig) {
HOME_LAYOUT_CONFIGS[layoutId] = config
}
+42
View File
@@ -0,0 +1,42 @@
/** چیدمان‌های صفحه اصلی — هر پروژه/ادمین می‌تواند یکی را انتخاب کند */
export type HomeLayoutId = 'default' | 'boursecu'
export type HomeSectionId =
| 'limitedPriceAlert'
| 'garoosiAssets'
| 'mainActions'
| 'boursecuQuickActions'
| 'referral'
| 'assets'
| 'marketClosed'
| 'currentPrice'
| 'banners'
| 'priceChart'
| 'priceStats'
export interface HomeQuickActionItem {
id: string
label: string
icon: string
iconSrc?: string
route: { name: string; query?: Record<string, string> }
}
export interface HomeLayoutSectionConfig {
id: HomeSectionId
visible?: boolean
}
export interface HomeLayoutConfig {
id: HomeLayoutId
wrapperClass?: string
outerSections?: HomeLayoutSectionConfig[]
sections: HomeLayoutSectionConfig[]
}
export interface HomeActionItem {
name: string
icon: string
label: string
handler: () => void
}
@@ -0,0 +1,17 @@
<template>
<!-- v-show: breakpoint switches must not destroy layouts or re-run child mount hooks -->
<MobileHomeView v-show="!isDesktop" @logout="$emit('logout')" />
<HomeDesktopView v-show="isDesktop" @logout="$emit('logout')" />
</template>
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import MobileHomeView from '../mobile/MobileHomeView.vue'
import HomeDesktopView from '../desktop/HomeDesktopView.vue'
defineEmits<{
logout: []
}>()
const isDesktop = useDesktopBreakpoint()
</script>
+2 -346
View File
@@ -1,351 +1,7 @@
<template>
<div >
<Transition>
<div v-if="popVisible" class="top-0 bottom-0 bg-black wrap-fix z-2000 opacity-60"
@click="popVisible = false">
</div>
</Transition>
<Transition name="slide-fade">
<div v-if="popVisible" class="wrap-fix bottom-11vh z-2000">
<div class="relative flex flex-col items-center ">
<div class="flex items-center justify-center bg-white rounded-full w-[30px] h-[30px] mb-2"
@click="popVisible = !popVisible">
<BasicIcon
icon="vuesax-linear:close-circle"
/>
</div>
<div class="flex flex-col items-center gap-2">
<BasicButton icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'user'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:user" size="16" class="absolute right-2" />
<div>حساب کاربری</div>
</div>
</BasicButton>
<!-- <BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"-->
<!-- @click="$router.push({name: 'notifyTrigger'}); popVisible = false">-->
<!-- <div class="flex gap-2 items-center">-->
<!-- <BasicIcon icon="vuesax-linear:bill" size="16" class="absolute right-2" />-->
<!-- <div>هشدار قیمت</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<!-- <BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"-->
<!-- @click="$router.push({name: 'transfer'}); popVisible = false">-->
<!-- <div class="flex gap-2 items-center">-->
<!-- <BasicIcon icon="vuesax-linear:card-send" size="16" class="absolute right-2" />-->
<!-- <div>انتقال کشکول گلد</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'physicalReceive'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:direct-inbox" size="16" class="absolute right-2" />
<div>دریافت طلای فیزیکی</div>
</div>
</BasicButton>
<BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'buyProduct'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:coin" size="16" class="absolute right-2" />
<div>خرید محصول</div>
</div>
</BasicButton>
<BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"
@click="openReceivesList(); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:receipt-item" size="16" class="absolute right-2" />
<div>لیست دریافتی ها</div>
</div>
</BasicButton>
<BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'giftCard'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:gift" size="16" class="absolute right-2" />
<div>ثبت کارت هدیه</div>
</div>
</BasicButton>
<BasicButton icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'about'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:info-circle" size="16" class="absolute right-2" />
<div>درباره ما</div>
</div>
</BasicButton>
<BasicButton v-if="isToken()" icon-size="16" class="!font-bold w-15rem"
@click="$router.push({name: 'rules'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:task" size="16" class="absolute right-2" />
<div>قوانین و مقررات</div>
</div>
</BasicButton>
<!-- <BasicButton icon-size="16" class="!font-bold w-50vw md:w-full"
@click="$router.push({name: 'permissions'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:archive-book" size="16" class="absolute right-2" />
<div>مجوز ها</div>
</div>
</BasicButton>-->
<!-- <BasicButton icon-size="16" class="!font-bold w-50vw md:w-full"
@click="$router.push({name: 'earnMoney'}); popVisible = false">
<div class="flex gap-2 items-center">
<BasicIcon icon="vuesax-linear:dollar-circle" size="16" class="absolute right-2" />
<div>کسب درآمد</div>
</div>
</BasicButton>-->
</div>
</div>
</div>
</Transition>
<div class=" !h-[calc(100vh-72px)] ">
<router-view v-slot="{ Component, route }">
<!-- Use any custom transition and to `fade` -->
<transition :name="route.meta.transition || 'fade'">
<component :is="Component" class="!h-[calc(100vh-72px)]" />
</transition>
</router-view>
</div>
<!-- <canvas ref="canvas" id="canvas1" width="400" height="80" class="w-full"></canvas>-->
<div v-if="isToken()" class="bottom-appbar w-auto mx-auto">
<div class="tabs">
<div :class="setNavColor('home')" class="tab" @click="setRoute('home')">
<Icon icon="vuesax-linear:home" />
<div class="text-xs mt-1 font-bold">صفحه اصلی</div>
</div>
<!-- <div :class="setNavColor('buyProduct')" class="tab" @click="setRoute('buyProduct')">-->
<!-- <Icon icon="vuesax-linear:coin" />-->
<!-- <div class="text-xs mt-1 font-bold">فروشگاه</div>-->
<!-- </div>-->
<div :class="setNavColor('transactions')" class="tab" @click="setRoute('transactions')">
<Icon icon="vuesax-linear:receipt-item" />
<div class="text-xs mt-1 font-bold">گزارش</div>
</div>
<div class="main_in">
<div class="text-white circle_in">
<div class="circle_into " @click="popVisible = !popVisible">
<img
class="p-2"
src="@/assets/img/logo.png"
/>
</div>
</div>
</div>
<!-- <div :class="setNavColor('buyProduct')" class="tab" @click="setRoute('buyProduct')">
<Icon icon="vuesax-linear:coin" />
<div class="text-xs mt-1 font-bold">خرید محصول</div>
</div>-->
<div :class="setNavColor('wallet')" class="tab" @click="setRoute('wallet')">
<Icon icon="vuesax-linear:wallet-1" />
<div class="text-xs mt-1 font-bold">کیف پول</div>
</div>
<!-- <div :class="setNavColor('physicalReceive')" class="tab" @click="setRoute('physicalReceive')">-->
<!-- <Icon icon="vuesax-linear:direct-inbox" />-->
<!-- <div class="text-xs mt-1 font-bold">دریافت فیزیکی</div>-->
<!-- </div>-->
<div :class="setNavColor('cart')" class="tab" @click="setRoute('cart')">
<a-badge :count="cartStore.itemCount">
<Icon icon="vuesax-linear:shopping-cart" />
</a-badge>
<div class="text-xs mt-1 font-bold">سبد خرید</div>
</div>
</div>
<div class="main">
<div class="circle">
</div>
</div>
</div>
<div v-else class="bottom-appbar w-auto mx-auto">
<div class="tabs">
<div :class="setNavColor('buyProduct')" class="tab" @click="setRoute('buyProduct')">
<Icon icon="vuesax-linear:coin" />
<div class="text-xs mt-1 font-bold">فروشگاه</div>
</div>
<div :class="setNavColor('about')" class="tab" @click="setRoute('about')">
<Icon icon="vuesax-linear:info-circle" />
<div class="text-xs mt-1 font-bold">درباره ما</div>
</div>
<div class="main_in">
<div class="text-white circle_in">
<div class="circle_into " @click="popVisible = !popVisible">
<img
class="p-2"
src="@/assets/img/logo.png"
/>
</div>
</div>
</div>
<!-- <div :class="setNavColor('buyProduct')" class="tab" @click="setRoute('buyProduct')">
<Icon icon="vuesax-linear:coin" />
<div class="text-xs mt-1 font-bold">خرید محصول</div>
</div>-->
<div :class="setNavColor('cart')" class="tab" @click="setRoute('cart')">
<a-badge :count="cartStore.itemCount">
<Icon icon="vuesax-linear:shopping-cart" />
</a-badge>
<div class="text-xs mt-1 font-bold">سبد خرید</div>
</div>
<div :class="setNavColor('login')" class="tab" @click="setRoute('login')">
<Icon icon="vuesax-linear:login" />
<div class="text-xs mt-1 font-bold">ورود</div>
</div>
</div>
<div class="main">
<div class="circle">
</div>
</div>
</div>
</div>
<AppLayoutShell />
</template>
<script lang="ts" setup>
import {computed, onMounted, ref} from 'vue'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import type { Transaction } from '@/models/transaction'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import {isToken} from "@/utils";
import { useCartStore } from '@/store/cartStore'
const cartStore = useCartStore()
const popVisible = ref(false)
const currentRoute = computed(() => router.currentRoute.value.name)
function setNavColor(val: string) {
if (currentRoute.value === val) {
return 'text-primary'
} else {
return 'text-gray-400'
}
}
function setRoute(val: string) {
popVisible.value = false
router.push({ name: val })
}
function openReceivesList() {
router.push({ name: 'list' })
}
onMounted(()=>{
console.log(isToken())
})
import AppLayoutShell from '@/layouts/AppLayoutShell.vue'
</script>
<style lang="less">
.bottom-appbar {
z-index: 5000;
height: 80px;
position: fixed;
display: flex;
justify-content: space-evenly;
bottom: 0;
right: 0;
left:0;
@media screen and (min-width: 426px) {
width: 426px;
margin: 0 auto;
}
.tabs {
display: flex;
height: 90%;
bottom: 0;
width: 100%;
background: white;
//background-color: red;
justify-content: space-between;
position: absolute;
z-index: 400;
//border: 1px dashed primary;
box-shadow: 0px -2px 4px -2px #0000001A;
.tab {
width: 80px;
text-align: center;
flex-flow: column;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
}
.main_in {
height: 70px;
width: 70px;
.circle_in {
margin-top: -10px;
border-radius: 100%;
height: 100%;
width: 100%;
z-index: 20;
display: flex;
//border: 1px dashed primary;
justify-content: center;
align-content: center;
flex-wrap: wrap;
background-color: #a72b77;
.circle_into {
overflow:hidden;
@apply bg-primary;
border-radius: 100%;
width: 90%;
display: flex;
justify-content: center;
align-content: center;
flex-wrap: wrap;
height: 90%;
border: white 1px solid;
}
}
}
}
.main {
height: 70px;
width: 70px;
position: relative;
.circle {
border-radius: 100%;
height: 100%;
width: 100%;
z-index: 20;
box-shadow: 0px -5px 4px -2px #0000001A;
position: absolute;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.4s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>
@@ -1,44 +1,59 @@
<template>
<div>
<div class="absolute inset-0 bg-cover bg-center opacity-60" :style="{ backgroundImage: `url(${img})` }"></div>
<div
class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10"
>
<div>لیست دریافت فیزیکی</div>
</div>
<div class="flex flex-col gap-4 pt-13 pb-35 px-2">
<RequestItem v-for="item in requestItems" :item="item"></RequestItem>
</div>
<Transition mode="out-in" name="fade">
<div
class="wrap-fix bottom-0 h-[19vh] bg-[#FBFBFC] shadow-lg rounded-3xl flex justify-center pt-3 z-200"
style="box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 26px 11px"
@click="openAdd()">
<BasicIcon icon="vuesax-linear:add" />
<div class="cursor-pointer">افزودن درخواست جدید</div>
<div class="physical-receive-page-root">
<DesktopPhysicalReceiveListView
v-show="isDesktop"
:request-items="requestItems"
@add="openAdd"
/>
<div v-show="!isDesktop">
<div class="absolute inset-0 bg-cover bg-center opacity-60" :style="{ backgroundImage: `url(${img})` }" />
<div class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10">
<div>لیست دریافت فیزیکی</div>
</div>
</Transition>
<div class="flex flex-col gap-4 pt-13 pb-35 px-2">
<RequestItem v-for="item in requestItems" :key="item.id" :item="item" />
</div>
<Transition mode="out-in" name="fade">
<div
class="wrap-fix bottom-0 h-[19vh] bg-[#FBFBFC] shadow-lg rounded-3xl flex justify-center pt-3 z-200"
style="box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 26px 11px"
@click="openAdd"
>
<BasicIcon icon="vuesax-linear:add" />
<div class="cursor-pointer">افزودن درخواست جدید</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {getPhysicalReceiveList} from "@/api/physicalreceive";
import type {Physicalreceive} from "@/models/physicalreceive";
import RequestItem from "@/views/base/physicalReceive/components/RequestItem.vue";
import router from "@/router";
import { onMounted, ref } from 'vue'
import { getPhysicalReceiveList } from '@/api/physicalreceive'
import type { Physicalreceive } from '@/models/physicalreceive'
import RequestItem from '@/views/base/physicalReceive/components/RequestItem.vue'
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import DesktopPhysicalReceiveListView from '../desktop/DesktopPhysicalReceiveListView.vue'
const isDesktop = useDesktopBreakpoint()
const requestItems = ref<Physicalreceive[]>([])
const openAdd = () => {
router.push('physicalReceive/new')
router.push({ name: 'new' })
}
onMounted(async () => {
let {rows} = await getPhysicalReceiveList()
const { rows } = await getPhysicalReceiveList()
requestItems.value = rows
console.log('rows',requestItems.value)
})
</script>
<style scoped>
</style>
.physical-receive-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,34 @@
<template>
<div class="dinner physical-receive-page">
<div class="dinner__container">
<DesktopPageTopbar title="لیست دریافت فیزیکی" show-back />
<div class="dphysical-list">
<RequestItem v-for="item in requestItems" :key="item.id" :item="item" />
<div v-if="requestItems.length === 0" class="dinner-surface p-8 text-center text-gray-500">
هنوز درخواستی ثبت نشده است.
</div>
</div>
<button type="button" class="dphysical-add" @click="$emit('add')">
<BasicIcon icon="vuesax-linear:add" />
افزودن درخواست جدید
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Physicalreceive } from '@/models/physicalreceive'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import RequestItem from '../components/RequestItem.vue'
defineProps<{
requestItems: Physicalreceive[]
}>()
defineEmits<{
add: []
}>()
</script>
@@ -262,11 +262,12 @@ import type { Transaction } from '@/models/transaction'
import type { TableEngineInput, TableOutput } from '@/models/table'
import { getTransactionTable } from '@/api/transaction'
import { getLastPrices, lastPrice } from '@/utils/price'
import { getProductBaseBuyPrice, getProductBaseSellPrice } from '@/utils/productPrice'
import { Product } from '@/models/products'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import { SelectItem } from '@/models/SelectItem'
import Bg_main from '@/components/bg_main.vue'
import { useFundUserStore } from '@/store/fundStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { useCartStore } from '@/store/cartStore'
const typeDeal: SelectItem[] = [
@@ -295,7 +296,7 @@ const sellItems = ref<Transaction[]>([])
const priceErrorVisible = ref<boolean>(false)
const buyTableOutput = ref<TableOutput<Transaction> | null>(null)
const sellTableOutput = ref<TableOutput<Transaction> | null>(null)
const fundStore = useFundUserStore()
const { loadWalletAssets } = useWalletAssets()
const refInput = ref<FormInstance>()
const el = ref()
@@ -653,18 +654,9 @@ const price = computed((): Price => <Price>lastPrice.value ?? 0)
async function initPrice() {
try {
// chartLoading.value = true
await getLastPrices()
// await setIntervalGetPrice()
loading.value = true
price.value = lastPrice.value
props.ProductItem.price = lastPrice.value?.buy * props.ProductItem?.amount
props.ProductItem.sellPrice = lastPrice.value?.sell * props.ProductItem?.amount
} catch (e) {
console.log(e)
} finally {
loading.value = false
// chartLoading.value = false
}
}
@@ -715,7 +707,9 @@ async function ok() {
infoVisible.value = true
const data = await createOrder({
direction: isBuy.value ? 1 : 2,
price: isBuy.value ? price.value?.buy : price.value?.sell,
price: isBuy.value
? getProductBaseBuyPrice(props.ProductItem)
: getProductBaseSellPrice(props.ProductItem),
amount: form.value.goldValue ?? 0,
gateway: form.value.gateway,
product_id: props.ProductItem.id,
@@ -753,7 +747,7 @@ async function ok() {
}
// refreshTable()
message.success('سفارش شما با موفقیت انجام شد')
await fundStore.getMyFund()
await loadWalletAssets(true)
} catch (e) {
// console.log(e)
// await initPrice()
@@ -94,7 +94,7 @@
</div>
<div class="mt-2 !text-primary text-sm font-bold">
{{ usePrice((props.ProductItem.price ?? 0) + (props.ProductItem.cash_fee_buy ?? 0)) }}
{{ usePrice(getProductBuyPrice(props.ProductItem)) }}
</div>
</div>
</div>
@@ -104,6 +104,7 @@
import { computed } from 'vue'
import type { Product } from '@/models/products'
import { usePrice } from '@/utils/usePrice'
import { getProductBuyPrice } from '@/utils/productPrice'
import ProductImage from '@/components/ProductImage.vue'
interface Props {
@@ -27,7 +27,7 @@
</div>
<div class="mt-auto !text-primary text-sm font-bold">
{{ usePrice((props.ProductItem.price ?? 0) + (props.ProductItem.cash_fee_buy ?? 0)) }}
{{ usePrice(getProductBuyPrice(props.ProductItem)) }}
</div>
</div>
</template>
@@ -36,6 +36,7 @@
import { computed } from 'vue'
import type { Product } from '@/models/products'
import { usePrice } from '@/utils/usePrice'
import { getProductBuyPrice } from '@/utils/productPrice'
import ProductImage from '@/components/ProductImage.vue'
interface Props {
@@ -0,0 +1,193 @@
import { ref, watch } from 'vue'
import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProductsCatalog'
import type { Product } from '@/models/products'
import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import {
priceSocketConnected,
priceStreamLifecycle,
priceWssEnabled,
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { subscribeProductListStream } from '@/utils/productStreamBridge'
let initPromise: Promise<void> | null = null
let productListInterval: ReturnType<typeof setInterval> | undefined
let providerMountCount = 0
let pricePollingMountCount = 0
let unsubscribeProductStream: (() => void) | undefined
const initialized = ref(false)
export const productsActiveKey = ref('همه')
export const productsTypeItems = ref<string[]>([])
export const productsFirstItems = ref<Product[]>()
export const productsItems = ref<Product[]>()
export const productsPageLoading = ref(true)
function sortProductsByInventory(products: Product[]) {
return [...products].sort((a, b) => {
const aInStock = Number(a.inventory ?? 0) > 0 ? 0 : 1
const bInStock = Number(b.inventory ?? 0) > 0 ? 0 : 1
if (aInStock !== bInStock) return aInStock - bInStock
return Number(b.inventory ?? 0) - Number(a.inventory ?? 0)
})
}
export function changeProductsTab() {
if (productsActiveKey.value !== 'همه') {
productsItems.value = productsFirstItems.value?.filter(
(val) => val.group_type === productsActiveKey.value,
)
} else {
productsItems.value = productsFirstItems.value
}
}
async function loadCategoryTabs() {
try {
productsTypeItems.value = await loadProductCategoryTabNames()
} catch (error) {
console.error('Product category tabs error:', error)
productsTypeItems.value = []
}
}
function applyProductsList(products: Product[]) {
const sorted = sortProductsByInventory(products)
productsFirstItems.value = sorted
changeProductsTab()
productsPageLoading.value = false
}
export async function refreshProductsPageData() {
try {
const fresh = await getProductsCatalog(true)
if (!fresh?.length) return
applyProductsList(fresh)
} catch (error) {
console.error('Products refresh error:', error)
}
}
export function applyProductsListFromSocket(products: Product[]) {
if (!products?.length) {
return
}
getProductsCatalogRef().value = products
applyProductsList(products)
}
function startProductListPolling() {
if (productListInterval || !shouldUsePricePolling()) {
return
}
productListInterval = setInterval(() => {
if (shouldUsePricePolling()) {
void refreshProductsPageData()
}
}, 10000)
}
function stopProductListPolling() {
if (productListInterval) {
clearInterval(productListInterval)
productListInterval = undefined
}
}
function syncProductListPolling() {
if (shouldUsePricePolling()) {
if (initialized.value) {
startProductListPolling()
}
return
}
stopProductListPolling()
}
watch(
() => [priceWssEnabled.value, priceSocketConnected.value, priceStreamLifecycle.value] as const,
() => {
syncProductListPolling()
if (shouldUsePricePolling() && initialized.value && !productsFirstItems.value?.length) {
void refreshProductsPageData()
}
},
)
export function onProductsPageProviderMounted() {
providerMountCount += 1
pricePollingMountCount += 1
unsubscribeProductStream = subscribeProductListStream(applyProductsListFromSocket)
if (pricePollingMountCount === 1) {
if (shouldUsePricePolling()) {
void getLastPrices()
}
startLastPricePolling()
}
}
export function onProductsPageProviderUnmounted() {
unsubscribeProductStream?.()
unsubscribeProductStream = undefined
providerMountCount = Math.max(0, providerMountCount - 1)
pricePollingMountCount = Math.max(0, pricePollingMountCount - 1)
if (pricePollingMountCount === 0) {
stopLastPricePolling()
}
if (providerMountCount === 0) {
stopProductListPolling()
}
}
export async function initProductsPageData(force = false) {
if (!force && initialized.value) {
syncProductListPolling()
if (productsFirstItems.value?.length) {
productsPageLoading.value = false
}
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
if (!productsFirstItems.value?.length) {
productsPageLoading.value = true
}
await loadCategoryTabs()
// stream-first: polling فقط وقتی SSE قطع/غیرفعال است
if (shouldUsePricePolling()) {
await refreshProductsPageData()
}
initialized.value = true
syncProductListPolling()
} catch (error) {
console.error('Products page initialization error:', error)
} finally {
// اگر استریم قبل از init لیست را داده، loading را دوباره true نکرده‌ایم؛
// اگر API/polling تمام شد یا محصول داریم، اسپینر را قطع کن.
if (productsFirstItems.value?.length || shouldUsePricePolling()) {
productsPageLoading.value = false
}
initPromise = null
}
})()
return initPromise
}
+41 -169
View File
@@ -1,15 +1,16 @@
<template>
<div class="bottom-0 fixed top-0 -z-10 pointer-events-none">
<img :src="img" alt="" class=" w-inherit "
:style="{ backgroundRepeat: 'repeat', }"
/>
</div>
<a-spin :spinning="loading">
<div :class="isDesktop ? 'dpage products-page' : ''">
<div class="bottom-0 fixed top-0 -z-10 pointer-events-none">
<img :src="img" alt="" class=" w-inherit "
:style="{ backgroundRepeat: 'repeat', }"
/>
</div>
<a-spin :spinning="productsPageLoading">
<div class="relative z-0 !h-full overflow-y-scroll">
<div class="w-full flex justify-center top-0 py-5" style="align-self: center">
<ARadioGroup @change="changeTab" v-model:value="activekey" button-style="solid">
<ARadioGroup @change="changeProductsTab" v-model:value="productsActiveKey" button-style="solid">
<ARadioButton class="cats" value="همه">همه</ARadioButton>
<ARadioButton class="cats" v-for="item in typeItems" :value="item">{{ item }}</ARadioButton>
<ARadioButton class="cats" v-for="item in productsTypeItems" :key="item" :value="item">{{ item }}</ARadioButton>
</ARadioGroup>
</div>
<div class="mx-5 flex justify-end" @click="viewVertical=!viewVertical">
@@ -20,7 +21,7 @@
<div class="mx-5 pb-30 pt-2 grid grid-cols-2 gap-3 items-stretch">
<template v-if="viewVertical">
<ProductItemVertical
v-for="item in items"
v-for="item in productsItems"
:key="item.id"
class="h-full"
:ProductItem="item"
@@ -29,7 +30,7 @@
</template>
<template v-else>
<ProductItem
v-for="item in items"
v-for="item in productsItems"
:key="item.id"
class="col-span-2 h-full"
:ProductItem="item"
@@ -40,9 +41,6 @@
</div>
</a-spin>
<!-- <BasicActionSheet v-model:show="showFilter">-->
<!-- <indexGold ></indexGold>-->
<!-- </BasicActionSheet>-->
<BasicActionSheet placement="bottom" v-model:show="showFilter">
<div class="">
@@ -51,191 +49,65 @@
</div>
</BasicActionSheet>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import router from '@/router'
import ProductItem from '@/views/base/products/components/ProductItem.vue'
import { getProducts } from '@/api/products'
import {
getLastPrices,
lastPrice,
stopPriceInterval,
setIntervalGetLastPrice
} from '@/utils/price'
import { loading, getToken } from '@/utils'
import { Price } from '@/models/home'
import type { Product } from '@/models/products'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import indexGold from '@/views/base/gold/index.vue'
import DealProduct from '@/views/base/products/components/DealProduct.vue'
import { cloneDeep } from 'lodash'
import img from "@/assets/img/user_ly1.svg";
import { message } from 'ant-design-vue'
import BasicButton from "@/App.vue";
import BasicIcon from "@/components/BasicPacks/BasicIcon.vue";
import ProductItemVertical from "@/views/base/products/components/ProductItemVertical.vue";
import { loadProductCategoryTabNames } from '@/utils/categoryTabs'
import { subscribeProductListStream } from '@/utils/productStreamBridge'
import { isProductVisible } from '@/utils/productPrice'
import {
productsActiveKey,
productsTypeItems,
productsItems,
productsPageLoading,
changeProductsTab,
initProductsPageData,
onProductsPageProviderMounted,
onProductsPageProviderUnmounted,
} from '@/views/base/products/composables/useProductsPageData'
const activekey = ref('همه')
const showFilter = ref()
const selectedProduct = ref<Product>()
const viewVertical = ref(true)
const isDesktop = useDesktopBreakpoint()
function clickProduct(item: Product) {
if(item?.show){
// Navigate to product detail page
if (isProductVisible(item)) {
router.push({ name: 'productDetail', params: { id: item.id } })
}
}
function changeTab() {
if (activekey.value != 'همه') {
items.value = firstItems.value?.filter((val) =>
val.group_type == activekey.value
)
} else items.value = firstItems.value
}
function sortProductsByInventory(products: Product[] | undefined) {
if (!products?.length) return products ?? []
return [...products].sort((a, b) => {
const aInStock = Number(a.inventory ?? 0) > 0 ? 0 : 1
const bInStock = Number(b.inventory ?? 0) > 0 ? 0 : 1
if (aInStock !== bInStock) return aInStock - bInStock
return Number(b.inventory ?? 0) - Number(a.inventory ?? 0)
})
}
async function loadCategoryTabs() {
try {
typeItems.value = await loadProductCategoryTabNames()
} catch (e) {
console.log(e)
typeItems.value = []
}
}
const firstItems = ref<Product[]>()
const price = ref<Price>()
const items = ref<Product[]>()
const typeItems = ref([])
let unsubscribeProductStream: (() => void) | undefined
function applyStreamProducts(products: Product[]) {
const sorted = sortProductsByInventory(products)
firstItems.value = sorted
changeTab()
applyLivePricesToProducts(items.value)
applyLivePricesToProducts(firstItems.value)
}
onMounted(async () => {
unsubscribeProductStream = subscribeProductListStream(applyStreamProducts)
onProductsPageProviderMounted()
await initProductsPageData()
try {
loading.value = true
const products = sortProductsByInventory(await getProducts())
items.value = products
firstItems.value = products
await loadCategoryTabs()
await initPrice()
// await setIntervalGetLastPrice()
// console.log('run product')
} catch (e: any) {
console.log(e)
// If 401 error, set empty items to show page without products
if (e?.response?.status === 401) {
items.value = []
firstItems.value = []
typeItems.value = []
}
} finally {
loading.value = false
if (router.currentRoute.value.query.cat=="coin"){
activekey.value="سکه"
changeTab()
}
if (router.currentRoute.value.query.state) {
if (router.currentRoute.value.query.product) {
clickProduct(items.value?.find(x => x.title === 'سکه'))
} else {
clickProduct(items.value?.find(x => x.title === 'آبشده نقدی'))
}
}
}
})
onUnmounted(() => {
unsubscribeProductStream?.()
stopPriceInterval()
})
function applyLivePricesToProducts(products: Product[] | undefined) {
if (!lastPrice.value || !products?.length) {
return
if (router.currentRoute.value.query.cat === 'coin') {
productsActiveKey.value = 'سکه'
changeProductsTab()
}
products.forEach((val) => {
if (val.group_type == 'آبشده') {
val.price = parseInt(String(lastPrice.value?.price_last ?? 0))
val.sellPrice = parseInt(String(lastPrice.value?.sell ?? 0))
if (router.currentRoute.value.query.state) {
if (router.currentRoute.value.query.product) {
clickProduct(productsItems.value?.find((x) => x.title === 'سکه'))
} else {
val.price = parseInt(String(val.amount * lastPrice.value?.price_last))
val.sellPrice = parseInt(String(val.amount * lastPrice.value?.sell))
clickProduct(productsItems.value?.find((x) => x.title === 'آبشده نقدی'))
}
syncProductShow(val)
})
}
/** show از API سرور — بعد از محاسبه قیمت زنده فقط اگر غیرفعال یا قیمت صفر باشد خاموش می‌شود */
function syncProductShow(product: Product) {
if (!product.active) {
product.show = false
return
}
})
const displayPrice = Number(product.price ?? 0) + Number(product.cash_fee_buy ?? 0)
if (displayPrice > 0) {
product.show = true
return
}
product.show = Number(lastPrice.value?.price_last ?? 0) > 0
}
watch(lastPrice, () => {
applyLivePricesToProducts(items.value)
applyLivePricesToProducts(firstItems.value)
}, { deep: true })
async function initPrice() {
try {
// chartLoading.value = true
await getLastPrices()
await setIntervalGetLastPrice()
loading.value = true
price.value = lastPrice.value
applyLivePricesToProducts(items.value)
applyLivePricesToProducts(firstItems.value)
} catch (e: any) {
console.log(e)
// If 401 error, just skip price initialization
if (e?.response?.status === 401) {
// Price will not be available, but page can still be shown
price.value = undefined
}
} finally {
loading.value = false
// chartLoading.value = false
}
}
onUnmounted(() => {
onProductsPageProviderUnmounted()
})
</script>
<style lang="less">
@@ -247,4 +119,4 @@ async function initPrice() {
color: rgba(206, 163, 76, var(--un-text-opacity))!important;
}
</style>
</style>
+18 -258
View File
@@ -1,271 +1,31 @@
<template>
<div class="bottom-0 fixed top-0">
<img :src="img" alt="" class="w-inherit" :style="{ backgroundRepeat: 'repeat' }"/>
<div class="product-detail-page-root">
<DesktopProductDetailView v-show="isDesktop" />
<MobileProductDetailView v-show="!isDesktop" />
<BasicActionSheet placement="bottom" v-model:show="showFilter">
<indexGold v-if="product?.group_type == 'آبشده'" :ProductItem="product" />
<DealProduct v-else :ProductItem="product" @close="showFilter = false" />
</BasicActionSheet>
</div>
<a-spin :spinning="loading">
<div class="!h-full overflow-y-scroll">
<div class="mx-5 pb-30 pt-5" style="color: #1b2b07">
<!-- Product Image -->
<div class="bg-gray-100 rounded-lg w-full flex justify-center mb-4">
<ProductImage
:product="product"
:dimmed="isOutOfStock"
img-class="max-w-full h-auto"
/>
</div>
<!-- Product Title -->
<div class="text-2xl font-bold mb-4">{{ product?.title }}</div>
<div class=" flex items-center justify-between mb-4 gap-4">
<!-- Product Price -->
<div class="text-xl font-semibold text-primary">
{{ usePrice((product?.price ?? 0) + (product?.cash_fee_buy ?? 0)) }}
</div>
<div class="flex-1">
<!-- Buy Button -->
<BasicButton
v-if="product?.show && product?.open_buy &&(product?.inventory || product?.inventory >0)"
class="w-full py-3 "
type="primary"
size="large"
@click="handleBuyClick"
>
خرید محصول
</BasicButton>
<div v-else-if="!product?.show" class="text-center text-gray-500 py-4">
این محصول در حال حاضر در دسترس نیست
</div>
<div v-else-if="!product?.open_buy" class="text-center text-gray-500 py-4">
خرید این محصول در حال حاضر امکان پذیر نیست
</div>
<div v-else-if="!product?.inventory || product?.inventory==0" class="text-center text-gray-500 py-4">
ناموجود
</div>
</div>
</div>
<!-- Product Description -->
<div v-if="product?.description" class="mb-6">
<div class="text-lg font-semibold mb-2">توضیحات محصول:</div>
<div class="text-base text-gray-700 whitespace-pre-line">{{ product?.description }}</div>
<!-- <div class="text-base text-gray-700 whitespace-pre-line">شمش طلای نقش برجسته ونوس ۱ گرمی انتخابی ظریف و ارزشمند برای سرمایه‌گذاری و هدیه است. این شمش از طلای خالص با عیار بالا ساخته شده و نقش برجسته‌ی الهه ونوس، نمادی از زیبایی و ظرافت، جلوه‌ای هنری و خاص به آن بخشیده است. وزن دقیق ۱ گرم، ابعاد مناسب و بسته‌بندی شکیل، این محصول را به گزینه‌ای ایده‌آل برای نگهداری ارزش، هدیه‌های خاص و کلکسیون‌های طلا تبدیل می‌کند. </div>-->
</div>
<!-- Similar Products -->
<div v-if="product?.similar?.length" class="mt-8">
<div class="text-lg font-semibold mb-4">محصولات مشابه</div>
<swiper
:slides-per-view="2.25"
:space-between="16"
class="similar-products-swiper"
>
<swiper-slide
v-for="item in product.similar"
:key="item.id"
>
<div
class="bg-white rounded-lg shadow p-4 cursor-pointer h-full"
@click="goToProduct(item.id)"
>
<!-- Image -->
<div class="bg-gray-100 rounded-md flex justify-center p-4 mb-2">
<ProductImage
:product="item"
:dimmed="!item.inventory || item.inventory === 0"
img-class="h-20 object-contain"
/>
</div>
<!-- Title -->
<div class="text-sm font-semibold mb-1 line-clamp-2">
{{ item.title }}
</div>
<!-- Price -->
<div class="text-sm text-primary font-bold mb-3">
{{ usePrice((item.price ?? 0) + (item.cash_fee_buy ?? 0)) }}
</div>
<span
v-if="item.inventory || item.inventory==0"
class="text-xs bg-gray-300 text-gray-600 px-1 py-0.5 rounded-md "
>
ناموجود
</span>
</div>
</swiper-slide>
</swiper>
</div>
</div>
</div>
</a-spin>
<!-- Basic Action Sheet for Buy -->
<BasicActionSheet placement="bottom" v-model:show="showFilter">
<div class="">
<indexGold v-if="product?.group_type == 'آبشده'" :ProductItem="product"></indexGold>
<DealProduct v-else :ProductItem="product" @close="showFilter = false"></DealProduct>
</div>
</BasicActionSheet>
</template>
<script setup lang="ts">
import {onMounted, onUnmounted, ref, computed, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {getProductById, getProducts} from '@/api/products'
import {loading, getToken} from '@/utils'
import type {Product} from '@/models/products'
<script lang="ts" setup>
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import indexGold from '@/views/base/gold/index.vue'
import DealProduct from '@/views/base/products/components/DealProduct.vue'
import {cloneDeep} from 'lodash'
import img from '@/assets/img/user_ly1.svg'
import {message} from 'ant-design-vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import {usePrice} from '@/utils/usePrice'
import {
getLastPrices,
lastPrice,
stopPriceInterval
} from '@/utils/price'
import {Swiper, SwiperSlide} from 'swiper/vue'
import 'swiper/swiper-bundle.css'
import ProductImage from '@/components/ProductImage.vue'
const route = useRoute()
const router = useRouter()
const product = ref<Product>()
const showFilter = ref(false)
const productId = computed(() => {
const id = route.params.id
return typeof id === 'string' ? parseInt(id) : id
})
const isOutOfStock = computed(
() => !product.value?.inventory || product.value.inventory === 0
)
watch(
() => productId.value,
(newId, oldId) => {
if (newId && newId !== oldId) {
loadProduct()
window.scrollTo({top: 0, behavior: 'smooth'})
}
}
)
async function loadProduct() {
try {
loading.value = true
// const products = await getProducts()
const products = await getProductById(Number(productId.value))
const foundProduct = products
if (!foundProduct) {
message.error('محصول یافت نشد')
router.push({name: 'buyProduct'})
return
}
product.value = foundProduct
await initPrice()
} catch (e: any) {
console.log(e)
if (e?.response?.status === 401) {
message.warning('برای مشاهده محصولات، لطفا ابتدا وارد حساب کاربری خود شوید')
router.push({name: 'login'})
} else {
message.error('خطا در بارگذاری محصول')
}
} finally {
loading.value = false
}
}
watch(lastPrice, async () => {
// console.log(items);
if (lastPrice.value && product.value.similar) {
product.value.similar.forEach((val) => {
// console.log(lastPrice.value)
if (val.group_type == 'آبشده') {
val.price = lastPrice.value?.price_last
val.sellPrice = lastPrice.value?.sell
} else {
val.price = parseInt(val.amount * lastPrice.value?.price_last)
val.sellPrice = parseInt(val.amount * lastPrice.value?.sell)
// console.log(lastPrice.value?.price_last + "*" + val.amount + ' ' + val.price)
}
})
}
}, {deep: true})
async function initPrice() {
try {
await getLastPrices()
loading.value = true
if (lastPrice.value && product.value) {
if (product.value.group_type == 'آبشده') {
product.value.price = parseInt(lastPrice.value?.price_last)
product.value.sellPrice = parseInt(lastPrice.value?.sell)
} else {
product.value.price = parseInt(product.value.amount * lastPrice.value?.price_last)
product.value.sellPrice = parseInt(product.value.amount * lastPrice.value?.sell)
}
}
} catch (e: any) {
console.log(e)
} finally {
loading.value = false
}
}
function handleBuyClick() {
if (!product.value?.show) {
return
}
// // Check if user is logged in
// if (!getToken()) {
// message.warning('برای خرید یا فروش محصولات، لطفا ابتدا وارد حساب کاربری خود شوید')
// router.push({ name: 'login' })
// return
// }
//
showFilter.value = true
product.value = cloneDeep(product.value)
}
onMounted(() => {
loadProduct()
})
onUnmounted(() => {
stopPriceInterval()
})
function goToProduct(id: number) {
router.push({name: 'productDetail', params: {id}})
}
import { useProductDetailPage } from './composables/useProductDetailPage'
import DesktopProductDetailView from './desktop/DesktopProductDetailView.vue'
import MobileProductDetailView from './mobile/MobileProductDetailView.vue'
const isDesktop = useDesktopBreakpoint()
const { product, showFilter } = useProductDetailPage()
</script>
<style scoped>
.text-primary {
color: #d3a233;
}
.similar-products-swiper {
padding-bottom: 10px;
}
.similar-products-swiper :deep(.swiper-slide) {
height: auto;
.product-detail-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,104 @@
import { computed, inject, onMounted, onUnmounted, provide, ref, watch, type InjectionKey } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { cloneDeep } from 'lodash'
import { getProductById } from '@/api/products'
import { findProductInCatalog, getProductsCatalog } from '@/composables/useProductsCatalog'
import { getLastPrices, stopPriceInterval } from '@/utils/price'
import { getProductBuyPrice } from '@/utils/productPrice'
import { loading } from '@/utils'
import type { Product } from '@/models/products'
export interface ProductDetailPageContext {
product: ReturnType<typeof ref<Product | undefined>>
showFilter: ReturnType<typeof ref<boolean>>
isOutOfStock: ReturnType<typeof computed<boolean>>
handleBuyClick: () => void
goToProduct: (id: number) => void
getProductBuyPrice: typeof getProductBuyPrice
}
export const PRODUCT_DETAIL_PAGE_CONTEXT_KEY: InjectionKey<ProductDetailPageContext> =
Symbol('productDetailPageContext')
export function useProductDetailPage() {
const route = useRoute()
const router = useRouter()
const product = ref<Product>()
const showFilter = ref(false)
const productId = computed(() => {
const id = route.params.id
return typeof id === 'string' ? parseInt(id) : Number(id)
})
const isOutOfStock = computed(() => !product.value?.inventory || product.value.inventory === 0)
watch(productId, (newId, oldId) => {
if (newId && newId !== oldId) {
void loadProduct()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
})
async function loadProduct() {
try {
loading.value = true
await getProductsCatalog()
let foundProduct = findProductInCatalog(Number(productId.value))
if (!foundProduct) foundProduct = await getProductById(Number(productId.value))
if (!foundProduct) {
message.error('محصول یافت نشد')
await router.push({ name: 'buyProduct' })
return
}
product.value = foundProduct
await getLastPrices()
} catch (e: any) {
if (e?.response?.status === 401) {
message.warning('برای مشاهده محصولات، لطفا ابتدا وارد حساب کاربری خود شوید')
await router.push({ name: 'login' })
} else {
message.error('خطا در بارگذاری محصول')
}
} finally {
loading.value = false
}
}
function handleBuyClick() {
if (!product.value?.show) return
showFilter.value = true
product.value = cloneDeep(product.value)
}
function goToProduct(id: number) {
router.push({ name: 'productDetail', params: { id } })
}
onMounted(() => {
void loadProduct()
})
onUnmounted(() => {
stopPriceInterval()
})
const context: ProductDetailPageContext = {
product,
showFilter,
isOutOfStock,
handleBuyClick,
goToProduct,
getProductBuyPrice,
}
provide(PRODUCT_DETAIL_PAGE_CONTEXT_KEY, context)
return context
}
export function useProductDetailPageContext() {
const context = inject(PRODUCT_DETAIL_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useProductDetailPageContext must be used within useProductDetailPage')
return context
}
@@ -0,0 +1,113 @@
<template>
<div class="dinner product-detail-page">
<div class="dinner__container">
<DesktopPageTopbar :title="product?.title ?? 'جزئیات محصول'" show-back sticky />
<a-spin :spinning="loading">
<div v-if="product" class="dproduct-grid">
<div class="dinner-surface dproduct-gallery">
<ProductImage
:product="product"
:dimmed="isOutOfStock"
img-class="max-w-full max-h-[320px] object-contain"
/>
</div>
<div class="dproduct-info">
<div class="dinner-surface dproduct-info__panel">
<div class="dproduct-info__price-row">
<span class="dproduct-info__price">{{ usePrice(getProductBuyPrice(product)) }}</span>
<BasicButton
v-if="product.show && product.open_buy && product.inventory"
type="primary"
size="large"
@click="handleBuyClick"
>
خرید محصول
</BasicButton>
<div v-else-if="!product.show" class="text-gray-500">این محصول در حال حاضر در دسترس نیست</div>
<div v-else-if="!product.open_buy" class="text-gray-500">خرید این محصول در حال حاضر امکان پذیر نیست</div>
<div v-else class="text-gray-500">ناموجود</div>
</div>
</div>
<div v-if="product.description" class="dinner-surface dproduct-info__desc">
<h3 class="font-semibold mb-3">توضیحات محصول:</h3>
<div class="whitespace-pre-line">{{ product.description }}</div>
</div>
<div v-if="product.similar?.length" class="dinner-surface p-5">
<h3 class="font-semibold mb-4">محصولات مشابه</h3>
<div class="dproduct-similar-grid">
<article
v-for="item in product.similar"
:key="item.id"
class="dproduct-similar-card"
@click="goToProduct(item.id)"
>
<div class="dproduct-similar-card__image">
<ProductImage
:product="item"
:dimmed="!item.inventory"
img-class="h-20 object-contain"
/>
</div>
<div class="text-sm font-semibold line-clamp-2">{{ item.title }}</div>
<div class="text-sm text-primary font-bold">{{ usePrice(getProductBuyPrice(item)) }}</div>
</article>
</div>
</div>
</div>
</div>
</a-spin>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import ProductImage from '@/components/ProductImage.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import { loading } from '@/utils'
import { usePrice } from '@/utils/usePrice'
import { useProductDetailPageContext } from '../composables/useProductDetailPage'
const { product, isOutOfStock, handleBuyClick, goToProduct, getProductBuyPrice } = useProductDetailPageContext()
</script>
<style scoped>
.dproduct-info__panel {
padding: 20px;
}
.dproduct-similar-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.dproduct-similar-card {
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 12px;
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
&__image {
display: flex;
justify-content: center;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
margin-bottom: 8px;
}
}
.text-primary {
color: #d3a233;
}
</style>
@@ -0,0 +1,92 @@
<template>
<div>
<div class="bottom-0 fixed top-0">
<img :src="img" alt="" class="w-inherit" :style="{ backgroundRepeat: 'repeat' }" />
</div>
<a-spin :spinning="loading">
<div class="!h-full overflow-y-scroll">
<div class="mx-5 pb-30 pt-5" style="color: #1b2b07">
<div class="bg-gray-100 rounded-lg w-full flex justify-center mb-4">
<ProductImage :product="product" :dimmed="isOutOfStock" img-class="max-w-full h-auto" />
</div>
<div class="text-2xl font-bold mb-4">{{ product?.title }}</div>
<div class="flex items-center justify-between mb-4 gap-4">
<div class="text-xl font-semibold text-primary">
{{ usePrice(getProductBuyPrice(product)) }}
</div>
<div class="flex-1">
<BasicButton
v-if="product?.show && product?.open_buy && product?.inventory"
class="w-full py-3"
type="primary"
size="large"
@click="handleBuyClick"
>
خرید محصول
</BasicButton>
<div v-else-if="!product?.show" class="text-center text-gray-500 py-4">
این محصول در حال حاضر در دسترس نیست
</div>
<div v-else-if="!product?.open_buy" class="text-center text-gray-500 py-4">
خرید این محصول در حال حاضر امکان پذیر نیست
</div>
<div v-else class="text-center text-gray-500 py-4">ناموجود</div>
</div>
</div>
<div v-if="product?.description" class="mb-6">
<div class="text-lg font-semibold mb-2">توضیحات محصول:</div>
<div class="text-base text-gray-700 whitespace-pre-line">{{ product.description }}</div>
</div>
<div v-if="product?.similar?.length" class="mt-8">
<div class="text-lg font-semibold mb-4">محصولات مشابه</div>
<swiper :slides-per-view="2.25" :space-between="16" class="similar-products-swiper">
<swiper-slide v-for="item in product.similar" :key="item.id">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer h-full" @click="goToProduct(item.id)">
<div class="bg-gray-100 rounded-md flex justify-center p-4 mb-2">
<ProductImage
:product="item"
:dimmed="!item.inventory"
img-class="h-20 object-contain"
/>
</div>
<div class="text-sm font-semibold mb-1 line-clamp-2">{{ item.title }}</div>
<div class="text-sm text-primary font-bold mb-3">
{{ usePrice(getProductBuyPrice(item)) }}
</div>
</div>
</swiper-slide>
</swiper>
</div>
</div>
</div>
</a-spin>
</div>
</template>
<script lang="ts" setup>
import img from '@/assets/img/user_ly1.svg'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import ProductImage from '@/components/ProductImage.vue'
import { loading } from '@/utils'
import { usePrice } from '@/utils/usePrice'
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/swiper-bundle.css'
import { useProductDetailPageContext } from '../composables/useProductDetailPage'
const { product, isOutOfStock, handleBuyClick, goToProduct, getProductBuyPrice } = useProductDetailPageContext()
</script>
<style scoped>
.text-primary {
color: #d3a233;
}
.similar-products-swiper {
padding-bottom: 10px;
}
.similar-products-swiper :deep(.swiper-slide) {
height: auto;
}
</style>
@@ -0,0 +1,43 @@
<template>
<div
:class="variant === 'desktop' ? 'drules-content' : 'rules-panel mt-5 border-solid border-2 border-primary text-primary rounded-[12px] p-4'"
>
<div :class="variant === 'desktop' ? 'drules-content__title' : 'font-bold text-sm text-primary'">
{{ rulesTitle }}
</div>
<div :class="variant === 'desktop' ? 'drules-content__divider' : 'w-full h-1px my-3 bg-secondary'" />
<div
v-for="(block, blockIndex) in rulesBlocks"
:key="`block-${blockIndex}`"
:class="variant === 'desktop' ? 'drules-content__block' : 'rules-block'"
>
<p
v-for="(rule, index) in block.items"
:key="`rule-${blockIndex}-${index}`"
:class="variant === 'desktop' ? 'drules-content__paragraph' : 'rules-paragraph'"
>
{{ rule }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRulesPageContext } from '../composables/useRulesPage'
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
const { rulesTitle, rulesBlocks } = useRulesPageContext()
</script>
<style scoped>
.rules-panel {
font-size: 0.875rem;
line-height: 1.75rem;
}
.rules-paragraph {
margin: 0 0 1rem;
text-align: right;
line-height: 1.9;
}
</style>
@@ -0,0 +1,19 @@
<template>
<div :class="variant === 'desktop' ? 'drules-support' : 'wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center'">
<a class="w-full" :href="supportHref" target="_blank" rel="noopener noreferrer">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="supportHref" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/rubika.webp" alt="" :class="variant === 'desktop' ? 'drules-support__bale' : 'w-12'" />
</a>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useRulesPageContext } from '../composables/useRulesPage'
withDefaults(defineProps<{ variant?: 'mobile' | 'desktop' }>(), { variant: 'mobile' })
const { supportHref } = useRulesPageContext()
</script>
@@ -0,0 +1,36 @@
import { computed, inject, provide, type ComputedRef, type InjectionKey } from 'vue'
import { getConfig } from '@/config/app-config'
import { getRulesBlocks, getRulesTitle, type RulesBlock } from '../rulesContent'
export interface RulesPageContext {
nameProject: ComputedRef<string>
rulesTitle: ComputedRef<string>
rulesBlocks: ComputedRef<RulesBlock[]>
supportHref: ComputedRef<string>
}
export const RULES_PAGE_CONTEXT_KEY: InjectionKey<RulesPageContext> = Symbol('rulesPageContext')
const SUPPORT_LINK = 'https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c'
export function useRulesPage() {
const nameProject = computed(() => getConfig('name_fa', 'کشکول گلد'))
const rulesTitle = computed(() => getRulesTitle(nameProject.value, false))
const rulesBlocks = computed(() => getRulesBlocks(nameProject.value, false))
const supportHref = computed(() => SUPPORT_LINK)
const context: RulesPageContext = {
nameProject,
rulesTitle,
rulesBlocks,
supportHref,
}
provide(RULES_PAGE_CONTEXT_KEY, context)
return context
}
export function useRulesPageContext() {
const context = inject(RULES_PAGE_CONTEXT_KEY)
if (!context) throw new Error('useRulesPageContext must be used within useRulesPage')
return context
}
@@ -0,0 +1,40 @@
<template>
<div class="drules">
<div class="drules__container">
<DesktopPageTopbar eyebrow="حقوقی" title="قوانین و مقررات" show-back />
<div class="drules-layout">
<section class="drules-surface drules-main">
<div class="drules-main__intro">
<div class="drules-main__logo">
<BasicIcon icon="vuesax-linear:task" size="28" />
</div>
<div>
<h2 class="drules-main__project">{{ nameProject }}</h2>
<p class="drules-main__tagline">شرایط استفاده از خدمات و تعهدات کاربران</p>
</div>
</div>
<RulesContentPanel variant="desktop" />
</section>
<aside class="drules-surface drules-side">
<h3 class="drules-side__title">پشتیبانی و ارتباط</h3>
<p class="drules-side__text">در صورت نیاز به توضیح بیشتر درباره قوانین، با پشتیبانی تماس بگیرید.</p>
<RulesSupportActions variant="desktop" />
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import RulesContentPanel from '../components/RulesContentPanel.vue'
import RulesSupportActions from '../components/RulesSupportActions.vue'
import { useRulesPageContext } from '../composables/useRulesPage'
const { nameProject } = useRulesPageContext()
</script>
<style lang="less" src="./styles/desktop-rules.less"></style>
@@ -0,0 +1,156 @@
/* Premium desktop rules page */
.drules {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.drules__container {
max-width: 1080px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.drules-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.drules-surface {
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.drules-main {
padding: 28px;
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(167, 43, 119, 0.08);
color: @App-primary-color;
flex-shrink: 0;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
}
.drules-content {
padding: 20px;
border: 1px solid rgba(167, 43, 119, 0.15);
border-radius: 12px;
background: rgba(167, 43, 119, 0.03);
color: @App-primary-color;
&__title {
font-size: 16px;
font-weight: 700;
line-height: 1.6;
margin: 0;
}
&__divider {
width: 100%;
height: 1px;
background: rgba(167, 43, 119, 0.12);
margin: 16px 0;
}
&__block + &__block {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid rgba(167, 43, 119, 0.08);
}
&__paragraph {
margin: 0 0 14px;
font-size: 14px;
line-height: 1.9;
text-align: right;
color: inherit;
word-break: normal;
overflow-wrap: break-word;
&:last-child {
margin-bottom: 0;
}
}
}
.drules-side {
padding: 24px;
position: sticky;
top: 88px;
&__title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 8px;
}
&__text {
font-size: 13px;
line-height: 1.7;
color: #6b7280;
margin: 0 0 20px;
}
}
.drules-support {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
> a:first-child {
flex: 1;
min-width: 0;
}
&__bale {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
@media (max-width: 1023.98px) {
.drules-layout {
grid-template-columns: 1fr;
}
.drules-side {
position: static;
}
}
+12 -274
View File
@@ -1,285 +1,23 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-primary font-bold drop-shadow flex">
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">قوانین و مقررات</div>
</div>
<div class="pt-20">
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<!-- <div class="h-20vh flex justify-center">
<img :src="logoFull" class="mb-20 h-15vh mt-5" />
</div>-->
<div class="overflow-y-auto h-60vh pb-20 border-primary">
<div
class="mt-5 text-5 border-solid border-2 border-primary text-primary rounded-[12px] p-4"
>
<div class="font-bold text-sm text-primary">
قوانین و مقررات معاملات طلا در بستر کشکول گلد
</div>
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="mt-5 text-xs leading-5">
• کاربران موظفند قبل از ثبت‌ نام، تمامی مفاد این تعهدنامه را مطالعه نموده و صرفاً در
صورت پذیرش آن اقدام به ثبت‌نام نمایند.
</div>
<div class="mt-5 text-xs leading-5">
• کاربران کشکول گلد می‌پذیرند که کلیه‌ی فعالیت‌های آن‌ها در کشکول گلد، در چارچوب قوانین
جمهوری اسلامی ایران بوده و هیچ گونه فعالیتی خارج از این چارچوب انجام نخواهد شد.
</div>
<div class="mt-5 text-xs leading-5">
• احراز هویت برای استفاده از خدمات کشکول گلد ضروری بوده و کاربران موظفند اطلاعات صحیح
خود را بارگذاری نمایند؛ بدیهی است در صورت وجود هرگونه تخلف در احراز هویت، مسئولیت به
عهده‌ی فرد متخلف بوده و کشکول گلد حق توقف ارائه خدمات به کاربر مذکور و ارجاع موارد
تخلف به مراجع ذی‌صلاح را خواهد داشت.
</div>
<div class="mt-5 text-xs leading-5">
• کشکول گلد خود را ملزم به حفظ اطلاعات شخصی کاربران در چارچوب قوانین قضایی کشور
می‌داند.
</div>
<div class="mt-5 text-xs leading-5">
• کاربران کشکول گلد تعهد می‌دهند که از قرار دادن حساب خود در اختیار دیگران خودداری
نمایند. از خدمات سایت، تنها برای حساب شخصی خود استفاده نموده و مسئولیت استفاده از
خدمات کشکول گلد برای فردی دیگر، به عهده کاربر خواهد بود. شماره حساب اعلام شده به سایت
باید متعلق به کاربر بوده و کاربران مجاز به دادن شماره حساب متعلق به اشخاص دیگر
نیستند.
</div>
<div class="mt-5 text-xs leading-5">
• کاربران می‌پذیرند در صورت تشخیص کارشناسان پشتیبانی، جهت حفظ امنیت دارایی حساب
کاربری‌شان با اطلاع قبلی نسبت به برقراری تماس تصویری با تیم پشتیبان همکاری نمایند.
</div>
<div class="mt-5 text-xs leading-5">
• هرگونه سهل‌انگاری کاربران در حفظ اطلاعات شخصی و امنیتی از جمله: رمز عبور کاربر،
سرقت گوشی کاربر، سرقت کارت ملی و مدارک شناسایی کاربر، سپردن مدارک شخصی و موبایل به
فرد ثالث و ... به عهده‌ی شخص کاربر بوده و کشکول گلد هیچ گونه مسئولیتی در این موارد
نخواهد داشت.
</div>
<div class="mt-5 text-xs leading-5">
• اکیداً توصیه می‌شود از رمز عبوری پیچیده، امن و همچنین کد شناسایی دو عاملی استفاده
شود.
</div>
<div class="mt-5 text-xs leading-5">
• کشکول گلد این اطمینان را می‌دهد که دارایی‌های کاربران را نزد خود به امانت و به
بهترین شکل و با بالاترین استانداردهای امنیتی ممکن حفظ نماید؛ در صورت بروز هرگونه
مشکل امنیتی، کشکول گلد متعهد به جبران خسارت خواهد بود.
</div>
<div class="mt-5 text-xs leading-5">
• به عنوان پلتفرم معاملات برخط فلزات گران‌بها، هیچ‌گونه مسئولیتی در قبال تصمیمات
اقتصادی فی مابین کاربران و سود و زیان حاصل از آن بر عهده کشکول گلد نمی‌باشد.
</div>
<div class="mt-5 text-xs leading-5">
• کشکول گلد حق اصلاح و تغییر قوانین و مقررات ذکر شده در این صفحه را چه به صورت کلی و
چه به صورت جزئی دارد و نسخه نهایی را در این صفحه در معرض دید قرار می‌دهد. استفاده از
خدمات کشکول گلد پس از اعمال تغییرات، به معنای پذیرش همه تغییرات می‌باشد.
</div>
<div class="mt-5 text-xs leading-5">
• از لحظه خرید کاربران تا زمان تحویل طلای خریداری شده، مسئولیت حفظ و نگهداری اصل طلا
بر عهده کشکول گلد می‌باشد.
</div>
<div class="mt-5 text-xs leading-5">
• کاربران کشکول گلد اذعان می‌دارند که از ریسک‌های اقتصادی مربوط به سرمایه‌گذاری در
حوزه طلا مطلع بوده و با علم بر این موضوع اقدام به تبادل و سرمایه‌گذاری می‌کنند.
</div>
<div class="mt-5 text-xs leading-5">
• کشکول گلد متعهد می‌شود در حد توان از حریم شخصی شما محافظت کند و در این راستا،
تکنولوژی مورد نیاز در سایت و پلتفرم را برای هر چه مطمئن‌تر و امن‌تر شدن استفاده شما،
توسعه دهد.
</div>
<div class="mt-5 text-xs leading-5">
• در صورت بروز نوسان شدید در قیمت طلا و هرگونه رویداد برهم‌زننده نظم حاکم بر بازار
طلا، کشکول گلد می‌تواند برای خرید و فروش طلا توسط کاربر سقف تعیین کند تا دو طرف متضرر
نشوند.
</div>
<div class="mt-5 text-xs leading-5">
• کشکول گلد مجاز است بدون اطلاع قبلی و به صلاح‌دید شخصی نسبت به توقف سفارش‌گیری جدید،
معاملات (از جمله سفارش‌های باز) را متوقف کند؛ بدیهی است که کلیه سفارش‌های تکمیل‌شده
قبل از توقف سفارش‌گیری، پردازش و تحویل داده خواهد شد.
</div>
<div class="mt-5 text-xs leading-5">
• در صورت بروز هرگونه اختلال در اینترنت و خدمات زیرساخت به دلایل حاکمیتی، هیچ‌گونه
مسئولیتی به عهده کشکول گلد نمی‌باشد.
</div>
<div class="mt-5 text-xs leading-5">
• درخواست برداشت ریال از حساب کاربری در سریع‌ترین زمان ممکن پس از ثبت، بررسی خواهد
شد. زمان واریز پول به حساب کاربران بر اساس محدودیت‌های انتقال وجه بین بانکی، متفاوت
خواهد بود. برای اطلاعات بیشتر، به قوانین انتقال وجه بین بانکی (پایا، ساتنا) مراجعه
فرمایید.
</div>
<div class="mt-5 text-xs leading-5">
• کاربر می‌پذیرد که به جز در موارد مورد تعهد، حق هیچ‌گونه داعیه، طلب و شکایت از
سایت، مدیران، کارمندان و افراد مرتبط با این سایت را نخواهد داشت.
</div>
<div class="mt-5 text-xs leading-5">
• اگر کاربر تحت هر عنوان اشتباهاً یا من غیر حق، وجوهی را به حساب کاربر منظور یا در
محاسبات خود هر نوع اشتباهی نماید، کشکول گلد هر زمان مجاز و مختار است راساً و مستقلاً و
بدون انجام هیچ‌گونه تشریفات اداری و قضایی و دریافت اجازه کتبی از متعهد (صاحب حساب)
در رفع اشتباه و برداشت از حساب‌های وی اقدام نماید و تشخیص نسبت به وقوع اشتباه یا
پرداخت بدون حق و لزوم برداشت از حساب معتبر خواهد بود و کاربر حق هرگونه اعتراض و
ادعایی را در خصوص نحوه عملکرد کشکول گلد از هر جهت از خود ساقط می‌نماید.
</div>
<div class="mt-5 text-xs leading-5">
• در صورت بروز هرگونه مشکل یا ابهام در هر یک از معاملات (مانند دستکاری قیمت، اختلال
در وب‌سرویس‌ها، انجام معامله خارج از دامنه نوسان و ...)، کشکول گلد حق دارد مستقلاً آن
معامله را ابطال کرده و دارایی‌های هر یک از طرفین را به حساب خودشان عودت دهد. بدیهی
است در صورتی که اشکال از سمت کشکول گلد باشد، شرکت موظف خواهد بود که جبران خسارت نماید
و در غیر این صورت، کاربر حق هرگونه اعتراض و ادعایی در خصوص نحوه عملکرد از هر جهت را
از خود ساقط می‌نماید.
</div>
<div class="mt-5 text-xs leading-5">
• در صورت فوت یا حجر کاربر، کشکول گلد پیرو قوانین جمهوری اسلامی ایران می‌باشد. کاربر
با علم و اطلاع از مقررات این بند نسبت به افتتاح حساب کاربری در کشکول گلد اقدام نموده و
کاربر، اشخاص ثالث، ورثه یا قائم‌مقام قانونی ایشان حق اعتراض و یا ادعایی در آینده در
این خصوص نخواهند داشت.
</div>
<div class="mt-5 text-xs leading-5">
• هزینه خدمات و کارمزد تراکنش‌های مالی انجام شده در مطابق با توضیحات ارائه شده در
صفحه راهنمای استفاده از کشکول گلد می‌باشد.
</div>
<div class="mt-5 text-xs leading-5">
• تمامی علائم تجاری، طرح‌های گرافیکی، رابط کاربری، رابط بصری، عکس، لوگو، کدهای
نرم‌افزار، ساختار، نحوه بیان، تنظیم مطالب و محتوای موجود در کشکول گلد و شبکه‌های متعلق
به کشکول گلد و به‌طور کلی، سایر حقوق و امتیازات مربوطه منحصراً در مالکیت کشکول گلد
می‌باشد و هیچ شخصی حق استفاده از آن را بدون رضایت کشکول گلد ندارد.
</div>
<div class="mt-5 text-xs leading-5">
• اگر هر یک از مفاد این موافقت‌نامه بر اساس هر قانون لازم‌الاجرا، نامعتبر یا غیرقابل
اجرا تشخیص داده شود، این امر بر اعتبار هیچ ماده دیگری تأثیری نخواهد داشت و بقیه
مقررات اجرا می‌شود.
</div>
</div>
</div>
</div>
<div class="wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white items-center">
<a class="w-full" href="https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<!-- <BasicButton type="primary" @click="$router.push({name: 'permissions'})">
<div>مجوز ها</div>
</BasicButton>-->
<a href="https://web.rubika.ir/#c=u0I8F1U0a939b0df5ba9f73618d33d7c">
<img src="@/assets/img/rubika.webp" alt="" class="w-12"/>
</a>
</div>
</div>
</div>
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
<div class="rules-page-root">
<DesktopRulesView v-show="isDesktop" />
<MobileRulesView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import img from '@/assets/img/user_ly1.svg'
// import gr_bottom from './assets/img/gr_bottom_white.svg'
import { loading } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { lengthValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User } from '@/models/login'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useRulesPage } from './composables/useRulesPage'
import DesktopRulesView from './desktop/DesktopRulesView.vue'
import MobileRulesView from './mobile/MobileRulesView.vue'
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
})
const formRef = ref<FormInstance>()
const userStore = useUserStore()
// const editPassVisible = ref<boolean>(false)
const editPassVisible = ref<boolean>(false)
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
function shareCode() {
// nextTick(() => {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({
title: 'کشکول گلد',
text: val
})
// message.info('کد رونوشت شد')
// })
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
}
}
onMounted(() => {
setForm()
})
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const isDesktop = useDesktopBreakpoint()
useRulesPage()
</script>
<style scoped>
/*.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
.rules-page-root {
min-height: 100%;
height: 100%;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
</style>
@@ -0,0 +1,21 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-primary font-bold drop-shadow flex">
<div class="cursor-pointer" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">قوانین و مقررات</div>
</div>
<div class="pt-20 pb-24 px-5">
<RulesContentPanel variant="mobile" />
<RulesSupportActions variant="mobile" />
</div>
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import RulesContentPanel from '../components/RulesContentPanel.vue'
import RulesSupportActions from '../components/RulesSupportActions.vue'
</script>
+49
View File
@@ -0,0 +1,49 @@
export interface RulesBlock {
title?: string
items: string[]
}
function resolveProjectName(nameProject?: string | null) {
return (nameProject ?? '').trim() || 'کشکول گلد'
}
function getDefaultRulesItems(nameProject: string): string[] {
const brand = resolveProjectName(nameProject)
return [
`کاربران موظفند قبل از ثبت‌نام، تمامی مفاد این تعهدنامه را مطالعه نموده و صرفاً در صورت پذیرش آن اقدام به ثبت‌نام نمایند.`,
`کاربران ${brand} می‌پذیرند که کلیه فعالیت‌های آن‌ها در ${brand}، در چارچوب قوانین جمهوری اسلامی ایران بوده و هیچ گونه فعالیتی خارج از این چارچوب انجام نخواهد شد.`,
`احراز هویت برای استفاده از خدمات ${brand} ضروری بوده و کاربران موظفند اطلاعات صحیح خود را بارگذاری نمایند. در صورت تخلف در احراز هویت، مسئولیت بر عهده فرد متخلف بوده و ${brand} حق توقف ارائه خدمات و ارجاع موارد تخلف به مراجع ذی‌صلاح را خواهد داشت.`,
`${brand} خود را ملزم به حفظ اطلاعات شخصی کاربران در چارچوب قوانین قضایی کشور می‌داند.`,
`کاربران ${brand} تعهد می‌دهند حساب خود را در اختیار دیگران قرار ندهند و تنها برای حساب شخصی خود از خدمات استفاده کنند. شماره حساب اعلام‌شده باید متعلق به خود کاربر باشد.`,
`کاربران می‌پذیرند در صورت تشخیص پشتیبانی، برای حفظ امنیت دارایی حساب، با اطلاع قبلی در تماس تصویری همکاری نمایند.`,
`سهل‌انگاری در حفظ اطلاعات و امنیت حساب از جمله رمز عبور، گوشی، کارت ملی و مدارک شناسایی، بر عهده کاربر است و ${brand} مسئولیتی در این موارد ندارد.`,
`استفاده از رمز عبور پیچیده و کد شناسایی دو عاملی توصیه می‌شود.`,
`${brand} دارایی کاربران را با بالاترین استانداردهای امنیتی حفظ می‌کند و در صورت بروز مشکل امنیتی متعهد به جبران خسارت خواهد بود.`,
`به عنوان پلتفرم معاملات برخط فلزات گران‌بها، ${brand} مسئولیتی در قبال تصمیمات اقتصادی کاربران و سود و زیان حاصل از آن ندارد.`,
`${brand} حق اصلاح قوانین این صفحه را دارد و استفاده از خدمات پس از اعمال تغییرات، به معنای پذیرش آن‌هاست.`,
`از لحظه خرید تا زمان تحویل طلای خریداری‌شده، مسئولیت حفظ و نگهداری آن بر عهده ${brand} است.`,
`کاربران ${brand} از ریسک‌های سرمایه‌گذاری در حوزه طلا مطلع بوده و با علم به این موضوع اقدام به معامله می‌کنند.`,
`${brand} متعهد است از حریم شخصی کاربران محافظت کند.`,
`در صورت نوسان شدید قیمت طلا، ${brand} می‌تواند برای خرید و فروش سقف تعیین کند.`,
`${brand} مجاز است بدون اطلاع قبلی سفارش‌گیری جدید را متوقف کند. سفارش‌های تکمیل‌شده قبل از توقف، پردازش می‌شوند.`,
`در صورت اختلال اینترنت یا زیرساخت به دلایل حاکمیتی، ${brand} مسئولیتی ندارد.`,
`درخواست برداشت ریال پس از ثبت بررسی می‌شود. زمان واریز مطابق محدودیت‌های انتقال وجه بین بانکی (پایا، ساتنا) خواهد بود.`,
`کاربر حق داعیه و شکایت از سایت، مدیران و کارمندان را جز در موارد تعهد ندارد.`,
`در صورت واریز اشتباه وجه، ${brand} مجاز است نسبت به رفع اشتباه و برداشت از حساب اقدام کند و کاربر حق اعتراض را از خود ساقط می‌داند.`,
`در صورت ابهام در معاملات، ${brand} حق ابطال معامله و عودت دارایی‌ها را دارد. در صورت اشکال از سمت ${brand}، جبران خسارت انجام می‌شود.`,
`در صورت فوت یا حجر کاربر، ${brand} پیرو قوانین جمهوری اسلامی ایران است.`,
`هزینه خدمات و کارمزد مطابق راهنمای استفاده از ${brand} محاسبه می‌شود.`,
`کلیه حقوق مادی و معنوی ${brand} محفوظ است و استفاده بدون رضایت مجاز نیست.`,
`ابطال هر بند، بر سایر بندها اثر ندارد.`,
]
}
export function getRulesTitle(nameProject: string, _isBoursecu = false) {
const brand = resolveProjectName(nameProject)
return `قوانین و مقررات معاملات طلا در بستر ${brand}`
}
export function getRulesBlocks(nameProject: string, _isBoursecu = false): RulesBlock[] {
return [{ items: getDefaultRulesItems(nameProject) }]
}
+96 -154
View File
@@ -1,10 +1,48 @@
<template>
<div @click="infoVisible = true">
<!-- {{ item}}-->
<button
v-if="variant === 'desktop'"
type="button"
class="dtxn-row"
@click="infoVisible = true"
>
<span
class="dtxn-row__icon"
:class="isUp ? 'dtxn-row__icon--up' : 'dtxn-row__icon--down'"
>
<Icon :icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`" size="18" />
</span>
<div class="dtxn-row__main">
<p class="dtxn-row__title">{{ item.title }}</p>
<div class="dtxn-row__meta">
<span>{{ toJalaliOutOfUTC(item.expire_at) }}</span>
<span :class="goldAmount >= 0 ? 'text-green-600' : 'text-red-600'">
{{ formatGoldAmount(goldAmount) }} {{ item.unit }}
</span>
<span v-if="isGold">
قیمت معامله: {{ usePriceWithoutType(tradePrice) }} ریال
</span>
</div>
</div>
<div
class="dtxn-row__amount"
:class="item.cash > 0 ? 'dtxn-row__amount--up' : 'dtxn-row__amount--down'"
>
{{ usePriceWithoutType(item.cash ?? 0) }} ریال
</div>
<div class="dtxn-row__status">
<ATag color="blue" class="!py-1 !px-4 !ml-0 !text-xs">
{{ item.order?.sts_str ?? item.withdraw?.sts_str ?? item.title }}
</ATag>
</div>
</button>
<div v-else @click="infoVisible = true">
<div class="flex">
<div :class="isUp? 'bg-green-50':'bg-red-50'" class="rounded-lg p-3 h-fit w-fit flex justify-center items-center">
<Icon :class="isUp? 'text-green-600':'text-red-600'"
:icon="`vuesax-linear:${isUp? 'arrow-up-2':'arrow-down-2'}`" />
<div :class="isUp ? 'bg-green-50' : 'bg-red-50'" class="rounded-lg p-3 h-fit w-fit flex justify-center items-center">
<Icon
:class="isUp ? 'text-green-600' : 'text-red-600'"
:icon="`vuesax-linear:${isUp ? 'arrow-up-2' : 'arrow-down-2'}`"
/>
</div>
<div class="w-full mr-3">
<div class="flex justify-between w-full">
@@ -12,13 +50,16 @@
<div>{{ toJalaliOutOfUTC(item.expire_at) }}</div>
</div>
<div class="flex justify-between mt-2">
<div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600':'text-red-600'"
class="text-gray-500 text-xs number-ltr">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}
<div>{{item.unit}}</div>
<div
:class="getTransactionGoldAmount(item) >= 0 ? 'text-green-600' : 'text-red-600'"
class="text-gray-500 text-xs number-ltr"
>
{{ formatGoldAmount(goldAmount) }}
<div>{{ item.unit }}</div>
</div>
<div v-if="!isShopProduct" class="text-gray-500 flex gap-1 text-xs items-center number-ltr">
<div :class="item.cash > 0 ? 'text-green-600':'text-red-600'" class="text-base">
{{ usePriceWithoutType(item.cash?? 0) }}
<div :class="item.cash > 0 ? 'text-green-600' : 'text-red-600'" class="text-base">
{{ usePriceWithoutType(item.cash ?? 0) }}
</div>
ریال
</div>
@@ -48,8 +89,7 @@
<div v-else-if="isGold" class="flex w-full justify-between mt-2">
<div class="text-gray-500">قیمت معامله:</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<!-- <div class="text-base">{{ typeof parseInt((Math.abs(item.cash) / Math.abs(item.gold)).toFixed(0)) }}</div>-->
<div class="text-base">{{ usePriceWithoutType(getTransactionTradePrice(item)) }}</div>
<div class="text-base">{{ usePriceWithoutType(tradePrice) }}</div>
ریال
</div>
</div>
@@ -57,167 +97,69 @@
<div class="text-gray-500">وضعیت :</div>
<div class="text-gray-500 flex gap-1 text-xs items-center">
<ATag class="!py-1 !px-6 !ml-0" color="blue">
{{ (item.order?.sts_str ?? item.withdraw?.sts_str) ?? item?.title }}
{{ item.order?.sts_str ?? item.withdraw?.sts_str ?? item?.title }}
</ATag>
</div>
</div>
<div v-if="item.note" class="flex w-full justify-between mt-1">
<div class="text-gray-500 flex gap-1 text-xs items-center">
<div class="!py-1 !ml-0">
{{ item.note }}
</div>
<div class="!py-1 !ml-0">{{ item.note }}</div>
</div>
</div>
<!-- <div v-else class="h-8"></div>-->
<a-divider class="!my-3" />
<BasicActionSheet v-model:show="infoVisible">
<div class="font-bold text-lg">{{ props.item.title }}</div>
<div class="pb-20 mt-5">
<div class="flex justify-between mb-3">
<div class="text-primary">مبلغ:</div>
<div :class="item.cash > 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.cash?? 0) }}</div>
ریال
</div>
</div>
<template v-if="isShopProduct">
<div class="flex justify-between mb-3">
<div class="text-primary">تعداد:</div>
<div class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ item.order?.number ?? 0 }}</div>
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">قیمت کل:</div>
<div class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.total_price ?? 0) }}</div>
ریال
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">قیمت واحد:</div>
<div class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price ?? 0) }}</div>
ریال
</div>
</div>
</template>
<template v-else>
<div v-if="item.order" class="flex justify-between mb-3">
<div class="text-primary">قیمت هر گرم طلا حین معامله:</div>
<div class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price?? 0) }}</div>
ریال
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">مقدار {{item.wallet?.product? item.wallet.product.title: 'آبشده' }} مبادله شده:</div>
<div :class="getTransactionGoldAmount(item) >= 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base ml-1">{{ formatGoldAmount(getTransactionGoldAmount(item)) }}</div>
{{item.unit}}
</div>
</div>
</template>
<div class="flex justify-between mb-3">
<div class="text-primary">موجودی کیف پول:</div>
<div :class="item.cash > 0? 'text-green-600' : 'text-red-600'" class="flex text-xs items-center number-ltr">
<div class="text-base">{{ usePriceWithoutType(item.balance_cash ?? 0) }}</div>
ریال
</div>
</div>
<div class="flex justify-between mb-3" v-if="item.wallet?.product">
<div class="text-primary">موجودی {{item.wallet.product.title}}:</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{item.wallet.product.unit}}
</div>
</div>
<div class="flex justify-between mb-3" v-else>
<div class="text-primary">موجودی کیف آبشده:</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
گرم
</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-primary">موجودی کیف پول:</div>
<div class="flex text-xs items-center" :class="isUp? 'text-green-600' : 'text-red-600'"><div class="text-base">{{usePriceWithoutType(12000000)}}</div>ریال</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">موجودی کیف طلا:</div>
<div class="flex items-center" :class="isUp? 'text-red-600' : 'text-green-600'">60 گرم</div>
</div>-->
<div class="flex justify-between mb-3">
<div class="text-primary">وضعیت:</div>
<div class="flex text-xs items-center">
<ATag :color="item.active? 'blue': 'blue'" class="!py-1 !px-6 !ml-0">
{{ item.order?.sts_str ?? item.withdraw?.sts_str }}
</ATag>
</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">تاریخ:</div>
<div>{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div class="flex justify-between mb-3">
<div class="text-primary">زمان:</div>
<div>{{ toTime(item.expire_at) }}</div>
</div>
<!-- <div class="flex justify-between mb-3">
<div class="text-primary">وضعیت معامله:</div>
<div class="flex text-xs items-center">
<ATag class="!py-1 !px-6 !ml-0" color="orange">
معاملات باز
</ATag>
</div>
</div>-->
<div v-if="item.eaj" class="flex justify-between mb-3">
<div class="text-primary">مهلت ارسال فیش:</div>
<div>{{ item.eaj }}</div>
</div>
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
</div>
<BasicActionSheet v-if="variant !== 'desktop'" v-model:show="infoVisible">
<div class="font-bold text-lg">{{ item.title }}</div>
<TransactionDetailPanel :item="item" variant="mobile" />
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
</BasicActionSheet>
<BasicModal
v-else
v-model:visible="infoVisible"
:title="item.title"
width="520px"
custom-footer
destroy-on-close
>
<TransactionDetailPanel :item="item" variant="desktop" />
<template #footer>
<BasicButton class="w-full" @click="infoVisible = false">بستن</BasicButton>
</template>
</BasicModal>
</template>
<script lang="ts" setup>
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { formatGoldAmount, getTransactionGoldAmount, getTransactionTradePrice, usePriceWithoutType } from '../../../../utils/usePrice'
import {
formatGoldAmount,
getTransactionGoldAmount,
getTransactionTradePrice,
usePriceWithoutType,
} from '@/utils/usePrice'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import { computed, ref } from 'vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import TransactionDetailPanel from './TransactionDetailPanel.vue'
import type { Transaction } from '@/models/transaction'
import { toJalaliNotime, toJalaliOutOfUTC, toTime } from '../../../../utils/useDateTime'
import { toJalaliOutOfUTC } from '@/utils/useDateTime'
import { computed, ref } from 'vue'
const infoVisible = ref<boolean>(false)
const props = withDefaults(
defineProps<{
item: Transaction
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
interface Prop {
item: Transaction
}
const props = withDefaults(defineProps<Prop>(), {})
const isUp = computed(() => props.item.type === 3 || props.item.type === 1 || props.item.type === 8 || props.item.type === 9)
const infoVisible = ref(false)
const goldAmount = computed(() => getTransactionGoldAmount(props.item))
const tradePrice = computed(() => getTransactionTradePrice(props.item))
const isUp = computed(
() => props.item.type === 3 || props.item.type === 1 || props.item.type === 8 || props.item.type === 9,
)
const isGold = computed(() => props.item.type === 1 || props.item.type === 2 || props.item.type === 10)
const isShopProduct = computed(() => !!props.item?.product_id)
/*const title = computed(() => {
if (isUp.value) {
if (isGold.value) {
return 'واریز ریالی'
} else {
return 'خرید طلا'
}
} else {
if (isGold.value) {
return 'برداشت ریالی'
} else {
return 'فروش طلا'
}
}
})*/
</script>
<style scoped>
</style>
@@ -0,0 +1,145 @@
<template>
<div :class="variant === 'desktop' ? 'dtxn-detail' : 'mb-8rem mt-5'">
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مبلغ:</div>
<div
:class="[
variant === 'desktop' ? 'dtxn-detail__value' : '',
item.cash > 0 ? 'text-green-600' : 'text-red-600',
'flex text-xs items-center number-ltr',
]"
>
<div class="text-base ml-1">{{ usePriceWithoutType(item.cash ?? 0) }}</div>
ریال
</div>
</div>
<template v-if="isShopProduct">
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">تعداد:</div>
<div :class="['flex text-xs items-center number-ltr', variant === 'desktop' ? 'dtxn-detail__value' : '']">
<div class="text-base ml-1">{{ item.order?.number ?? 0 }}</div>
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">قیمت کل:</div>
<div :class="['flex text-xs items-center number-ltr', variant === 'desktop' ? 'dtxn-detail__value' : '']">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.total_price ?? 0) }}</div>
ریال
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">قیمت واحد:</div>
<div :class="['flex text-xs items-center number-ltr', variant === 'desktop' ? 'dtxn-detail__value' : '']">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price ?? 0) }}</div>
ریال
</div>
</div>
</template>
<template v-else>
<div
v-if="item.order"
:class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'"
>
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">قیمت هر گرم طلا حین معامله:</div>
<div :class="['flex text-xs items-center number-ltr', variant === 'desktop' ? 'dtxn-detail__value' : '']">
<div class="text-base ml-1">{{ usePriceWithoutType(item.order?.price ?? 0) }}</div>
ریال
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
مقدار {{ item.wallet?.product ? item.wallet.product.title : 'آبشده' }} مبادله شده:
</div>
<div
:class="[
goldAmount >= 0 ? 'text-green-600' : 'text-red-600',
'flex text-xs items-center number-ltr',
variant === 'desktop' ? 'dtxn-detail__value' : '',
]"
>
<div class="text-base ml-1">{{ formatGoldAmount(goldAmount) }}</div>
{{ item.unit }}
</div>
</div>
</template>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">موجودی کیف پول:</div>
<div
:class="[
item.cash > 0 ? 'text-green-600' : 'text-red-600',
'flex text-xs items-center number-ltr',
variant === 'desktop' ? 'dtxn-detail__value' : '',
]"
>
<div class="text-base">{{ usePriceWithoutType(item.balance_cash ?? 0) }}</div>
ریال
</div>
</div>
<div
v-if="item.wallet?.product"
:class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'"
>
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">
موجودی {{ item.wallet.product.title }}:
</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
{{ item.wallet.product.unit }}
</div>
</div>
<div v-else :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">موجودی کیف آبشده:</div>
<div class="flex text-xs text-yellow-700 items-center">
<div class="text-base ml-1">{{ item.balance_gold }}</div>
گرم
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">وضعیت:</div>
<div class="flex text-xs items-center">
<ATag color="blue" class="!py-1 !px-6 !ml-0">
{{ item.order?.sts_str ?? item.withdraw?.sts_str }}
</ATag>
</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">تاریخ:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalaliNotime(item.expire_at) }}</div>
</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">زمان:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toTime(item.expire_at) }}</div>
</div>
<div v-if="item.eaj" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ item.eaj }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import {
formatGoldAmount,
getTransactionGoldAmount,
usePriceWithoutType,
} from '@/utils/usePrice'
import type { Transaction } from '@/models/transaction'
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
const props = withDefaults(
defineProps<{
item: Transaction
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const goldAmount = computed(() => getTransactionGoldAmount(props.item))
const isShopProduct = computed(() => !!props.item?.product_id)
</script>
@@ -0,0 +1,134 @@
import { computed, inject, onMounted, provide, ref, type ComponentPublicInstance, type InjectionKey } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { loading, loadingMore } from '@/utils'
import { getTransactionTable } from '@/api/transaction'
import type { Transaction } from '@/models/transaction'
import type { TableEngineInput, TableOutput } from '@/models/table'
export interface TransactionsPageContext {
items: ReturnType<typeof ref<Transaction[]>>
dateFilter: ReturnType<typeof ref<string | undefined>>
emptyText: ReturnType<typeof computed<string>>
applyFilter: () => Promise<void>
reloadTable: () => Promise<void>
bindMobileObserver: (el: Element | ComponentPublicInstance | null) => void
bindDesktopObserver: (el: Element | ComponentPublicInstance | null) => void
showFilter: ReturnType<typeof ref<boolean>>
}
export const TRANSACTIONS_PAGE_KEY: InjectionKey<TransactionsPageContext> = Symbol('transactionsPage')
export function useTransactionsPageProvider() {
const isDesktop = useDesktopBreakpoint()
const pagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const showFilter = ref(false)
const dateFilter = ref<string>()
const mobileObserverEl = ref<HTMLElement | null>(null)
const desktopObserverEl = ref<HTMLElement | null>(null)
const activeObserverEl = computed(() =>
isDesktop.value ? desktopObserverEl.value : mobileObserverEl.value
)
const emptyText = computed(() => 'تراکنشی موجود نیست')
async function initTable() {
try {
if (dateFilter.value) {
pagination.value.filters = [
{
field: 'created_at',
cr: 'and',
type: 'date',
op: 'eq',
value: dateFilter.value,
},
]
} else {
pagination.value.filters = []
}
pagination.value.page === 0 ? (loading.value = true) : (loadingMore.value = true)
tableOutput.value = await getTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
} catch (e) {
console.log(e)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function reloadTable() {
pagination.value.page = 0
items.value = []
await initTable()
}
async function applyFilter() {
await reloadTable()
showFilter.value = false
}
function resolveObserverElement(el: Element | ComponentPublicInstance | null) {
if (!el) return null
if (el instanceof HTMLElement) return el
const root = (el as ComponentPublicInstance).$el
return root instanceof HTMLElement ? root : null
}
function bindMobileObserver(el: Element | ComponentPublicInstance | null) {
mobileObserverEl.value = resolveObserverElement(el)
}
function bindDesktopObserver(el: Element | ComponentPublicInstance | null) {
desktopObserverEl.value = resolveObserverElement(el)
}
useIntersectionObserver(
activeObserverEl,
async ([entry]) => {
if (
entry?.isIntersecting &&
tableOutput.value &&
(pagination.value.page + 1) * pagination.value.limit < tableOutput.value.query.total
) {
pagination.value.page++
await initTable()
}
},
{
root: null,
rootMargin: '300px',
threshold: 0.01,
}
)
onMounted(() => {
initTable()
})
const context: TransactionsPageContext = {
items,
dateFilter,
emptyText,
applyFilter,
reloadTable,
bindMobileObserver,
bindDesktopObserver,
showFilter,
}
provide(TRANSACTIONS_PAGE_KEY, context)
return context
}
export function useTransactionsPageContext() {
const context = inject(TRANSACTIONS_PAGE_KEY)
if (!context) {
throw new Error('useTransactionsPageContext must be used within useTransactionsPageProvider')
}
return context
}
@@ -0,0 +1,81 @@
<template>
<div class="dtxn">
<div class="dtxn__container">
<DesktopPageTopbar title="تراکنش‌ها" />
<div class="dtxn-filters">
<div class="dtxn-filters__label">
<BasicIcon icon="vuesax-linear:filter" size="16" />
<span>فیلتر تاریخ:</span>
</div>
<BaseDatePicker
v-model:value="dateFilter"
class="dtxn-filters__date"
placeholder="تاریخ"
size="large"
/>
<BasicButton type="primary" @click="applyFilter">اعمال فیلتر</BasicButton>
<BasicButton v-if="dateFilter" @click="clearFilter">حذف فیلتر</BasicButton>
</div>
<div :class="loading ? 'min-h-[40vh]' : ''" class="mt-5">
<div v-if="!loading" class="dtxn-list">
<list-anim>
<Item
v-for="item in items"
:key="item.id"
:item="item"
variant="desktop"
/>
</list-anim>
<div :ref="bindDesktopObserver" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</div>
<template v-else>
<TransactionSkeleton v-for="i in 8" :key="i" class="mt-1" />
</template>
<div v-if="items.length <= 0 && !loading" class="dtxn-empty">
<DotLottieVue
class="w-44 h-44"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<p class="dtxn-empty__text">{{ emptyText }}</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { loading, loadingMore } from '@/utils'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import Item from '../components/Item.vue'
import { useTransactionsPageContext } from '../composables/useTransactionsPage'
const {
items,
dateFilter,
emptyText,
applyFilter,
reloadTable,
bindDesktopObserver,
} = useTransactionsPageContext()
async function clearFilter() {
dateFilter.value = undefined
await reloadTable()
}
</script>
<style lang="less" src="./styles/desktop-transactions.less"></style>
@@ -0,0 +1,286 @@
/* Premium desktop transactions page */
.dtxn {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dtxn__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dtxn-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.dtxn-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
&__btn {
padding: 8px 14px;
border: 1px solid #e5e7eb;
border-radius: 999px;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #6b7280;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
border-color: #d1d5db;
color: #374151;
}
&--active {
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
}
}
}
.dtxn-toggle {
display: inline-flex;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 10px;
&__btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: #6b7280;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&--active {
background: #fff;
color: #111827;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
}
}
.dtxn-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 1 1 100%;
min-width: 100%;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #6b7280;
flex-shrink: 0;
}
&__product {
flex: 1;
min-width: 140px;
max-width: 240px;
}
&__select,
&__date {
min-width: 140px;
}
&__dates {
display: flex;
align-items: center;
gap: 10px;
}
}
.dtxn-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dtxn-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 16px;
align-items: center;
width: 100%;
padding: 16px 18px;
border: 1px solid #f3f4f6;
border-radius: 12px;
background: #fff;
text-align: right;
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: #e5e7eb;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
&--up {
background: #ecfdf3;
color: #027a48;
}
&--down {
background: #fef3f2;
color: #b42318;
}
}
&__main {
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin: 0 0 4px;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 12px;
color: #6b7280;
}
&__amount {
font-size: 15px;
font-weight: 600;
direction: ltr;
text-align: left;
white-space: nowrap;
&--up {
color: #027a48;
}
&--down {
color: #b42318;
}
}
&__status {
flex-shrink: 0;
}
}
.dtxn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 24px;
text-align: center;
background: #fff;
border-radius: 14px;
border: 1px dashed #e5e7eb;
&__image {
width: 140px;
max-width: 40vw;
height: auto;
object-fit: contain;
}
&__text {
margin-top: 16px;
font-size: 14px;
font-weight: 600;
color: #6b7280;
}
}
.dtxn-detail {
display: flex;
flex-direction: column;
gap: 12px;
&__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 13px;
&:last-child {
border-bottom: none;
}
}
&__label {
color: #6b7280;
font-weight: 500;
flex-shrink: 0;
}
&__value {
color: #111827;
font-weight: 600;
text-align: left;
}
}
@media (max-width: 900px) {
.dtxn-row {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
&__amount,
&__status {
grid-column: 2;
}
&__amount {
justify-self: start;
}
&__status {
justify-self: end;
}
}
}
+18 -203
View File
@@ -1,213 +1,28 @@
<template>
<div >
<!-- <teleport to="body">-->
<div :style="{ backgroundImage: `url(${img})` }" class="fixed top-0 right-0 left-0 bottom-0"></div>
<!-- </teleport>-->
<div class="absolute inset-0 bg-cover bg-center opacity-60"></div>
<div
class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10"
>
<div>لیست تراکنش ها</div>
<BasicIcon icon="vuesax-linear:filter" @click="showFilter = true" />
</div>
<!-- <a-spin :spinning="loading">-->
<div
ref="el"
:class="loading ? 'h-screen' : ''"
class="px-3 pt-16 pb-4 "
style="max-height: calc(100vh - 50px)"
>
<!-- <div v-for="item in items">ss</div>-->
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
<div ref="observer" v-show="!loading" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</list-anim>
<TransactionSkeleton v-for="i in 13" v-else :key="i" class="mt-1" />
<div v-if="items.length <= 0" class="flex flex-col items-center justify-center h-80vh">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<div class="font-bold">تراکنشی موجود نیست</div>
</div>
</div>
<!-- </a-spin>-->
<BasicActionSheet v-model:show="showFilter">
<div class="font-bold text-lg">فیلتر</div>
<div class="pb-20 mt-5">
<a-form-item class="w-full gap-2 !flex-col" label="تاریخ" name="password">
<BaseDatePicker v-model:value="dateFilter" class="w-full" size="large" />
</a-form-item>
<BasicButton class="w-full mt-5" type="primary" @click="applyFilter"
>اعمال فیلتر</BasicButton
>
<BasicButton class="w-full mt-2" @click="showFilter = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
<div class="transactions-page-root">
<ResponsiveViewPair>
<template #mobile>
<MobileTransactionsView />
</template>
<template #desktop>
<DesktopDefaultTransactionsView />
</template>
</ResponsiveViewPair>
</div>
</template>
<script lang="ts" setup>
import Item from './components/Item.vue'
/*import {lengthValidation, required} from "@/utils/inputRules";
import {loading} from "@/utils";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import BasicInput from "@/components/BasicPacks/BasicInput.vue";*/
import { onMounted, ref } from 'vue'
import type { TableEngineInput, TableOutput } from '@/models/table'
import { loading, loadingMore } from '@/utils'
// import {getOrders} from "@/api/order";
import { getTransactionTable } from '@/api/transaction'
import type { Transaction } from '@/models/transaction'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import {useInfiniteScroll, useIntersectionObserver, useScroll} from '@vueuse/core'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import img from "@/assets/img/user_ly1.svg";
import ResponsiveViewPair from '@/layouts/components/ResponsiveViewPair.vue'
import { useTransactionsPageProvider } from './composables/useTransactionsPage'
import MobileTransactionsView from './mobile/MobileTransactionsView.vue'
import DesktopDefaultTransactionsView from './desktop/DesktopDefaultTransactionsView.vue'
const pagination = ref<TableEngineInput>({ filters: [], limit: 7, page: 0 })
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const showFilter = ref<boolean>(false)
const dateFilter = ref<string>()
const observer = ref()
// const { y } = useScroll(el)
async function initTable() {
try {
if (dateFilter.value) {
pagination.value.filters = []
pagination.value.filters.push({
field: 'created_at',
cr: 'and',
type: 'date',
op: 'eq',
value: dateFilter.value
})
} else {
pagination.value.filters = []
}
pagination.value.page === 0 ? (loading.value = true) : (loadingMore.value = true)
tableOutput.value = await getTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
// console.log(tableOutput.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function applyFilter() {
pagination.value.page = 0
items.value = []
await initTable()
showFilter.value = false
}
onMounted(() => {
initTable()
})
/*useInfiniteScroll(
el,
async () => {
// load more
// console.log('salamss')
if (
tableOutput.value &&
(pagination.value.page + 1) * pagination.value.limit < tableOutput.value.query.total
) {
// console.log('gfgf')
pagination.value.page++
await initTable()
}
},
{ distance: 12 }
)*/
useIntersectionObserver(
observer,
async ([{isIntersecting}]) => {
if ( isIntersecting &&
tableOutput.value &&
(pagination.value.page + 1) * pagination.value.limit < tableOutput.value.query.total
) {
// console.log('gfgf')
pagination.value.page++
await initTable()
}
},
{
root: null,
rootMargin: '300px',
threshold: 0.01
}
)
/*const items = [
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
{
isUp: true,
hasTransition: true
},
{
isUp: false,
hasTransition: false
},
{
isUp: true,
hasTransition: true
},
]*/
useTransactionsPageProvider()
</script>
<style scoped lang="less">
.transaction-list {
position: fixed;
right: 0;
left: 0;
@media screen and (min-width: 426px) {
width: 426px;
left: auto;
right: auto;
}
<style scoped>
.transactions-page-root {
min-height: 0;
height: 100%;
}
</style>
@@ -0,0 +1,84 @@
<template>
<div>
<div :style="{ backgroundImage: `url(${img})` }" class="fixed top-0 right-0 left-0 bottom-0"></div>
<div class="absolute inset-0 bg-cover bg-center opacity-60"></div>
<div
class="bg-white w-full transaction-list py-3 px-3 font-bold drop-shadow flex justify-between z-10"
>
<div>لیست تراکنش ها</div>
<BasicIcon icon="vuesax-linear:filter" @click="showFilter = true" />
</div>
<div
ref="el"
:class="loading ? 'h-screen' : ''"
class="px-3 pt-16 pb-4"
style="max-height: calc(100vh - 50px)"
>
<list-anim v-if="!loading">
<Item v-for="item in items" :key="item.id" :item="item" />
<div :ref="bindMobileObserver" v-show="!loading" class="w-full opacity-0 relative">
در حال بارگذاری
</div>
<TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />
</list-anim>
<TransactionSkeleton v-for="i in 13" v-else :key="i" class="mt-1" />
<div v-if="items.length <= 0 && !loading" class="flex flex-col items-center justify-center h-80vh">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/51d3315a-cf88-4c18-bb4d-a31511fd4325/KjSBzcMbi6.json"
/>
<div class="font-bold">{{ emptyText }}</div>
</div>
</div>
<BasicActionSheet v-model:show="showFilter">
<div class="font-bold text-lg">فیلتر</div>
<div class="pb-20 mt-5">
<a-form-item class="w-full gap-2 !flex-col" label="تاریخ" name="password">
<BaseDatePicker v-model:value="dateFilter" class="w-full" size="large" />
</a-form-item>
<BasicButton class="w-full mt-5" type="primary" @click="applyFilter">اعمال فیلتر</BasicButton>
<BasicButton class="w-full mt-2" @click="showFilter = false">بازگشت</BasicButton>
</div>
</BasicActionSheet>
</div>
</template>
<script lang="ts" setup>
import Item from '../components/Item.vue'
import { ref } from 'vue'
import { loading, loadingMore } from '@/utils'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import img from '@/assets/img/user_ly1.svg'
import { useTransactionsPageContext } from '../composables/useTransactionsPage'
const el = ref<HTMLElement | null>(null)
const {
items,
dateFilter,
emptyText,
applyFilter,
bindMobileObserver,
showFilter,
} = useTransactionsPageContext()
</script>
<style scoped lang="less">
.transaction-list {
position: fixed;
right: 0;
left: 0;
@media screen and (min-width: 426px) {
width: 426px;
left: auto;
right: auto;
}
}
</style>
@@ -0,0 +1,31 @@
<template>
<a-form ref="formPassRef" :model="userPassForm" class="!mt-5" name="basic" @finish="submitPassForm">
<a-form-item label="کلمه عبور جدید" :rules="passwordRules()" name="password">
<BasicInput v-model:value="userPassForm.password" is-password place-holder="کلمه عبور " />
<div
:class="[guideTypePassword.color, userPassForm.password.length >= 6 ? 'block' : 'hidden']"
class="text-[12px] mt-1"
>
{{ guideTypePassword.text }}
</div>
</a-form-item>
<a-form-item v-if="showSubmit">
<BasicButton :disabled="loading" class="w-full py-2" html-type="submit" type="primary">
<a-spin v-if="loading" size="small" />
<span v-else>ثبت</span>
</BasicButton>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import { loading } from '@/utils'
import { useUserPageContext } from '../composables/useUserPage'
withDefaults(defineProps<{ showSubmit?: boolean }>(), { showSubmit: false })
const { userPassForm, formPassRef, passwordRules, guideTypePassword, submitPassForm } =
useUserPageContext()
</script>
@@ -0,0 +1,41 @@
<template>
<a-form ref="formRef" :model="userFormState" class="!mt-5" name="basic" @finish="ok">
<a-form-item :rules="[isValidPersian()]" label="نام" name="fname">
<BasicInput v-model:value="userFormState.fname" />
</a-form-item>
<a-form-item :rules="[isValidPersian()]" label="نام خانوادگی" name="lname">
<BasicInput v-model:value="userFormState.lname" />
</a-form-item>
<a-form-item label="تاریخ تولد" name="birth_date">
<BaseDatePicker v-model:value="userFormState.birth_date" class="w-full" input-read-only />
</a-form-item>
<a-form-item :rules="[requireNationalCode()]" label="شماره ملی" name="n_code">
<BasicInput
v-model:value="userFormState.n_code"
type="number"
:disabled="userFormState.is_legal == 1 || userFormState.is_legal == 2"
/>
</a-form-item>
<a-form-item label="نام شهر" name="city">
<BasicInput v-model:value="userFormState.city" />
</a-form-item>
<a-form-item v-if="showSubmit">
<BasicButton :disabled="loading" class="w-full py-2" html-type="submit" type="primary">
<a-spin v-if="loading" size="small" />
<span v-else>ثبت</span>
</BasicButton>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import { loading } from '@/utils'
import { useUserPageContext } from '../composables/useUserPage'
withDefaults(defineProps<{ showSubmit?: boolean }>(), { showSubmit: false })
const { userFormState, formRef, isValidPersian, requireNationalCode, ok } = useUserPageContext()
</script>
@@ -0,0 +1,201 @@
import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import { loading } from '@/utils'
import {
guideTypePassword,
isValidPersian,
passwordRules,
requireNationalCode,
} from '@/utils/inputRules'
import type { Password, User } from '@/models/login'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me'
import { useUserStore } from '@/store/userStore'
export interface UserMenuAction {
id: string
label: string
icon: string
onClick: () => void
}
export interface UserPageContext {
userStore: ReturnType<typeof useUserStore>
userFormState: ReturnType<typeof ref<User>>
userPassForm: ReturnType<typeof ref<Password>>
formRef: ReturnType<typeof ref<FormInstance | undefined>>
formPassRef: ReturnType<typeof ref<FormInstance | undefined>>
editPassVisible: ReturnType<typeof ref<boolean>>
editPasswordVisible: ReturnType<typeof ref<boolean>>
isLegal: ReturnType<typeof computed<{ title: string; color: string; icon: string }>>
shareButtonLabel: ReturnType<typeof computed<string>>
menuActions: ReturnType<typeof computed<UserMenuAction[]>>
loading: typeof loading
guideTypePassword: typeof guideTypePassword
isValidPersian: typeof isValidPersian
requireNationalCode: typeof requireNationalCode
passwordRules: typeof passwordRules
copyCode: () => void
shareCode: () => void
ok: () => void
submitPassForm: () => void
}
export const USER_PAGE_CONTEXT_KEY: InjectionKey<UserPageContext> = Symbol('userPageContext')
export function useUserPage() {
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
city: '',
n_code: undefined,
})
const userPassForm = ref<Password>({ password: '' })
const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>()
const userStore = useUserStore()
const editPassVisible = ref(false)
const editPasswordVisible = ref(false)
const isLegal = computed(() => {
const result = { title: '', color: '', icon: '' }
switch (userFormState.value.is_legal) {
case 0:
result.title = 'احراز هویت نشده'
result.color = 'reject'
result.icon = 'vuesax-linear:close-circle'
break
case 1:
case 2:
result.title = 'احراز هویت شده'
result.color = 'success'
result.icon = 'vuesax-linear:verify'
break
}
return result
})
const shareButtonLabel = computed(() => 'اشتراک گذاری')
const menuActions = computed<UserMenuAction[]>(() => [
{
id: 'password',
label: 'تغییر رمز عبور',
icon: 'vuesax-linear:lock',
onClick: () => { editPasswordVisible.value = true },
},
{
id: 'about',
label: 'درباره ما',
icon: 'vuesax-linear:info-circle',
onClick: () => router.push({ name: 'about' }),
},
{
id: 'rules',
label: 'قوانین و مقررات',
icon: 'vuesax-linear:task',
onClick: () => router.push({ name: 'rules' }),
},
])
function setForm() {
if (userStore.userInfo) {
userFormState.value = { ...userStore.userInfo }
}
}
function copyCode() {
const val = userStore.userInfo?.referral_id
if (val) {
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
}
function shareCode() {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({ title: 'کشکول گلد', text: val })
}
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
location.reload()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
function submitPassForm() {
formPassRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updatePass(userPassForm.value)
await userStore.getMyInfo()
message.success('پسورد شما با موفقیت به روز رسانی شد')
editPasswordVisible.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
onMounted(() => {
setForm()
if (userStore.userInfo?.is_legal === 0) {
editPassVisible.value = true
}
})
const context: UserPageContext = {
userStore,
userFormState,
userPassForm,
formRef,
formPassRef,
editPassVisible,
editPasswordVisible,
isLegal,
shareButtonLabel,
menuActions,
loading,
guideTypePassword,
isValidPersian,
requireNationalCode,
passwordRules,
copyCode,
shareCode,
ok,
submitPassForm,
}
provide(USER_PAGE_CONTEXT_KEY, context)
return context
}
export function useUserPageContext() {
const context = inject(USER_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useUserPageContext must be used within useUserPage')
}
return context
}
@@ -0,0 +1,101 @@
<template>
<div class="duser">
<div class="duser__container">
<header class="duser-topbar">
<button type="button" class="duser-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="duser-topbar__meta">
<p class="duser-topbar__eyebrow">حساب کاربری</p>
<h1 class="duser-topbar__title">اطلاعات حساب کاربری</h1>
</div>
</header>
<div class="duser-grid">
<section class="duser-surface duser-profile">
<div class="duser-profile__header">
<div class="duser-profile__avatar">
<BasicIcon icon="vuesax-linear:user" size="28" />
</div>
<div>
<h2 class="duser-profile__name">{{ userStore.userInfo?.name ?? 'کاربر' }}</h2>
<p class="duser-profile__phone">{{ userStore.userInfo?.mobile }}</p>
</div>
</div>
<div class="duser-profile__fields">
<div class="duser-profile__field">
<span class="duser-profile__label">نام و نام خانوادگی</span>
<span class="duser-profile__value">{{ userStore.userInfo?.name }}</span>
</div>
<div class="duser-profile__field">
<span class="duser-profile__label">شماره تلفن همراه</span>
<span class="duser-profile__value">{{ userStore.userInfo?.mobile }}</span>
</div>
<div v-if="userStore.userInfo?.referral_id" class="duser-profile__field">
<span class="duser-profile__label">کد معرف</span>
<span class="duser-profile__value">{{ userStore.userInfo?.referral_id }}</span>
</div>
</div>
<div v-if="userStore.userInfo?.referral_id" class="duser-profile__referral-actions">
<button type="button" class="duser-profile__btn" @click="shareCode">
<BasicIcon icon="vuesax-linear:share" size="16" />
<span>{{ shareButtonLabel }}</span>
</button>
<button type="button" class="duser-profile__btn" @click="copyCode">
<BasicIcon icon="vuesax-linear:copy" size="16" />
<span>رونوشت</span>
</button>
</div>
<button
type="button"
class="duser-profile__btn duser-profile__btn--primary"
@click="editPassVisible = true"
>
<BasicIcon icon="vuesax-linear:edit-2" size="16" />
<span>ویرایش اطلاعات کاربری</span>
</button>
</section>
<section class="duser-surface duser-actions">
<h3 class="duser-actions__title">تنظیمات و خدمات</h3>
<div class="duser-actions__list">
<button
v-for="action in menuActions"
:key="action.id"
type="button"
class="duser-actions__item"
@click="action.onClick"
>
<span class="duser-actions__icon">
<BasicIcon :icon="action.icon" size="18" />
</span>
<span class="duser-actions__label">{{ action.label }}</span>
<BasicIcon class="duser-actions__chevron" icon="vuesax-linear:arrow-left" size="16" />
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import { useUserPageContext } from '../composables/useUserPage'
const {
userStore,
editPassVisible,
shareButtonLabel,
menuActions,
copyCode,
shareCode,
} = useUserPageContext()
</script>
<style lang="less" src="./styles/desktop-user.less"></style>
@@ -0,0 +1,260 @@
/* Premium desktop user account page */
.duser {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.duser__container {
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.duser-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: #6b7280;
font-size: 13px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 8px 0;
&:hover {
color: #111827;
}
}
&__meta {
flex: 1;
min-width: 0;
}
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin: 0 0 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
margin: 0;
line-height: 1.2;
}
}
.duser-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
gap: 20px;
align-items: start;
}
.duser-surface {
background: #ffffff;
border-radius: 14px;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.duser-profile {
&__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #f3f4f6;
}
&__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
color: @App-primary-color;
flex-shrink: 0;
}
&__name {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
margin: 0 0 4px;
}
&__phone {
font-size: 13px;
color: #6b7280;
margin: 0;
direction: ltr;
text-align: right;
}
&__fields {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
&__field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: #f9fafb;
}
&__label {
font-size: 12px;
font-weight: 600;
color: #6b7280;
}
&__value {
font-size: 14px;
font-weight: 600;
color: #111827;
direction: ltr;
text-align: left;
}
&__referral-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
&__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #374151;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
background: #f9fafb;
border-color: #d1d5db;
}
&--primary {
width: 100%;
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
&:hover {
opacity: 0.92;
color: #fff;
}
}
}
}
.duser-actions {
&__title {
font-size: 13px;
font-weight: 600;
color: #374151;
margin: 0 0 16px;
}
&__list {
display: flex;
flex-direction: column;
gap: 8px;
}
&__item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
border: 1px solid #f3f4f6;
border-radius: 10px;
background: #fff;
text-align: right;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
background: #f9fafb;
border-color: #e5e7eb;
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #f3f4f6;
color: #374151;
flex-shrink: 0;
overflow: visible;
}
&__icon-image {
width: 18px;
height: 18px;
object-fit: contain;
transform: scale(2.2);
}
&__label {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #111827;
}
&__chevron {
color: #9ca3af;
flex-shrink: 0;
}
}
@media (max-width: 860px) {
.duser-grid {
grid-template-columns: 1fr;
}
}
+61 -493
View File
@@ -1,212 +1,9 @@
<template>
<div class="relative">
<div>
<!-- <img :src="gr_bottom" alt="" class="absolute bottom-20">-->
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="bg-white w-full py-3 px-3 font-bold drop-shadow flex">
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2 items-center">اطلاعات حساب کاربری</div>
</div>
<div class="pt-16 pb-8 z-30 relative">
<div
class="border-gray-200 border-1 rounded-xl p-5 trans flex flex-col items-center mx-5 z-1000 relative bg-white shadow-lg"
>
<div class="font-bold w-full text-center flex justify-between">
نام و نام خانوادگی:
<div class="mr-2">{{ userStore.userInfo?.name }}</div>
</div>
<div class="font-bold w-full text-center flex justify-between">
شماره تلفن همراه:
<div class="mr-2">{{ userStore.userInfo?.mobile }}</div>
</div>
<div
v-if="userStore.userInfo?.referral_id"
class="font-bold w-full text-center flex justify-between gap-2 items-center"
>
کد معرف:
<div class="flex gap-2">
<div class="mr-2">{{ userStore.userInfo?.referral_id }}</div>
<!-- <BasicIcon icon="vuesax-linear:copy" color="#2563EB" class="cursor-pointer" size="18"
@click.stop="copyCode()" />
&lt;!&ndash; vuesax-linear:hierarchy-3&ndash;&gt;
<BasicIcon icon="vuesax-linear:share" color="#2563EB" class="cursor-pointer" size="18"
@click.stop="shareCode()" />-->
</div>
</div>
<div class="w-full flex gap-2 mt-1">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1"
icon-size="18"
@click.stop="shareCode()"
>
اشتراک گذاری
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1"
icon-size="18"
@click.stop="copyCode()"
>
رونوشت
</BasicButton>
</div>
<BasicButton
class="mt-5 w-full"
icon=""
iconSize="18"
size="large"
type="primary"
@click="editPassVisible = true"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:eye" class="absolute right-8" />
<div>ویرایش اطلاعات کاربری</div>
</div>
</BasicButton>
<div class="h-40vh overflow-y-auto mt-4">
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="editPasswordVisible = true"
:key="10"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:lock" class="absolute right-8" />
<div>تغییر رمز عبور</div>
</div>
</BasicButton>
<!-- <BasicButton
class="w-full mt-2"
size="large"
@click="$router.push({ name: 'address' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:map" class="absolute right-8" />
<div>آدرس ها</div>
</div>
</BasicButton>-->
<!-- <BasicButton-->
<!-- class="w-full mt-2"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'notifyTrigger' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:bill" class="absolute right-8" />-->
<!-- <div>هشدار قیمت</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'about' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:info-circle" class="absolute right-8" />
<div>درباره ما</div>
</div>
</BasicButton>
<!-- <BasicButton-->
<!-- class="mt-2 w-full"-->
<!-- icon=""-->
<!-- iconSize="18"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'physicalReceive' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:direct-inbox" class="absolute right-8" />-->
<!-- <div>دریافت طلای فیزیکی</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'rules' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:task" class="absolute right-8" />
<div>قوانین و مقررات</div>
</div>
</BasicButton>
<!-- <BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'permissions' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:archive-book" class="absolute right-8" />
<div>مجوز ها</div>
</div>
</BasicButton>-->
<!-- <BasicButton-->
<!-- class="mt-2 w-full"-->
<!-- icon=""-->
<!-- iconSize="18"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'earnMoney' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:dollar-circle" class="absolute right-8" />-->
<!-- <div>کسب درآمد</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="showLogoutConfirm=!showLogoutConfirm"
>
<div class="flex">
<Icon
:color="y > 50 ? '' : ''"
class="color-transition absolute right-8"
icon="vuesax-linear:logout"
@click="showLogoutConfirm = true"
/>
<div>خروج از حساب کاربری</div>
</div>
</BasicButton>
</div>
</div>
</div>
<DialogVant v-model:show="showLogoutConfirm" :closeOnClickOverlay="false" :timeOut="-1">
<!-- <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80">
</template>-->
<div class="user-page-root">
<DesktopUserView v-show="isDesktop" />
<MobileUserView v-show="!isDesktop" />
<template v-slot:title>
<div class="flex flex-col items-center">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
</template>
<template v-slot:button>
<div class="text-center text-red flex items-center justify-center" @click="logout">
<a-spin :spinning="logoutLoading" size="small"> خروج</a-spin>
</div>
<div class="text-center" @click="showLogoutConfirm = false">انصراف</div>
</template>
</DialogVant>
<BasicActionSheet v-model:show="editPassVisible">
<BasicActionSheet v-if="!isDesktop" v-model:show="editPassVisible">
<div class="flex justify-center justify-between">
<div class="font-bold text-lg">ویرایش اطلاعات</div>
<div class="flex flex-col justify-center items-center">
@@ -215,314 +12,85 @@
</div>
</div>
<div class="h60vh overflow-y-auto pb20">
<a-form ref="formRef" :model="userFormState" class="!mt-5" name="basic" @finish="ok">
<a-form-item :rules="[isValidPersian()]" label="نام" name="fname">
<BasicInput v-model:value="userFormState.fname" />
</a-form-item>
<a-form-item :rules="[isValidPersian()]" label="نام خانوادگی" name="lname">
<BasicInput v-model:value="userFormState.lname" />
</a-form-item>
<a-form-item label="تاریخ تولد" name="birth_date">
<BaseDatePicker v-model:value="userFormState.birth_date" class="w-full" inputReadOnly />
</a-form-item>
<a-form-item :rules="[requireNationalCode()]" label="شماره ملی" name="n_code">
<BasicInput v-model:value="userFormState.n_code" type="number" :disabled="userFormState.is_legal == 1 || userFormState.is_legal == 2" />
</a-form-item>
<a-form-item label="نام شهر" name="city">
<BasicInput v-model:value="userFormState.city" />
</a-form-item>
<a-form-item>
<div class="wrap-fix md:w-[395px] bottom-5 px-3">
<BasicButton
:disabled="loading"
class="w-full py-2 mb-20"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>ثبت</div>
</div>
</BasicButton>
</div>
</a-form-item>
</a-form>
<UserProfileForm show-submit />
</div>
</BasicActionSheet>
<BasicActionSheet v-model:show="editPasswordVisible">
<BasicModal
v-else
v-model:visible="editPassVisible"
title="ویرایش اطلاعات"
width="520px"
destroy-on-close
:loading="loading"
@ok="ok"
>
<div class="flex justify-end mb-4">
<div class="flex flex-col items-center">
<BasicIcon :icon="isLegal.icon" size="30" :class="isLegal.color" />
<p class="text-[10px] mt-1" :class="isLegal.color">{{ isLegal.title }}</p>
</div>
</div>
<UserProfileForm />
</BasicModal>
<BasicActionSheet v-if="!isDesktop" v-model:show="editPasswordVisible">
<div class="font-bold text-lg">ویرایش رمزعبور</div>
<div class="h30vh overflow-y-auto pb20">
<a-form
ref="formPassRef"
:model="userPassForm"
class="!mt-5"
name="basic"
@finish="submitPassForm"
>
<a-form-item label="کلمه عبور جدید" :rules="passwordRules()" name="password">
<BasicInput v-model:value="userPassForm.password" isPassword place-holder="کلمه عبور " />
<div
:class="[
guideTypePassword.color,
userPassForm.password.length >= 6 ? 'block' : 'hidden'
]"
class="text-[12px] mt-1"
>
{{ guideTypePassword.text }}
</div>
</a-form-item>
<a-form-item>
<div class="wrap-fix md:w-[395px] bottom-5 px-3">
<BasicButton
:disabled="loading"
class="w-full py-2 mb-20"
html-type="submit"
type="primary"
>
<div>
<a-spin v-if="loading" size="small" />
<div v-else>ثبت</div>
</div>
</BasicButton>
</div>
</a-form-item>
</a-form>
<div class="h35vh overflow-y-auto">
<UserPasswordForm show-submit />
</div>
</BasicActionSheet>
<BasicModal
v-else
v-model:visible="editPasswordVisible"
title="ویرایش رمزعبور"
width="480px"
destroy-on-close
:loading="loading"
@ok="submitPassForm"
>
<UserPasswordForm />
</BasicModal>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, computed } from 'vue'
import img from '@/assets/img/user_ly1.svg'
import gr_bottom from './assets/img/gr_bottom_white.svg'
import {loading, removeToken} from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import {
lengthValidation,
required,
requireNationalCode,
passwordRules,
guideTypePassword,
isValidPersian,
} from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User, Password } from '@/models/login'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import {DotLottieVue} from "@lottiefiles/dotlottie-vue";
import DialogVant from "@/components/DialogVant.vue";
import {useWalletStore} from "@/store/walletStore";
import Icon from "@/components/BasicPacks/BasicIcon.vue";
import {useWindowScroll} from "@vueuse/core/index";
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
city: '',
n_code: undefined
})
const userPassForm = ref<Password>({
password: ''
})
const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>()
const userStore = useUserStore()
const showLogoutConfirm = ref<boolean>()
const walletStore=useWalletStore()
const logoutLoading = ref<boolean>(false)
const { x, y } = useWindowScroll()
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useUserPage } from './composables/useUserPage'
import MobileUserView from './mobile/MobileUserView.vue'
import DesktopUserView from './desktop/DesktopUserView.vue'
import UserProfileForm from './components/UserProfileForm.vue'
import UserPasswordForm from './components/UserPasswordForm.vue'
// const editPassVisible = ref<boolean>(false)
const editPassVisible = ref<boolean>(false)
const editPasswordVisible = ref<boolean>(false)
const isDesktop = useDesktopBreakpoint()
async function logout() {
logoutLoading.value = true
// baseConfirm('آیا از حساب کاربری خارج می شوید؟',async () => {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
/* userStore.$reset()
walletStore.$reset()*/
removeToken()
await router.push({ name: 'login' })
logoutLoading.value = true
window.location.reload()
// console.log('salam')
// })
}
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
// })
}
const isLegal = computed(() => {
const result = {
title: '',
color: '',
icon: ''
}
switch (userFormState.value.is_legal) {
case 0:
result.title = 'احراز هویت نشده'
result.color = 'reject'
result.icon = 'vuesax-linear:close-circle'
break
case 1:
result.title = 'احراز هویت شده'
result.color = 'success'
result.icon = 'vuesax-linear:verify'
break
case 2:
result.title = 'احراز هویت شده'
result.color = 'success'
result.icon = 'vuesax-linear:verify'
break
}
return result
})
function shareCode() {
// nextTick(() => {
const val = `⭐️الان طلا بخر؛ آخر ماه سودشو ببر!
با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!
این دعوت نامه از طرف "${userStore.userInfo?.name}" عزیز برای شما ارسال شده تا شما هم از بازار طلای کشکول گلد جا نمونید.
روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.
kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
navigator.share({
title: 'کشکول گلد',
text: val
})
// message.info('کد رونوشت شد')
// })
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
city: '',
n_code: undefined
}
}
onMounted(() => {
setForm()
})
function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updateMe(userFormState.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('اطلاعات شما با موفقیت به روز رسانی شد')
location.reload()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
function submitPassForm() {
formPassRef.value?.validateFields().then(async () => {
try {
loading.value = true
await updatePass(userPassForm.value)
await userStore.getMyInfo()
editPassVisible.value = false
message.success('پسورد شما با موفقیت به روز رسانی شد')
editPasswordVisible.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
onMounted(() => {
console.log(userStore.userInfo?.is_legal === 2)
if (userStore.userInfo?.is_legal === 0) {
editPassVisible.value = true
}
})
const {
editPassVisible,
editPasswordVisible,
isLegal,
loading,
ok,
submitPassForm,
} = useUserPage()
</script>
<style scoped>
/*.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
.user-page-root {
min-height: 100%;
height: 100%;
}
</style>
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
.midLevel {
color: orange;
}
.week,
<style lang="less">
.reject {
color: red;
}
.success {
color: green;
}
.background-image-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 0;
}
.background-image-container img {
width: 100%;
height: auto;
}
.relative {
position: relative;
z-index: 40;
}
.relative {
overflow-y: auto;
max-height: 100vh;
//scroll-behavior: smooth;
}
.fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50; /* بالاتر از همه */
}
</style>
@@ -0,0 +1,298 @@
<template>
<div class="relative">
<div>
<!-- <img :src="gr_bottom" alt="" class="absolute bottom-20">-->
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div class="bg-white w-full py-3 px-3 font-bold drop-shadow flex">
<div @click="router.back()" class="cursor-pointer">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2 items-center">اطلاعات حساب کاربری</div>
</div>
<div class="pt-16 pb-8 z-30 relative">
<div
class="border-gray-200 border-1 rounded-xl p-5 trans flex flex-col items-center mx-5 z-1000 relative bg-white shadow-lg"
>
<div class="font-bold w-full text-center flex justify-between">
نام و نام خانوادگی:
<div class="mr-2">{{ userStore.userInfo?.name }}</div>
</div>
<div class="font-bold w-full text-center flex justify-between">
شماره تلفن همراه:
<div class="mr-2">{{ userStore.userInfo?.mobile }}</div>
</div>
<div
v-if="userStore.userInfo?.referral_id"
class="font-bold w-full text-center flex justify-between gap-2 items-center"
>
کد معرف:
<div class="flex gap-2">
<div class="mr-2">{{ userStore.userInfo?.referral_id }}</div>
<!-- <BasicIcon icon="vuesax-linear:copy" color="#2563EB" class="cursor-pointer" size="18"
@click.stop="copyCode()" />
&lt;!&ndash; vuesax-linear:hierarchy-3&ndash;&gt;
<BasicIcon icon="vuesax-linear:share" color="#2563EB" class="cursor-pointer" size="18"
@click.stop="shareCode()" />-->
</div>
</div>
<div class="w-full flex gap-2 mt-1">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1"
icon-size="18"
@click.stop="shareCode()"
>
اشتراک گذاری
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1"
icon-size="18"
@click.stop="copyCode()"
>
رونوشت
</BasicButton>
</div>
<BasicButton
class="mt-5 w-full"
icon=""
iconSize="18"
size="large"
type="primary"
@click="editPassVisible = true"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:eye" class="absolute right-8" />
<div>ویرایش اطلاعات کاربری</div>
</div>
</BasicButton>
<div class="h-40vh overflow-y-auto mt-4">
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="editPasswordVisible = true"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:lock" class="absolute right-8" />
<div>تغییر رمز عبور</div>
</div>
</BasicButton>
<!-- <BasicButton
class="w-full mt-2"
size="large"
@click="$router.push({ name: 'address' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:map" class="absolute right-8" />
<div>آدرس ها</div>
</div>
</BasicButton>-->
<!-- <BasicButton-->
<!-- class="w-full mt-2"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'notifyTrigger' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:bill" class="absolute right-8" />-->
<!-- <div>هشدار قیمت</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'about' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:info-circle" class="absolute right-8" />
<div>درباره ما</div>
</div>
</BasicButton>
<!-- <BasicButton-->
<!-- class="mt-2 w-full"-->
<!-- icon=""-->
<!-- iconSize="18"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'physicalReceive' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:direct-inbox" class="absolute right-8" />-->
<!-- <div>دریافت طلای فیزیکی</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'rules' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:task" class="absolute right-8" />
<div>قوانین و مقررات</div>
</div>
</BasicButton>
<!-- <BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="$router.push({ name: 'permissions' })"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:archive-book" class="absolute right-8" />
<div>مجوز ها</div>
</div>
</BasicButton>-->
<!-- <BasicButton-->
<!-- class="mt-2 w-full"-->
<!-- icon=""-->
<!-- iconSize="18"-->
<!-- size="large"-->
<!-- @click="$router.push({ name: 'earnMoney' })"-->
<!-- >-->
<!-- <div class="flex">-->
<!-- <BasicIcon icon="vuesax-linear:dollar-circle" class="absolute right-8" />-->
<!-- <div>کسب درآمد</div>-->
<!-- </div>-->
<!-- </BasicButton>-->
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="showLogoutConfirm=!showLogoutConfirm"
>
<div class="flex">
<Icon
:color="y > 50 ? '' : ''"
class="color-transition absolute right-8"
icon="vuesax-linear:logout"
@click="showLogoutConfirm = true"
/>
<div>خروج از حساب کاربری</div>
</div>
</BasicButton>
</div>
</div>
</div>
<DialogVant v-model:show="showLogoutConfirm" :closeOnClickOverlay="false" :timeOut="-1">
<!-- <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80">
</template>-->
<template v-slot:title>
<div class="flex flex-col items-center">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
</template>
<template v-slot:button>
<div class="text-center text-red flex items-center justify-center" @click="logout">
<a-spin :spinning="logoutLoading" size="small"> خروج</a-spin>
</div>
<div class="text-center" @click="showLogoutConfirm = false">انصراف</div>
</template>
</DialogVant>
</div>
</template>
<script lang="ts" setup>
import img from '@/assets/img/user_ly1.svg'
import { loading, removeToken } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import DialogVant from '@/components/DialogVant.vue'
import { useWalletStore } from '@/store/walletStore'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useWindowScroll } from '@vueuse/core/index'
import { onMounted, ref } from 'vue'
import { useUserStore } from '@/store/userStore'
import { useUserPageContext } from '../composables/useUserPage'
const userStore = useUserStore()
const walletStore = useWalletStore()
const showLogoutConfirm = ref<boolean>()
const logoutLoading = ref<boolean>(false)
const { y } = useWindowScroll()
const { editPassVisible, editPasswordVisible, copyCode, shareCode } = useUserPageContext()
async function logout() {
logoutLoading.value = true
userStore.userInfo = undefined
walletStore.walletInfo = undefined
removeToken()
await router.push({ name: 'login' })
window.location.reload()
}
</script>
<style scoped>
/*.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
.midLevel {
color: orange;
}
.week,
.reject {
color: red;
}
.success {
color: green;
}
.background-image-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
z-index: 0;
}
.background-image-container img {
width: 100%;
height: auto;
}
.relative {
position: relative;
z-index: 40;
}
.relative {
overflow-y: auto;
max-height: 100vh;
//scroll-behavior: smooth;
}
.fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50; /* بالاتر از همه */
}
</style>

Some files were not shown because too many files have changed in this diff Show More