Merge feature/responsive-layout-refactor into modular for desktop layouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:02:11 +03:30
co-authored by Cursor
235 changed files with 18805 additions and 5910 deletions
@@ -0,0 +1,67 @@
<template>
<div
:class="variant === 'desktop'
? (isBoursecu ? 'dabout-content--boursecu' : 'dabout-content--default')
: (isBoursecuProject()
? 'mt-5 border-solid border-2 border-primary text-primary rounded-[12px] p-4 text-xs leading-5'
: 'bg-primary text-white rounded-[12px] p-4 text-xs leading-5')"
class="dabout-content"
>
<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>
<div
v-if="section.title"
:class="variant === 'desktop' ? 'dabout-content__title' : 'font-bold text-sm mb-2'"
>
{{ section.title }}
</div>
<div
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
:key="`paragraph-${sectionIndex}-${paragraphIndex}`"
:class="[
variant === 'desktop' ? 'dabout-content__paragraph' : '',
paragraphIndex > 0 ? 'mt-3' : '',
]"
>
{{ paragraph }}
</div>
<ul
v-if="(section.items ?? []).length"
:class="variant === 'desktop' ? 'dabout-content__list' : ''"
>
<li
v-for="(item, itemIndex) in section.items ?? []"
:key="`item-${sectionIndex}-${itemIndex}`"
:class="variant === 'desktop' ? 'dabout-content__list-item' : 'mt-2'"
>
<template v-if="variant !== 'desktop'">• {{ item }}</template>
<template v-else>{{ item }}</template>
</li>
</ul>
</template>
<div
v-if="variant !== 'desktop'"
class="w-full h-1px my-3 bg-tertiary"
></div>
</div>
</template>
<script lang="ts" setup>
import { useAboutPageContext } from '../composables/useAboutPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
aboutSections,
isBoursecu,
isBoursecuProject,
} = useAboutPageContext()
</script>
@@ -0,0 +1,41 @@
<template>
<div :class="variant === 'desktop' ? 'dabout-support' : 'wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white'">
<a class="w-full" :href="supportHref">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="baleHref" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" :class="variant === 'desktop' ? 'dabout-support__bale' : 'h-10 w-10'" />
</a>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
const props = withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
config,
getProjectSupportPhoneLink,
getProjectBaleLink,
} = useAboutPageContext()
const supportHref = computed(() =>
config.config.name === 'garoosi'
? 'tel:09031265925'
: getProjectSupportPhoneLink(),
)
const baleHref = computed(() =>
config.config.name === 'garoosi'
? 'https://ble.ir/garoosigallery'
: getProjectBaleLink(),
)
</script>
@@ -0,0 +1,48 @@
import { computed, inject, provide, type InjectionKey, type ComputedRef } from 'vue'
import { useConfigStore } from '@/store/configStore'
import {
getProjectBaleLink,
getProjectSupportPhoneLink,
isBoursecuProject,
} from '@/utils/config'
import { getAboutSections, type AboutSection } from '../aboutContent'
export interface AboutPageContext {
config: ReturnType<typeof useConfigStore>
aboutSections: ComputedRef<AboutSection[]>
isBoursecu: ComputedRef<boolean>
isBoursecuProject: typeof isBoursecuProject
getProjectSupportPhoneLink: typeof getProjectSupportPhoneLink
getProjectBaleLink: typeof getProjectBaleLink
}
export const ABOUT_PAGE_CONTEXT_KEY: InjectionKey<AboutPageContext> = Symbol('aboutPageContext')
export function useAboutPage() {
const config = useConfigStore()
const nameProject = config.config.name_fa
const isBoursecu = computed(() => isBoursecuProject())
const aboutSections = computed(() => getAboutSections(nameProject, isBoursecu.value))
const context: AboutPageContext = {
config,
aboutSections,
isBoursecu,
isBoursecuProject,
getProjectSupportPhoneLink,
getProjectBaleLink,
}
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 a page that calls useAboutPage()')
}
return context
}
@@ -0,0 +1,50 @@
<template>
<div class="dabout">
<div class="dabout__container">
<header class="dabout-topbar">
<button type="button" class="dabout-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="dabout-topbar__meta">
<p class="dabout-topbar__eyebrow">اطلاعات</p>
<h1 class="dabout-topbar__title">درباره ما</h1>
</div>
</header>
<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">{{ config.config.name_fa }}</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 router from '@/router'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
import { useAboutPageContext } from '../composables/useAboutPage'
const { config } = useAboutPageContext()
</script>
@@ -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 -164
View File
@@ -1,176 +1,24 @@
<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 pb-20">
<div class="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="w-full h-1px my-3 bg-tertiary"></div>
<div v-if="section.title" class="font-bold text-sm mb-2">
{{ section.title }}
</div>
<div
v-for="(paragraph, paragraphIndex) in section.paragraphs ?? []"
:key="`paragraph-${sectionIndex}-${paragraphIndex}`"
:class="paragraphIndex > 0 ? 'mt-3' : ''"
>
{{ paragraph }}
</div>
<div
v-for="(item, itemIndex) in section.items ?? []"
:key="`item-${sectionIndex}-${itemIndex}`"
class="mt-2"
>
• {{ item }}
</div>
</template>
<div class="w-full h-1px my-3 bg-tertiary"></div>
</div>
</div>
</div>
<div v-if="config.config.name === 'garoosi'" class="wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white">
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img src="@/assets/img/bale.png" alt="" class="h-10 w-10"/>
</a>
</div>
<div v-else class="wrap-fix bottom-0 px-10 pb-3rem flex gap-2 bg-white">
<a class="w-full" :href="getProjectSupportPhoneLink()">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a :href="getProjectBaleLink()" target="_blank" rel="noopener noreferrer">
<img src="@/assets/img/bale.png" alt="بله" class="h-10 w-10"/>
</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, computed } 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 {useConfigStore} from "@/store/configStore";
import {getProjectBaleLink, getProjectSupportPhoneLink, getReferralShareText, getReferralShareTitle, isBoursecuProject} from "@/utils/config";
import { getAboutSections } from './aboutContent'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAboutPage } from './composables/useAboutPage'
import DesktopAboutView from './desktop/DesktopAboutView.vue'
import MobileAboutView from './mobile/MobileAboutView.vue'
const config=useConfigStore()
const isDesktop = useDesktopBreakpoint()
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 nameProject= useConfigStore().config.name_fa
const aboutSections = computed(() => getAboutSections(nameProject, isBoursecuProject()))
// 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() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val
})
}
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
}
})
}
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,31 @@
<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">
<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="overflow-y-auto h-65vh pb-20">
<AboutSectionsContent variant="mobile" />
</div>
</div>
<AboutSupportActions variant="mobile" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import AboutSectionsContent from '../components/AboutSectionsContent.vue'
import AboutSupportActions from '../components/AboutSupportActions.vue'
</script>
@@ -0,0 +1,55 @@
<template>
<div
:class="variant === 'desktop'
? 'daboutus-content'
: 'mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4'"
>
<img
v-if="variant !== 'desktop'"
:src="config.config?.avatar"
class="mb-2 mt-5 w-10rem"
style="justify-self: center"
alt=""
/>
<div :class="variant === 'desktop' ? 'daboutus-content__body' : 'text-sm leading-7 space-y-2'">
<h2 :class="variant === 'desktop' ? 'daboutus-content__title' : 'text-base font-bold mb-3'">درباره ما</h2>
<div :class="variant === 'desktop' ? 'daboutus-content__paragraph' : 'mt-3'">
گروسی گالری بستری امن و آنلاین برای خرید و فروش طلای آبشده فراهم کرده تا کاربران بتوانند سرمایه‌های خرد خود را به راحتی پس‌انداز و مدیریت کنند.
</div>
<div :class="variant === 'desktop' ? 'daboutus-content__paragraph' : 'mt-3'">
هدف ما ایجاد تجربه‌ای سریع، شفاف و کاربرپسند در معاملات طلای آبشده است.
</div>
</div>
<div :class="variant === 'desktop' ? 'daboutus-content__enamad' : ''" style="justify-self: center">
<a
referrerpolicy="origin"
target="_blank"
href="https://trustseal.enamad.ir/?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
>
<img
referrerpolicy="origin"
src="https://trustseal.enamad.ir/logo.aspx?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
alt="enamad"
style="cursor:pointer"
code="DdltBYWJgvc7LJs9eM46SEliXRRU0kSW"
/>
</a>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAboutUsPageContext } from '../composables/useAboutUsPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useAboutUsPageContext()
</script>
@@ -0,0 +1,27 @@
import { inject, provide, type InjectionKey } from 'vue'
import { useConfigStore } from '@/store/configStore'
export interface AboutUsPageContext {
config: ReturnType<typeof useConfigStore>
}
export const ABOUT_US_PAGE_CONTEXT_KEY: InjectionKey<AboutUsPageContext> = Symbol('aboutUsPageContext')
export function useAboutUsPage() {
const config = useConfigStore()
const context: AboutUsPageContext = {
config,
}
provide(ABOUT_US_PAGE_CONTEXT_KEY, context)
return context
}
export function useAboutUsPageContext() {
const context = inject(ABOUT_US_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAboutUsPageContext must be used within a page that calls useAboutUsPage()')
}
return context
}
@@ -0,0 +1,41 @@
<template>
<div class="daboutus">
<div class="daboutus__container">
<header class="daboutus-topbar">
<button type="button" class="daboutus-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="daboutus-topbar__meta">
<p class="daboutus-topbar__eyebrow">اطلاعات</p>
<h1 class="daboutus-topbar__title">درباره ما</h1>
</div>
</header>
<section class="daboutus-surface daboutus-main">
<div class="daboutus-main__intro">
<div class="daboutus-main__logo">
<img :src="config.config?.avatar" alt="" class="daboutus-main__avatar" />
</div>
<div>
<h2 class="daboutus-main__project">{{ config.config.name_fa }}</h2>
<p class="daboutus-main__tagline">آشنایی با خدمات و چشم‌انداز ما</p>
</div>
</div>
<div class="daboutus-main__scroll">
<AboutUsContentPanel variant="desktop" />
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import AboutUsContentPanel from '../components/AboutUsContentPanel.vue'
import { useAboutUsPageContext } from '../composables/useAboutUsPage'
const { config } = useAboutUsPageContext()
</script>
@@ -0,0 +1,168 @@
/* Premium desktop about-us page */
.daboutus {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.daboutus__container {
max-width: 860px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.daboutus-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;
}
}
.daboutus-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);
}
.daboutus-main {
padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 180px);
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
flex-shrink: 0;
}
&__logo {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: rgba(9, 61, 81, 0.08);
flex-shrink: 0;
overflow: hidden;
}
&__avatar {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
&__project {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 4px;
}
&__tagline {
font-size: 13px;
color: #6b7280;
margin: 0;
}
&__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-left: 4px;
margin-left: -4px;
}
}
.daboutus-content {
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;
&__title {
font-size: 16px;
font-weight: 700;
color: inherit;
margin: 0 0 12px;
}
&__paragraph {
font-size: 14px;
line-height: 1.9;
color: inherit;
text-align: justify;
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
&__enamad {
display: flex;
justify-content: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(9, 61, 81, 0.12);
}
}
@media (max-width: 1023.98px) {
.daboutus-main {
max-height: none;
}
}
+13 -53
View File
@@ -1,64 +1,24 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black 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="config.config?.avatar" class="mb-2 mt-5 w-10rem" style="justify-self: center"/>
<div class="overflow-y-auto h-65vh pb-20 border-[#093D51]">
<div
class="mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4"
>
<div class="text-sm leading-7 space-y-2">
<h2 class="text-base font-bold mb-3">درباره ما</h2>
<div class="mt-3"> گروسی گالری بستری امن و آنلاین برای خرید و فروش طلای آبشده فراهم کرده تا کاربران بتوانند سرمایه‌های خرد خود را به راحتی پس‌انداز و مدیریت کنند. </div>
<div class="mt-3"> هدف ما ایجاد تجربه‌ای سریع، شفاف و کاربرپسند در معاملات طلای آبشده است. </div>
<!-- <div class="mt-4 border-t pt-3">
<h3 class="font-semibold mb-2">اطلاعات تماس</h3>
<div class="space-y-1">
<div> <span class="font-medium">ایمیل:</span> <a href="mailto:hajiabolfath@gmail.com" class="text-blue-600">hajiabolfath@gmail.com</a> </div>
<div> <span class="font-medium">کد پستی:</span> ۳۷۱۳۷۶۴۸۹۰ </div>
<div> <span class="font-medium">آدرس:</span> استان قم، شهرستان قم، بخش مرکزی، شهر قم، محله ارم (بازار)، کوچه مرعشی نجفی ۸، خیابان آیت‌الله مرعشی نجفی (ارم)، تجاری ارم، طبقه ۱، واحد ۱۵۵</div>
</div>
</div>-->
</div>
<div style="justify-self: center">
<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'>
<img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=710578&Code=DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'
alt='enamad'
style='cursor:pointer'
code='DdltBYWJgvc7LJs9eM46SEliXRRU0kSW'
>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="about-us-page-root">
<DesktopAboutUsView v-show="isDesktop" />
<MobileAboutUsView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import {useConfigStore} from "@/store/configStore";
import img from '@/assets/img/user_ly1.svg'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAboutUsPage } from './composables/useAboutUsPage'
import DesktopAboutUsView from './desktop/DesktopAboutUsView.vue'
import MobileAboutUsView from './mobile/MobileAboutUsView.vue'
const config = useConfigStore()
const isDesktop = useDesktopBreakpoint()
useAboutUsPage()
</script>
<style scoped>
.about-us-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,29 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black 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">
<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="overflow-y-auto h-65vh pb-20 border-[#093D51]">
<AboutUsContentPanel variant="mobile" />
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import AboutUsContentPanel from '../components/AboutUsContentPanel.vue'
</script>
@@ -0,0 +1,101 @@
<template>
<a-form
ref="formRef"
:model="address"
:class="variant === 'desktop' ? 'daddr-form' : '!mt-5'"
name="address-form"
>
<div :class="variant === 'desktop' ? 'daddr-form__fields' : 'flex flex-col items-center'">
<div
v-if="variant !== 'desktop'"
class="font-bold text-lg mb-2"
>
آدرس
</div>
<div
:class="variant === 'desktop'
? 'daddr-form__grid'
: 'flex flex-col gap-4 pb-20 w-full h-60vh overflow-y-scroll'"
>
<a-form-item label="استان" name="province" :rules="[required()]">
<BasicSelect
v-model:value="address.province"
:items="provinceList"
value-name="title"
key-name="title"
:size="variant === 'desktop' ? 'large' : undefined"
@change="changeProvince"
/>
</a-form-item>
<a-form-item label="شهر" name="city" :rules="[required()]">
<BasicSelect
v-model:value="address.city"
:disabled="!cityList"
value-name="title"
:items="cityList"
key-name="title"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item label="کد پستی" name="zip" :rules="[required()]">
<BasicInput
v-model:value="address.zip"
placeholder="کد پستی را وارد نمایید"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item label="تلفن تماس" name="phone" :rules="[required()]">
<BasicInput
v-model:value="address.phone"
placeholder="تلفن تماس را وارد نمایید"
:size="variant === 'desktop' ? 'large' : undefined"
/>
</a-form-item>
<a-form-item
label="آدرس کامل"
name="address"
:rules="[required()]"
:class="variant === 'desktop' ? 'daddr-form__address-item' : ''"
>
<BasicTextArea
v-model:value="address.address"
placeholder="آدرس کامل را وارد نمایید"
/>
</a-form-item>
<BasicButton
:class="variant === 'desktop' ? 'daddr-form__submit' : '!text-white px-3 pb-4rem'"
type="primary"
@click="handleOk"
>
{{ editId ? 'ویرایش آدرس' : 'ثبت آدرس' }}
</BasicButton>
</div>
</div>
</a-form>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import BasicTextArea from '@/components/BasicPacks/BasicTextArea.vue'
import { required } from '@/utils/inputRules'
import { useAddressPageContext } from '../composables/useAddressPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
address,
formRef,
editId,
provinceList,
cityList,
changeProvince,
handleOk,
} = useAddressPageContext()
</script>
@@ -0,0 +1,94 @@
<template>
<div class="address-mobile-item shadow mb-2 p-2 rounded-lg w-full">
<div class="flex justify-between">
<div>
<div class="font-semibold text-base mb-2">{{ item.address }}</div>
</div>
</div>
<div class="flex flex-col gap-2 mt-2">
<BasicInfoItem is-line title="استان" :value="item.province" />
<BasicInfoItem is-line title="شهر" :value="item.city" />
<BasicInfoItem is-line title="کد پستی" :value="item.zip" />
<BasicInfoItem is-line title="شماره تلفن" :value="item.phone" />
</div>
<a-divider class="!my-3" />
<div class="flex justify-between mt-1">
<div class="mb-2">{{ formatAddressDate(item.created_at) }}</div>
<div class="flex gap-2">
<BasicIcon
color="red"
icon="vuesax-linear:trash"
class="p-1"
@click.stop="deleteConfirmVisible = true"
/>
<BasicIcon
color="green"
icon="vuesax-linear:edit"
class="p-1"
@click.stop="emits('edit', item)"
/>
</div>
</div>
<DialogVant
v-model:show="deleteConfirmVisible"
teleport="body"
:close-on-click-overlay="false"
:time-out="-1"
>
<template #title>
<div class="flex flex-col items-center">
<div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
</div>
</template>
<template #button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template>
</DialogVant>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { toJalali } from '@/utils/useDateTime'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue'
import type { Address } from '@/models/address'
import { deleteAddress } from '@/api/address'
const deleteLoading = ref(false)
const deleteConfirmVisible = ref(false)
const props = defineProps<{
item: Address
}>()
const emits = defineEmits<{
edit: [item: Address]
refresh: []
}>()
function formatAddressDate(dateTime?: string) {
if (!dateTime) return ''
const formatted = toJalali(dateTime)
return formatted.includes('Invalid') ? '' : formatted
}
async function deleteItem() {
try {
deleteLoading.value = true
await deleteAddress(props.item.id!)
deleteConfirmVisible.value = false
emits('refresh')
} catch (e) {
console.log(e)
} finally {
deleteLoading.value = false
}
}
</script>
+113 -108
View File
@@ -1,112 +1,155 @@
<template>
<div @click="infoVisible = true" class="shadow mb-2 p-2 rounded-lg">
<div v-if="variant === 'desktop'" class="daddr-card">
<p class="daddr-card__address">{{ item.address }}</p>
<div class="daddr-card__meta">
<div class="daddr-card__row">
<span class="daddr-card__label">استان:</span>
<span class="daddr-card__value">{{ item.province }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">شهر:</span>
<span class="daddr-card__value">{{ item.city }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">کد پستی:</span>
<span class="daddr-card__value">{{ item.zip }}</span>
</div>
<div class="daddr-card__row">
<span class="daddr-card__label">شماره تلفن:</span>
<span class="daddr-card__value">{{ item.phone }}</span>
</div>
</div>
<div class="daddr-card__actions">
<button
type="button"
class="daddr-card__icon-btn daddr-card__icon-btn--edit"
@click.stop="emits('edit', item)"
>
<BasicIcon color="green" icon="vuesax-linear:edit" size="18" />
</button>
<button
type="button"
class="daddr-card__icon-btn daddr-card__icon-btn--danger"
@click.stop="deleteConfirmVisible = true"
>
<BasicIcon color="red" icon="vuesax-linear:trash" size="18" />
</button>
</div>
<div class="daddr-card__footer">
<span class="daddr-card__date">{{ formatAddressDate(item.created_at) }}</span>
</div>
<DialogVant
v-model:show="deleteConfirmVisible"
:close-on-click-overlay="false"
:time-out="-1"
teleport="body"
>
<template #title>
<div class="flex flex-col items-center">
<div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
</div>
</template>
<template #button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template>
</DialogVant>
</div>
<div v-else class="shadow mb-2 p-2 rounded-lg">
<div class="flex justify-between">
<div>
<div class="font-semibold text-base mb-2">{{item.address }}</div>
<div class="font-semibold text-base mb-2">{{ item.address }}</div>
</div>
</div>
<div class="flex flex-col gap-2 mt-2">
<BasicInfoItem is-line title="استان" :value="item.province"/>
<BasicInfoItem is-line title="شهر" :value="item.city"/>
<BasicInfoItem is-line title="کد پستی" :value="item.zip"/>
<BasicInfoItem is-line title="شماره تلفن" :value="item.phone"/>
<BasicInfoItem is-line title="استان" :value="item.province" />
<BasicInfoItem is-line title="شهر" :value="item.city" />
<BasicInfoItem is-line title="کد پستی" :value="item.zip" />
<BasicInfoItem is-line title="شماره تلفن" :value="item.phone" />
</div>
<a-divider class="!my-3" />
<div class="flex justify-between mt-1">
<div class="mb-2">{{toJalali(item.created_at)}}</div>
<div class="flex gap-2 ">
<BasicIcon color="red" icon="vuesax-linear:trash" @click.stop="deleteConfirmVisible = true" class="p-1"/>
<BasicIcon color="green" icon="vuesax-linear:edit" @click.stop="emits('edit',item)" class="p-1"/>
<div class="mb-2">{{ formatAddressDate(item.created_at) }}</div>
<div class="flex gap-2">
<BasicIcon
color="red"
icon="vuesax-linear:trash"
class="p-1"
@click.stop="deleteConfirmVisible = true"
/>
<BasicIcon
color="green"
icon="vuesax-linear:edit"
class="p-1"
@click.stop="emits('edit', item)"
/>
</div>
</div>
</div>
<!-- <teleport to="app">-->
<DialogVant v-model:show="deleteConfirmVisible" teleport="body" :closeOnClickOverlay="false" :timeOut="-1">
<!-- <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80">
</template>-->
<template v-slot:title>
<DialogVant
v-model:show="deleteConfirmVisible"
teleport="body"
:close-on-click-overlay="false"
:time-out="-1"
>
<template #title>
<div class="flex flex-col items-center">
<div class="p-5 text-sm">
آیا از حذف آدرس اطمینان دارید؟
</div>
<div class="p-5 text-sm">آیا از حذف آدرس اطمینان دارید؟</div>
</div>
</template>
<template v-slot:button>
<template #button>
<div class="text-center text-red flex items-center justify-center" @click="deleteItem">
<a-spin :spinning="deleteLoading" size="small">
تایید
</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">
انصراف
<a-spin :spinning="deleteLoading" size="small">تایید</a-spin>
</div>
<div class="text-center" @click="deleteConfirmVisible = false">انصراف</div>
</template>
</DialogVant>
<!-- </teleport>-->
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { toJalali } from '../../../../utils/useDateTime'
import { ref } from 'vue'
import { toJalali } from '@/utils/useDateTime'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import DialogVant from '@/components/DialogVant.vue'
import { updatePriceTrigger } from '@/api/triggerPrice'
import { loading } from '@/utils'
import type { Address } from '@/models/address'
import { deleteAddress } from '@/api/address'
const infoVisible = ref<boolean>(false)
const isActive = ref<boolean>(false)
const deleteLoading = ref(false)
const deleteConfirmVisible = ref(false)
interface Prop {
item: Address
}
function changeActive(val: boolean) {
console.log(val)
try {
loading.value = true
updatePriceTrigger(props?.item.id, {is_active: val})
} catch (e) {
console.log(e)
isActive.value = !isActive.value
} finally {
loading.value = false
}
}
const props = withDefaults(
defineProps<{
item: Address
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
function returnPriceCondition(val: string) {
switch (val) {
case '==': return 'مساوی'
case '<': return 'کوچکتر'
// case '==': return 'مساوی'
}
/* if (val === '=='){
return 'مساوی'
} else if (val === '<'){
return 'کوچکتر'
}*/
const emits = defineEmits<{
edit: [item: Address]
refresh: []
}>()
function formatAddressDate(dateTime?: string) {
if (!dateTime) return ''
const formatted = toJalali(dateTime)
return formatted.includes('Invalid') ? '' : formatted
}
async function deleteItem() {
try {
deleteLoading.value = true
await deleteAddress(props.item.id)
await deleteAddress(props.item.id!)
deleteConfirmVisible.value = false
emits('refresh')
} catch (e) {
@@ -115,42 +158,4 @@ async function deleteItem() {
deleteLoading.value = false
}
}
function returnAction(val: string) {
switch (val) {
case 'sms': return 'پیامک'
case 'email': return 'ایمیل'
case 'notify': return 'نوتیفیکیشن'
// case '==': return 'مساوی'
}
/* if (val === '=='){
return 'مساوی'
} else if (val === '<'){
return 'کوچکتر'
}*/
}
const props = withDefaults(defineProps<Prop>(), {})
const emits = defineEmits(['edit','refresh'])
/*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 @@
import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
import { cloneDeep } from 'lodash'
import { type FormInstance, message } from 'ant-design-vue'
import { createAddress, getCity, updateAddress } from '@/api/address'
import type { Address } from '@/models/address'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { loading, loadingMore } from '@/utils'
import { isBazretalaProject } from '@/utils/config'
import {
addressItems,
addressProvinceList,
initAddressPageData,
refreshAddressItems,
} from './useAddressPageData'
export interface AddressPageContext {
provinceList: typeof addressProvinceList
cityList: Ref<any[] | undefined>
items: typeof addressItems
addAddressVisible: Ref<boolean>
editId: Ref<number | undefined>
address: Ref<Address>
formRef: Ref<FormInstance | undefined>
loading: typeof loading
loadingMore: typeof loadingMore
isBazretalaProject: typeof isBazretalaProject
resetFields: () => void
openAdd: () => void
openEdit: (val: Address) => void
handleOk: (e?: MouseEvent) => void
initItems: () => Promise<void>
changeProvince: (obj: { id: number }) => Promise<void>
}
export const ADDRESS_PAGE_CONTEXT_KEY: InjectionKey<AddressPageContext> =
Symbol('addressPageContext')
const emptyAddress = (): Address => ({
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false,
})
export function useAddressPage() {
const isDesktop = useDesktopBreakpoint()
const cityList = ref<any[]>()
const addAddressVisible = ref(false)
const editId = ref<number | undefined>(undefined)
const formRef = ref<FormInstance>()
const address = ref<Address>(emptyAddress())
function resetFields() {
editId.value = undefined
address.value = emptyAddress()
cityList.value = undefined
}
function openAdd() {
resetFields()
editId.value = undefined
addAddressVisible.value = !isDesktop.value
}
async function openEdit(val: Address) {
address.value = cloneDeep(val)
editId.value = address.value.id
if (address.value.province) {
const province = addressProvinceList.value.find((item) => item.title === address.value.province)
if (province?.id) {
cityList.value = await getCity(province.id)
}
}
addAddressVisible.value = true
}
const handleOk = (_e?: MouseEvent) => {
formRef.value?.validate().then(async () => {
if (editId.value) {
await updateAddress(editId.value, address.value)
} else {
await createAddress(address.value)
}
await initItems()
addAddressVisible.value = false
message.success('آدرس با موفقیت ثبت شد')
resetFields()
}).catch((error) => {
console.log('error', error)
})
}
async function initItems() {
await refreshAddressItems()
}
const changeProvince = async (obj: { id: number }) => {
try {
loading.value = true
cityList.value = undefined
address.value.city = undefined
cityList.value = await getCity(obj.id)
} catch (e) {
message.error('خطا در دریافت شهرها')
} finally {
loading.value = false
}
}
onMounted(async () => {
await initAddressPageData()
})
const context: AddressPageContext = {
provinceList: addressProvinceList,
cityList,
items: addressItems,
addAddressVisible,
editId,
address,
formRef,
loading,
loadingMore,
isBazretalaProject,
resetFields,
openAdd,
openEdit,
handleOk,
initItems,
changeProvince,
}
provide(ADDRESS_PAGE_CONTEXT_KEY, context)
return context
}
export function useAddressPageContext() {
const context = inject(ADDRESS_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useAddressPageContext must be used within a page that calls useAddressPage()')
}
return context
}
@@ -0,0 +1,82 @@
import { ref } from 'vue'
import { getProvince, tableAddress } from '@/api/address'
import type { Address } from '@/models/address'
import { loading, loadingMore } from '@/utils'
import { runOnceAsync, resetOnceAsyncByPrefix } from '@/composables/useOnceAsync'
/**
* Shared address page datasets — mobile and desktop views read this module state only.
*/
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export const addressItems = ref<Address[]>([])
export const addressProvinceList = ref<any[]>([])
export function isAddressPageDataInitialized() {
return initialized.value
}
export async function refreshAddressItems() {
try {
loading.value = true
const data = await tableAddress({ page: 0, limit: 150 })
addressItems.value = data.rows
} catch (error) {
console.error('Address list refresh error:', error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadAddressItems() {
const data = await tableAddress({ page: 0, limit: 150 })
addressItems.value = data.rows
}
async function loadProvinces() {
addressProvinceList.value = await getProvince()
}
export async function initAddressPageData(force = false) {
if (!force && initialized.value) {
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
if (force) {
resetAddressPageData()
}
loading.value = true
await Promise.all([
runOnceAsync('address:items', () => loadAddressItems(), force),
runOnceAsync('address:provinces', () => loadProvinces(), force),
])
initialized.value = true
} catch (error) {
console.error('Address page initialization error:', error)
} finally {
loading.value = false
initPromise = null
}
})()
return initPromise
}
export function resetAddressPageData() {
initialized.value = false
initPromise = null
resetOnceAsyncByPrefix('address:')
addressItems.value = []
addressProvinceList.value = []
}
@@ -0,0 +1,86 @@
<template>
<div class="daddr">
<div class="daddr__container">
<header class="daddr-topbar">
<p class="daddr-topbar__eyebrow">حساب کاربری</p>
<h1 class="daddr-topbar__title">آدرس‌ها</h1>
<p class="daddr-topbar__subtitle">آدرس‌های تحویل سفارش خود را مدیریت کنید</p>
</header>
<div
v-if="isBazretalaProject()"
class="daddr-notice"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<div class="daddr-grid">
<section class="daddr-surface daddr-form">
<div class="daddr-form__header">
<h2 class="daddr-form__title">{{ editId ? 'ویرایش آدرس' : 'ثبت آدرس جدید' }}</h2>
<button
v-if="editId"
type="button"
class="daddr-form__reset"
@click="openAdd()"
>
آدرس جدید
</button>
</div>
<AddressForm variant="desktop" />
</section>
<section class="daddr-surface daddr-list">
<div class="daddr-list__header">
<h3 class="daddr-list__title">آدرس‌های ثبت‌شده</h3>
<span class="daddr-list__count">{{ items.length }} مورد</span>
</div>
<div v-if="loading" class="daddr-list__skeletons">
<TransactionSkeleton v-for="i in 6" :key="i" class="mt-1" />
</div>
<div v-else-if="items.length" class="daddr-list__items">
<list-anim>
<Item
v-for="item in items"
:key="`desktop-${item.id}`"
:item="item"
variant="desktop"
@edit="openEdit"
@refresh="initItems"
/>
</list-anim>
</div>
<div v-else class="daddr-empty">
<BasicIcon icon="vuesax-linear:location" size="36" />
<p>هنوز آدرسی ثبت نشده است.</p>
<button type="button" class="daddr-empty__btn" @click="openAdd()">
افزودن اولین آدرس
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import AddressForm from '../components/AddressForm.vue'
import Item from '../components/Item.vue'
import { useAddressPageContext } from '../composables/useAddressPage'
const {
items,
loading,
editId,
isBazretalaProject,
openAdd,
openEdit,
initItems,
} = useAddressPageContext()
</script>
@@ -0,0 +1,307 @@
/* Premium desktop address page — scoped to desktop shell only */
html.app-layout--desktop {
.daddr {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.daddr__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.daddr-topbar {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__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 0 6px;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
max-width: 520px;
line-height: 1.6;
}
}
.daddr-notice {
margin-bottom: 20px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.15);
background: rgba(59, 130, 246, 0.06);
color: @App-primary-color;
font-size: 13px;
line-height: 1.8;
text-align: justify;
}
.daddr-grid {
display: grid;
grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.daddr-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);
}
.daddr-form {
padding: 24px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
&__reset {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #6b7280;
font-size: 12px;
font-weight: 600;
font-family: inherit;
padding: 6px 10px;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
&:hover {
border-color: @App-primary-color;
color: @App-primary-color;
}
}
&__fields {
width: 100%;
}
&__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
&__address-item {
margin-bottom: 8px !important;
}
&__submit {
width: 100%;
margin-top: 8px;
}
}
.daddr-list {
display: flex;
flex-direction: column;
min-height: 420px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 24px 0;
}
&__title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
&__count {
font-size: 12px;
font-weight: 600;
color: #6b7280;
background: #f3f4f6;
border-radius: 999px;
padding: 4px 10px;
}
&__items {
padding: 16px 24px 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
&__skeletons {
padding: 16px 24px 24px;
}
}
.daddr-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px 16px;
padding: 16px 18px;
border: 1px solid #eef0f3;
border-radius: 12px;
background: #fafbfc;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
border-color: #dbe0e6;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
&__address {
grid-column: 1 / -1;
font-size: 16px;
font-weight: 700;
color: #111827;
margin: 0;
line-height: 1.5;
}
&__meta {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
&__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
&__label {
color: #9ca3af;
flex-shrink: 0;
}
&__value {
color: #374151;
font-weight: 600;
}
&__actions {
display: flex;
align-items: flex-start;
gap: 4px;
}
&__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
background: #f9fafb;
}
&--danger:hover {
border-color: #fecaca;
background: #fef2f2;
}
&--edit:hover {
border-color: #bbf7d0;
background: #f0fdf4;
}
}
&__footer {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
border-top: 1px solid #eef0f3;
}
&__date {
font-size: 12px;
color: #9ca3af;
}
}
.daddr-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #6b7280;
text-align: center;
p {
margin: 0;
font-size: 14px;
}
&__btn {
margin-top: 4px;
border: none;
border-radius: 10px;
background: @App-primary-color;
color: #fff;
font-size: 13px;
font-weight: 600;
font-family: inherit;
padding: 10px 16px;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.92;
}
}
}
@media (max-width: 1023.98px) {
.daddr-grid {
grid-template-columns: 1fr;
}
}
}
+29 -246
View File
@@ -1,257 +1,40 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-primary font-bold drop-shadow wrap-fix flex z-10">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آدرس ها</div>
</div>
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<!-- <a-spin :spinning="loading">-->
<div ref="el" :class="loading? 'h-screen': ''" class="px-3 pt-16 pb-2 overflow-y-scroll"
style="max-height: calc(100vh - 50px)">
<div
v-if="isBazretalaProject()"
class="text-justify text-sm text-primary mb-4 px-1 leading-7"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<list-anim v-if="!loading" class="pb-[calc(var(--app-bottom-chrome-height)+var(--app-add-card-height)+1rem)]">
<Item v-for="item in items" :key="item.id" :item="item" @edit="openEdit" @refresh="initItems" />
<!-- <TransactionSkeleton v-if="loadingMore" class="mt-1 mb-8" />-->
</list-anim>
<div v-if="loading">
<TransactionSkeleton v-for="i in 13" :key="i" class="mt-1" />
</div>
</div>
<!-- </a-spin>-->
<BasicActionSheet v-model:show="addAddressVisible" class="h-80vh" >
<a-form ref="formRef" :model="address" class="!mt-5" name="basic"
@finish="ok()">
<div class="flex flex-col items-center">
<div class="font-bold text-lg mb-2">آدرس</div>
<div class="flex flex-col gap-4 pb-20 w-full h-60vh overflow-y-scroll">
<a-form-item label="استان" name="province" :rules="[required()]">
<BasicSelect
v-model:value="address.province"
:items="provinceList"
@change="changeProvince"
valueName="title"
keyName="title"
/>
</a-form-item>
<a-form-item label="شهر" name="city" :rules="[required()]">
<BasicSelect
:disabled="!cityList"
v-model:value="address.city"
valueName="title"
:items="cityList"
keyName="title"
/>
</a-form-item>
<a-form-item
label="کد پستی" name="zip" :rules="[required()]">
<BasicInput placeholder="کد پستی را وارد نمایید" v-model:value="address.zip" />
</a-form-item>
<a-form-item
label="تلفن تماس" name="phone" :rules="[required()]"
>
<BasicInput placeholder="تلفن تماس را وارد نمایید" v-model:value="address.phone" />
</a-form-item>
<!-- <FormItem label="کد پستی" name="">
<Input placeholder="کد پستی را وارد نمایید" />
</FormItem>-->
<a-form-item
label="آدرس کامل"
name="address"
:rules="[required()]"
>
<BasicTextArea placeholder="آدرس کامل را وارد نمایید" v-model:value="address.address" />
</a-form-item>
<!-- <div class=" w-full pb-5 !text-white flex justify-center items-center">-->
<BasicButton class=" !text-white px-3 pb-4rem" type="primary" @click="handleOk">ثبت آدرس
</BasicButton>
</div>
<!-- </div>-->
</div>
</a-form>
<div v-if="!isDesktop">
<MobileAddressView />
<BasicActionSheet v-model:show="addAddressVisible" class="h-80vh">
<AddressForm />
</BasicActionSheet>
<!-- <div class="bg-primary w-3rem h-3rem flex items-center justify-center rounded-lg fixed bottom-12vh left-8vw"
@click="openAdd()">
<BasicIcon icon="vuesax-linear:add" color="white" />
</div>-->
<Teleport to="body">
<div
v-if="!addAddressVisible"
class="add-address app-dock-add-bar"
@click="openAdd()"
>
<div>افزودن آدرس جدید</div>
<BasicIcon icon="vuesax-linear:add" />
</div>
</Teleport>
</div>
<template v-else>
<DesktopAddressView />
<BasicModal
v-model:visible="addAddressVisible"
:title="editId ? 'ویرایش آدرس' : 'ثبت آدرس جدید'"
width="520px"
destroy-on-close
custom-footer
>
<AddressForm variant="desktop" />
<template #footer>
<div></div>
</template>
</BasicModal>
</template>
</template>
<script lang="ts" setup>
import { createPriceTrigger, getPriceTriggers, updatePriceTrigger } from '@/api/triggerPrice'
const radioStyle = reactive({
display: '',
height: '30px',
lineHeight: '30px'
})
import type { SelectItem } from '@/models/SelectItem'
// 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, reactive, 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, useScroll } from '@vueuse/core'
import ListAnim from '@/components/ListAnim.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import { mobileValidation, required } from '@/utils/inputRules'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import type { User } from '@/models/login'
import { type FormInstance, message } from 'ant-design-vue'
import type { PriceTrigger } from '@/models/PriceTrigger'
import BasicSelect from '@/components/BasicPacks/BasicSelect.vue'
import Item from './components/Item.vue'
import { cloneDeep } from 'lodash'
import router from '@/router'
import type { Address } from '@/models/address'
import { createAddress, getCity, getProvince, tableAddress, updateAddress } from '@/api/address'
import BasicTextArea from '@/components/BasicPacks/BasicTextArea.vue'
import img from "@/assets/img/user_ly1.svg";
import { isBazretalaProject } from '@/utils/config'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useAddressPage } from './composables/useAddressPage'
import AddressForm from './components/AddressForm.vue'
import DesktopAddressView from './desktop/DesktopAddressView.vue'
import MobileAddressView from './mobile/MobileAddressView.vue'
const provinceList = ref<any[]>([])
const cityList = ref<any[]>()
const items = ref<PriceTrigger[]>([])
const addAddressVisible = ref<boolean>(false)
const editId = ref<number | undefined>(undefined)
const el = ref()
const address = ref<Address>({
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false
})
const formRef = ref<FormInstance>()
onMounted(async () => {
await initItems()
provinceList.value = await getProvince()
})
function resetFields() {
editId.value = undefined
address.value = {
city: '',
province: '',
phone: '',
address: '',
zip: '',
default_address: false
}
}
function openAdd() {
resetFields()
editId.value = undefined
addAddressVisible.value = true
}
const handleOk = (e: MouseEvent) => {
formRef.value?.validate().then(async () => {
editId.value ? await updateAddress(editId.value, address.value) : await createAddress(address.value)
await initItems()
addAddressVisible.value = false
message.success('آدرس با موفقیت ثبت شد')
resetFields()
})
.catch((error) => {
console.log('error', error)
})
}
async function initItems() {
try {
items.value = []
const data = await tableAddress({ page: 0, limit: 150 })
items.value = data.rows
// console.log(items.value)
} catch (e) {
console.log(e)
} finally {
loading.value = false
loadingMore.value = false
}
}
const changeProvince = async (obj: any) => {
try {
loading.value = true
cityList.value = undefined
address.value.city = undefined
cityList.value = await getCity(obj.id)
} catch (e) {
message.error('خطا در دریافت شهرها')
} finally {
loading.value = false
}
}
function openEdit(val: PriceTrigger) {
address.value = cloneDeep(val)
editId.value = address.value.id
addAddressVisible.value = true
}
async function ok() {
formRef.value?.validateFields().then(async () => {
try {
loading.value = true
editId.value ? await updatePriceTrigger(editId.value, address.value) : await createPriceTrigger(address.value)
message.success('آدرس با موفقیت ایجاد شد')
await initItems()
addAddressVisible.value = false
//
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
const isDesktop = useDesktopBreakpoint()
const { addAddressVisible, editId } = useAddressPage()
</script>
<style scoped>
</style>
@@ -0,0 +1,72 @@
<template>
<div>
<div class="bg-white py-3 px-3 text-primary font-bold drop-shadow wrap-fix flex z-10">
<div @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">آدرس ها</div>
</div>
<div>
<img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />
</div>
<div
:class="loading ? 'h-screen' : ''"
class="px-3 pt-16 pb-2 overflow-y-scroll"
style="max-height: calc(100vh - 50px)"
>
<div
v-if="isBazretalaProject()"
class="text-justify text-sm text-primary mb-4 px-1 leading-7"
>
جهت تحویل فیزیکی محصول در تهران و قم ازطریق معرف اقدام فرمائید یا با شماره امورمالی و حسابداری 09925202099 درارتباط باشید.
</div>
<div
v-if="!loading"
class="pb-[calc(var(--app-bottom-chrome-height)+var(--app-add-card-height)+1rem)]"
>
<Item
v-for="item in items"
:key="item.id"
:item="item"
@edit="openEdit"
@refresh="initItems"
/>
</div>
<div v-if="loading">
<TransactionSkeleton v-for="i in 13" :key="i" class="mt-1" />
</div>
</div>
<Teleport to="body">
<div
v-if="!addAddressVisible"
class="add-address app-dock-add-bar"
@click="openAdd()"
>
<div>افزودن آدرس جدید</div>
<BasicIcon icon="vuesax-linear:add" />
</div>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import img from '@/assets/img/user_ly1.svg'
import router from '@/router'
import Item from '../components/Item.vue'
import { useAddressPageContext } from '../composables/useAddressPage'
const {
items,
loading,
addAddressVisible,
isBazretalaProject,
openAdd,
openEdit,
initItems,
} = useAddressPageContext()
</script>
@@ -0,0 +1,90 @@
import { inject, onMounted, provide, ref, type InjectionKey, type Ref } from 'vue'
import { message } from 'ant-design-vue'
import { getEarnMoney } from '@/api/earnMoney'
import type { EarnMoney } from '@/models/earnMoney'
import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
import { loading } from '@/utils'
export interface EarnMoneyPageContext {
data: Ref<EarnMoney | undefined>
userStore: ReturnType<typeof useUserStore>
nameProject: string
isTransaction: Ref<boolean>
loading: typeof loading
setIsTransaction: (value: boolean) => void
copyCode: () => void
shareCode: () => void
loadEarnMoney: () => Promise<void>
}
export const EARN_MONEY_PAGE_CONTEXT_KEY: InjectionKey<EarnMoneyPageContext> =
Symbol('earnMoneyPageContext')
export function useEarnMoneyPage() {
const data = ref<EarnMoney>()
const userStore = useUserStore()
const nameProject = useConfigStore().config.name_fa
const isTransaction = ref<boolean>(false)
function setIsTransaction(value: boolean) {
isTransaction.value = value
}
function copyCode() {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
function shareCode() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val,
})
}
async function loadEarnMoney() {
try {
loading.value = true
data.value = await getEarnMoney()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadEarnMoney()
})
const context: EarnMoneyPageContext = {
data,
userStore,
nameProject,
isTransaction,
loading,
setIsTransaction,
copyCode,
shareCode,
loadEarnMoney,
}
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 a page that calls useEarnMoneyPage()')
}
return context
}
@@ -0,0 +1,121 @@
<template>
<div class="dearn">
<div class="dearn__container">
<header class="dearn-topbar">
<p class="dearn-topbar__eyebrow">بازاریابی</p>
<h1 class="dearn-topbar__title">کسب درآمد</h1>
<p class="dearn-topbar__subtitle">با دعوت دوستان، پاداش دریافت کنید</p>
</header>
<section class="dearn-surface dearn-referral">
<div class="dearn-referral__header">
<div class="dearn-referral__icon">
<img :src="coin" alt="" />
</div>
<div>
<h2 class="dearn-referral__title">دعوت دوستان</h2>
<p class="dearn-referral__desc">هر دعوت موفق 10 سوت طلا هدیه</p>
</div>
</div>
<div class="dearn-referral__code">
<span class="dearn-referral__code-label">کد بازاریابی</span>
<span class="dearn-referral__code-value">{{ userStore.userInfo.referral_id }}</span>
</div>
<div class="dearn-referral__actions">
<BasicButton
icon="vuesax-linear:share"
type="primary"
class="dearn-referral__btn"
icon-size="18"
@click.stop="shareCode()"
>
اشتراک گذاری
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
class="dearn-referral__btn"
icon-size="18"
@click.stop="copyCode()"
>
رونوشت
</BasicButton>
</div>
</section>
<div class="dearn-stats">
<div class="dearn-stats__card dearn-stats__card--users">
<span class="dearn-stats__value">{{ data?.usersSum ?? 0 }} عدد</span>
<span class="dearn-stats__label">تعداد زیر مجموعه</span>
</div>
<div class="dearn-stats__card dearn-stats__card--gifts">
<span class="dearn-stats__value">{{ data?.giftsSum ?? 0 }} سوت</span>
<span class="dearn-stats__label">میزان کل پاداش ها</span>
</div>
</div>
<div class="dearn-tabs">
<button
type="button"
class="dearn-tabs__btn"
:class="{ 'dearn-tabs__btn--active': isTransaction }"
@click="setIsTransaction(true)"
>
پاداش ها
</button>
<button
type="button"
class="dearn-tabs__btn"
:class="{ 'dearn-tabs__btn--active': !isTransaction }"
@click="setIsTransaction(false)"
>
فهرست زیر مجموعه
</button>
</div>
<section class="dearn-surface dearn-list">
<div v-if="loading" class="dearn-list__loading">
<TransactionSkeleton v-for="i in 6" :key="i" class="mt-1" />
</div>
<template v-else>
<div v-if="isTransaction" class="dearn-list__items">
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
<div v-if="!data?.gifts?.length" class="dearn-empty">
<BasicIcon icon="vuesax-linear:gift" size="36" />
<p>هنوز پاداشی ثبت نشده است.</p>
</div>
</div>
<div v-else class="dearn-list__items">
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
<div v-if="!data?.users?.length" class="dearn-empty">
<BasicIcon icon="vuesax-linear:people" size="36" />
<p>هنوز زیرمجموعه‌ای ثبت نشده است.</p>
</div>
</div>
</template>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import coin from '@/assets/img/coin.png'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import TransactionSkeleton from '@/components/TransactionSkeleton.vue'
import EarnCard from '../components/EarnCard.vue'
import GiftCard from '../components/GiftCard.vue'
import { useEarnMoneyPageContext } from '../composables/useEarnMoneyPage'
const {
data,
userStore,
isTransaction,
loading,
setIsTransaction,
copyCode,
shareCode,
} = useEarnMoneyPageContext()
</script>
@@ -0,0 +1,244 @@
/* Premium desktop earn money page */
.dearn {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.dearn__container {
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dearn-topbar {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__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 0 6px;
line-height: 1.2;
}
&__subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.dearn-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);
}
.dearn-referral {
padding: 24px 28px;
margin-bottom: 20px;
&__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
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: #dbeafe;
flex-shrink: 0;
img {
width: 28px;
height: 28px;
object-fit: contain;
}
}
&__title {
font-size: 18px;
font-weight: 700;
color: #111827;
margin: 0 0 6px;
}
&__desc {
font-size: 13px;
line-height: 1.6;
color: #6b7280;
margin: 0;
}
&__code {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 18px;
margin-bottom: 16px;
border-radius: 12px;
background: #f9fafb;
border: 1px dashed #d1d5db;
}
&__code-label {
font-size: 12px;
color: #6b7280;
font-weight: 500;
}
&__code-value {
font-size: 18px;
font-weight: 700;
color: @App-primary-color;
letter-spacing: 0.02em;
}
&__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
&__btn {
min-height: 44px;
}
}
.dearn-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
&__card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 16px;
border-radius: 14px;
text-align: center;
&--users {
background: #dbeafe;
color: #2563eb;
}
&--gifts {
background: #fef9c3;
color: #ca8a04;
}
}
&__value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
}
&__label {
font-size: 13px;
font-weight: 500;
opacity: 0.9;
}
}
.dearn-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
margin-bottom: 20px;
background: #f3f4f6;
border-radius: 12px;
&__btn {
min-width: 140px;
padding: 10px 18px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 14px;
font-weight: 600;
color: #6b7280;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&:hover {
color: #374151;
}
&--active {
background: #fff;
color: @App-primary-color;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
}
}
.dearn-list {
padding: 20px 24px 24px;
&__loading,
&__items {
display: flex;
flex-direction: column;
gap: 4px;
}
}
.dearn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #9ca3af;
text-align: center;
p {
margin: 0;
font-size: 14px;
color: #6b7280;
}
svg,
.basic-icon {
color: #d1d5db;
}
}
@media (max-width: 1023.98px) {
.dearn-stats {
grid-template-columns: 1fr;
}
.dearn-referral__actions {
grid-template-columns: 1fr;
}
}
+14 -149
View File
@@ -1,159 +1,24 @@
<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="text-center p-3 border-solid border-1 rounded-[8px] border-primary ">
<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-4">
<div class="flex ">
<div class="font-bold">دعوت دوستان</div>
<!-- <div class="text-xs ">راهنمای کسب درآمد</div>-->
</div>
<div class="text-center mt-2 text-xs">هر دعوت موفق 10 سوت طلا هدیه</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-[#0A1B37] p-2 rounded-[8px] w-full">-->
<!-- <div class="text-white text-center">فهرست زیرمجموعه</div>-->
<!-- </div>-->
<!-- <div class="bg-[#0A1B37] 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 v-if="isTransaction">
<GiftCard v-for="(val,i) in data?.gifts" :key="i" :item="val"/>
</div>
<div v-else>
<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'
import {useConfigStore} from "@/store/configStore";
import {getReferralShareText, getReferralShareTitle} from "@/utils/config";
const data = ref<EarnMoney>()
const userStore = useUserStore()
const nameProject= useConfigStore().config.name_fa
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() {
const val = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: nameProject,
referralId: userStore.userInfo?.referral_id,
})
navigator.share({
title: getReferralShareTitle(nameProject),
text: val
})
}
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,101 @@
<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>
<div class="p-4">
<div class="text-center p-3 border-solid border-1 rounded-[8px] border-primary">
<div class="flex items-center">
<div class="bg-[#DBEAFE] h-10 w-10 rounded-100% flex justify-center items-center">
<img :src="coin" alt="" />
</div>
<div class="mr-4">
<div class="flex">
<div class="font-bold">دعوت دوستان</div>
</div>
<div class="text-center mt-2 text-xs">هر دعوت موفق 10 سوت طلا هدیه</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="border-solid border-1 border-[#E5E7EB] mt-6"></div>
<div v-if="isTransaction">
<GiftCard v-for="(val, i) in data?.gifts" :key="i" :item="val" />
</div>
<div v-else>
<EarnCard v-for="(val, i) in data?.users" :key="i" :item="val" />
</div>
</div>
</template>
<script lang="ts" setup>
import coin from '@/assets/img/coin.png'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import router from '@/router'
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>
@@ -0,0 +1,103 @@
<template>
<div
:class="variant === 'desktop'
? 'deducate-content'
: 'mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4'"
>
<div
v-if="variant === 'desktop'"
class="deducate-content__avatar-wrap"
>
<img :src="config.config.avatar" alt="" class="deducate-content__avatar" />
</div>
<div
v-else
class="h-20vh flex justify-center"
>
<img :src="config.config.avatar" alt="" class="mb-20 h-15vh mt-5" />
</div>
<div :class="variant === 'desktop' ? 'deducate-content__body' : ''">
<div
:class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold text-sm text-[#093D51]'"
>
شیوهٔ ورود و ثبت ‌نام در گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• برای ثبت‌نام در گروسی گالری، اگر قبلاً حساب کاربری دارید می‌توانید از طریق پیامک وارد شوید.<br />
• اگر حساب کاربری ندارید، "از این قسمت" وارد صفحه ثبت نام شده و با وارد کردن نام و شماره تلفنی که با کد ملی شما مطابقت دارد، حساب کاربری بسازید.<br />
• الزامی به وارد کردن کد معرف نیست.
</div>
<img :src="img1" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شارژ کیف پول
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• ابتدا کیف پول را در منو انتخاب کرده، مبلغ مورد نظر را وارد نموده و دکمه واریز را انتخاب کنید تا کیف پول شما شارژ شود.<br />
</div>
<img :src="img2" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ خرید از گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• سپس از بخش «خرید و فروش» انتخاب کنید که طلای آب‌شده می‌خواهید یا محصولات دیگر (شامل گوی، شمش یا مکعب).<br />
• توجه داشته باشید کارمزد طلای آب‌شده حدود ۲٪ است و کارمزد ریالی محصولات دیگر در پایین صفحه قیمت درج شده است.
</div>
<img :src="img4" :class="variant === 'desktop' ? 'deducate-content__image' : ''" />
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ فروش در گروسی گالری
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• برای فروش محصولاتی که خریداری کرده‌اید، به بخش «خرید و فروش» مراجعه کرده، محصول موردنظر را انتخاب و گزینه فروش را بزنید تا مبلغ قابل دریافت نمایش داده شود.<br />
• مبلغ به کیف پول شما واریز می‌شود و از طریق پیام پشتیبانی می‌توانید درخواست برداشت دهید.<br />
• اگر موجودی کیف پول بیش از ۱۰ میلیون تومان باشد، مبلغ تا پایان روز کاری بعد به حساب اعلام‌شده شما واریز خواهد شد.
</div>
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
شیوهٔ دریافت فیزیکی طلا (ارسال)
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• ابتدا موجودی محصول موردنظر را با پشتیبانی در واتساپ هماهنگ کنید.<br />
• سپس روی لوگو کلیک کرده، گزینه «برداشت فیزیکی» و بعد «درخواست جدید» را انتخاب کنید.<br />
• محصولات خریداری‌شده و تعداد آن‌ها را مشخص کرده و آدرس خود را ثبت نمایید.<br />
• پشتیبانی عکس و وزن محصول را برای شما ارسال می‌کند.<br />
• ارسال به‌صورت پس‌کرایه و بیمه‌شده انجام می‌شود.<br />
</div>
<div :class="variant === 'desktop' ? 'deducate-content__divider' : 'w-full h-1px my-3 bg-secondary'"></div>
<div :class="variant === 'desktop' ? 'deducate-content__section-title' : 'font-bold mt-4 text-sm text-[#093D51]'">
پاداش معرفی
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید.
</div>
<div :class="variant === 'desktop' ? 'deducate-content__text' : 'mt-5 text-xs leading-5'">
• سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br />
• در صورتی که دوستان شما از طریق لینک شما ثبت‌نام کنند، به‌صورت خودکار ۱ سوت طلا به‌عنوان پاداش دریافت خواهید کرد.
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import img1 from '@/assets/img/1.png'
import img2 from '@/assets/img/2.png'
import img4 from '@/assets/img/4.png'
import { useEducatePageContext } from '../composables/useEducatePage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,43 @@
<template>
<div
v-if="config.config.name === 'garoosi'"
:class="variant === 'desktop' ? 'deducate-support' : 'wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white'"
>
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img
src="@/assets/img/bale.png"
alt=""
:class="variant === 'desktop' ? 'deducate-support__bale' : 'h-10 w-10'"
/>
</a>
</div>
<div
v-else
:class="variant === 'desktop' ? 'deducate-support' : 'wrap-fix bottom-0 px-10 py-5 flex gap-2 bg-white'"
>
<a class="w-full" href="tel:09306612518">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="">
<BasicButton icon="vuesax-linear:whatsapp" />
</a>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { useEducatePageContext } from '../composables/useEducatePage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,105 @@
import { inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe } from '@/api/me'
import type { User } from '@/models/login'
import { useConfigStore } from '@/store/configStore'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
export interface EducatePageContext {
config: ReturnType<typeof useConfigStore>
userFormState: ReturnType<typeof ref<User>>
formRef: ReturnType<typeof ref<FormInstance | undefined>>
editPassVisible: ReturnType<typeof ref<boolean>>
setForm: () => void
copyCode: () => void
resetData: () => void
ok: () => void
}
export const EDUCATE_PAGE_CONTEXT_KEY: InjectionKey<EducatePageContext> = Symbol('educatePageContext')
export function useEducatePage() {
const config = useConfigStore()
const userStore = useUserStore()
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined,
})
const formRef = ref<FormInstance>()
const editPassVisible = ref<boolean>(false)
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(val)
message.info('کد رونوشت شد')
}
const resetData = () => {
formRef.value?.resetFields()
userFormState.value = {
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined,
}
}
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
}
})
}
onMounted(() => {
setForm()
})
const context: EducatePageContext = {
config,
userFormState,
formRef,
editPassVisible,
setForm,
copyCode,
resetData,
ok,
}
provide(EDUCATE_PAGE_CONTEXT_KEY, context)
return context
}
export function useEducatePageContext() {
const context = inject(EDUCATE_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useEducatePageContext must be used within a page that calls useEducatePage()')
}
return context
}
@@ -0,0 +1,52 @@
<template>
<div class="deducate">
<div class="deducate__container">
<header class="deducate-topbar">
<button type="button" class="deducate-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="deducate-topbar__meta">
<p class="deducate-topbar__eyebrow">راهنما</p>
<h1 class="deducate-topbar__title">آموزش نرم افزار</h1>
</div>
</header>
<div class="deducate-layout">
<section class="deducate-surface deducate-main">
<div class="deducate-main__intro">
<div class="deducate-main__logo">
<BasicIcon icon="vuesax-linear:book" size="28" />
</div>
<div>
<h2 class="deducate-main__project">{{ config.config.name_fa }}</h2>
<p class="deducate-main__tagline">آموزش گام‌به‌گام استفاده از خدمات</p>
</div>
</div>
<div class="deducate-main__scroll">
<EducateContentPanel variant="desktop" />
</div>
</section>
<aside class="deducate-surface deducate-side">
<h3 class="deducate-side__title">پشتیبانی و ارتباط</h3>
<p class="deducate-side__text">
در صورت نیاز به راهنمایی بیشتر، با پشتیبانی تماس بگیرید.
</p>
<EducateSupportActions variant="desktop" />
</aside>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import EducateContentPanel from '../components/EducateContentPanel.vue'
import EducateSupportActions from '../components/EducateSupportActions.vue'
import { useEducatePageContext } from '../composables/useEducatePage'
const { config } = useEducatePageContext()
</script>
@@ -0,0 +1,231 @@
/* Premium desktop educate page */
.deducate {
min-height: 100%;
background: #f6f7f9;
color: #111827;
}
.deducate__container {
max-width: 1080px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.deducate-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;
}
}
.deducate-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 300px);
gap: 20px;
align-items: start;
}
.deducate-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);
}
.deducate-main {
padding: 28px;
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 180px);
&__intro {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #eef0f3;
flex-shrink: 0;
}
&__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;
}
&__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-left: 4px;
margin-left: -4px;
}
}
.deducate-content {
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;
&__avatar-wrap {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
&__avatar {
max-height: 120px;
width: auto;
object-fit: contain;
}
&__section-title {
font-size: 15px;
font-weight: 700;
color: inherit;
margin: 0 0 10px;
}
&__text {
font-size: 13px;
line-height: 1.8;
color: inherit;
text-align: justify;
margin: 0 0 12px;
}
&__image {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin: 12px 0 4px;
border-radius: 8px;
}
&__divider {
width: 100%;
height: 1px;
background: rgba(9, 61, 81, 0.12);
margin: 16px 0;
}
}
.deducate-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;
}
}
.deducate-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) {
.deducate-layout {
grid-template-columns: 1fr;
}
.deducate-side {
position: static;
}
.deducate-main {
max-height: none;
}
}
+12 -196
View File
@@ -1,208 +1,24 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black 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-10">
<div>
<!-- <img :src="img" alt="" class="mt-2 w-full absolute bottom-0" />-->
<!-- <img :src="config.config.avatar" 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="config.config.avatar" alt="" class="mb-20 h-15vh mt-5">
</div>
<div class="overflow-y-auto h-60vh pb-10 border-[#093D51]">
<div
class="mt-5 text-5 border-solid border-2 border-[#093D51] text-[#093D51] rounded-[12px] p-4"
>
<div class="font-bold text-sm text-[#093D51]">
شیوهٔ ورود و ثبت ‌نام در گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• برای ثبت‌نام در گروسی گالری، اگر قبلاً حساب کاربری دارید می‌توانید از طریق پیامک وارد شوید.<br />
• اگر حساب کاربری ندارید، "از این قسمت" وارد صفحه ثبت نام شده و با وارد کردن نام و شماره تلفنی که با کد ملی شما مطابقت دارد، حساب کاربری بسازید.<br />
• الزامی به وارد کردن کد معرف نیست.
</div>
<img :src="img1" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شارژ کیف پول
</div>
<div class="mt-5 text-xs leading-5">
• ابتدا کیف پول را در منو انتخاب کرده، مبلغ مورد نظر را وارد نموده و دکمه واریز را انتخاب کنید تا کیف پول شما شارژ شود.<br />
</div>
<img :src="img2" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ خرید از گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• سپس از بخش «خرید و فروش» انتخاب کنید که طلای آب‌شده می‌خواهید یا محصولات دیگر (شامل گوی، شمش یا مکعب).<br />
• توجه داشته باشید کارمزد طلای آب‌شده حدود ۲٪ است و کارمزد ریالی محصولات دیگر در پایین صفحه قیمت درج شده است.
</div>
<img :src="img4" />
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ فروش در گروسی گالری
</div>
<div class="mt-5 text-xs leading-5">
• برای فروش محصولاتی که خریداری کرده‌اید، به بخش «خرید و فروش» مراجعه کرده، محصول موردنظر را انتخاب و گزینه فروش را بزنید تا مبلغ قابل دریافت نمایش داده شود.<br />
• مبلغ به کیف پول شما واریز می‌شود و از طریق پیام پشتیبانی می‌توانید درخواست برداشت دهید.<br />
• اگر موجودی کیف پول بیش از ۱۰ میلیون تومان باشد، مبلغ تا پایان روز کاری بعد به حساب اعلام‌شده شما واریز خواهد شد.
</div>
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
شیوهٔ دریافت فیزیکی طلا (ارسال)
</div>
<div class="mt-5 text-xs leading-5">
• ابتدا موجودی محصول موردنظر را با پشتیبانی در واتساپ هماهنگ کنید.<br />
• سپس روی لوگو کلیک کرده، گزینه «برداشت فیزیکی» و بعد «درخواست جدید» را انتخاب کنید.<br />
• محصولات خریداری‌شده و تعداد آن‌ها را مشخص کرده و آدرس خود را ثبت نمایید.<br />
• پشتیبانی عکس و وزن محصول را برای شما ارسال می‌کند.<br />
• ارسال به‌صورت پس‌کرایه و بیمه‌شده انجام می‌شود.<br />
</div>
<div class="w-full h-1px my-3 bg-secondary"></div>
<div class="font-bold mt-4 text-sm text-[#093D51]">
پاداش معرفی
</div>
<div class="mt-5 text-xs leading-5">
• در صفحه اصلی روی "تنظیمات" کلیک کرده و وارد حساب کاربری خود شوید.
</div>
<!-- <img :src="img2" />-->
<div class="mt-5 text-xs leading-5">
• سپس گزینه «اشتراک‌گذاری» یا «کپی لینک» را انتخاب کرده و لینک دعوت را برای دوستانتان ارسال کنید.<br />
• در صورتی که دوستان شما از طریق لینک شما ثبت‌نام کنند، به‌صورت خودکار ۱ سوت طلا به‌عنوان پاداش دریافت خواهید کرد.
</div>
<!-- <img :src="img3" />-->
</div>
</div>
</div>
<div v-if="config.config.name === 'garoosi'" class="wrap-fix bottom-0 px-10 py-6 flex gap-2 bg-white">
<a class="w-full" href="tel:09031265925">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="https://ble.ir/garoosigallery">
<img src="@/assets/img/bale.png" alt="" class="h-10 w-10"/>
</a>
</div>
<div v-else class="wrap-fix bottom-0 px-10 py-5 flex gap-2 bg-white">
<a class="w-full" href="tel:09306612518">
<BasicButton class="w-full" type="primary">تماس با پشتیبانی</BasicButton>
</a>
<a href="">
<BasicButton icon="vuesax-linear:whatsapp" />
</a>
</div>
</div>
</div>
<!-- <ActionSheet v-model:show="editPassVisible">salam</ActionSheet>-->
<div class="educate-page-root">
<DesktopEducateView v-show="isDesktop" />
<MobileEducateView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import img from '@/assets/img/user_ly1.svg'
import img1 from '@/assets/img/1.png'
import img2 from '@/assets/img/2.png'
import img4 from '@/assets/img/4.png'
import { loading } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.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 {useConfigStore} from "@/store/configStore";
const config=useConfigStore()
const userFormState = ref<User>({
fname: '',
lname: '',
mobile: undefined,
birth_date: '',
phone_number: '',
province: '',
city: '',
n_code: undefined
})
const formRef = ref<FormInstance>()
const userStore = useUserStore()
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useEducatePage } from './composables/useEducatePage'
import DesktopEducateView from './desktop/DesktopEducateView.vue'
import MobileEducateView from './mobile/MobileEducateView.vue'
// const editPassVisible = ref<boolean>(false)
const editPassVisible = ref<boolean>(false)
const isDesktop = useDesktopBreakpoint()
function setForm() {
userFormState.value = userStore.userInfo
}
function copyCode() {
// nextTick(() => {
const val = userStore.userInfo?.referral_id
navigator.clipboard.writeText(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
}
})
}
useEducatePage()
</script>
<style scoped>
/*.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
.educate-page-root {
min-height: 100%;
height: 100%;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}*/
/*.trans{
transition: height 1s;
}*/
</style>
@@ -0,0 +1,27 @@
<template>
<div>
<div class="wrap-fix z-1 bg-white py-3 px-3 text-black 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-10">
<div class="text-justify text-base z-200000 relative">
<div class="px-10">
<div class="overflow-y-auto h-60vh pb-10 border-[#093D51]">
<EducateContentPanel variant="mobile" />
</div>
</div>
<EducateSupportActions variant="mobile" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import EducateContentPanel from '../components/EducateContentPanel.vue'
import EducateSupportActions from '../components/EducateSupportActions.vue'
</script>
+9 -2
View File
@@ -1,5 +1,9 @@
<template>
<div v-if="item" class="bg-white flex flex-col items-center p-3 rounded-xl">
<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">{{ item.title ?? '-' }}</div>
<div class="mt-1 flex flex-col items-center gap-0.5 text-center">
@@ -21,9 +25,12 @@ import { getProductDisplayImage } from '@/utils/productAssets'
interface Prop {
item: WalletInventoryCard
variant?: 'mobile' | 'desktop'
}
const props = defineProps<Prop>()
const props = withDefaults(defineProps<Prop>(), {
variant: 'mobile',
})
const imageSrc = ref('')
const displayImage = computed(() => imageSrc.value || resolveImage())
@@ -0,0 +1,120 @@
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 { useConfigStore } from '@/store/configStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight, fundPageInventoryCards } from '@/utils/productAssets'
import { useWalletStore } from '@/store/walletStore'
import { getLastPrices } from '@/utils/price'
import {
getProjectFundEmptyImage,
getProjectFundEmptyText,
getProjectFundHeaderBackground,
isBazretalaProject,
} from '@/utils/config'
import img from '@/assets/img/user_ly1.svg'
import empty from '@/assets/img/cash_empty.svg'
export interface FundPageContext {
isDesktop: Ref<boolean>
nameProject: string
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
isBazretalaProject: typeof isBazretalaProject
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 nameProject = useConfigStore().config.name
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const walletStore = useWalletStore()
const fundHeaderBackground = getProjectFundHeaderBackground(img)
const fundEmptyImage = getProjectFundEmptyImage(empty)
const fundEmptyText = getProjectFundEmptyText()
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 `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function openFundInfo(productId: number) {
router.push({ name: 'fund_info', params: { id: productId } })
}
function goBack() {
router.back()
}
onMounted(async () => {
try {
loading.value = true
const tasks = [loadWalletAssets(true), getLastPrices()]
if (isBazretalaProject()) {
tasks.push(walletStore.getMyWallet())
}
await Promise.all(tasks)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
const context: FundPageContext = {
isDesktop,
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
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,138 @@
<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"
:class="{ 'dfund-summary--bazretala': isBazretalaProject() }"
>
<img
:src="fundHeaderBackground"
alt=""
class="dfund-summary__bg"
:class="{ 'dfund-summary__bg--cover': isBazretalaProject() }"
/>
<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="isBazretalaProject()">
<div class="dfund-summary__row">
<span>دارایی نقره</span>
<strong>{{ walletStore.walletInfo?.count_silver ?? 0 }} گرم</strong>
</div>
<div class="dfund-summary__row">
<span>دارایی طلایی</span>
<strong>{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }} گرم</strong>
</div>
</template>
<template v-else-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 && !isBazretalaProject()"
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 v-if="nameProject !== 'abaeigold'">{{ 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 { useWalletStore } from '@/store/walletStore'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const walletStore = useWalletStore()
const {
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
@@ -0,0 +1,495 @@
/* 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;
}
&__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;
}
/* 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%;
}
+12 -162
View File
@@ -1,173 +1,23 @@
<template>
<div>
<div class="bg-white w-full py-3 px-3 text-primary 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="w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden"
:class="isBazretalaProject() ? 'bazretala-fund-header' : 'text-tertiary bg-primary'"
>
<img
:src="fundHeaderBackground"
alt=""
:class="isBazretalaProject()
? 'pointer-events-none z-0 absolute inset-0 w-full object-cover object-center'
: 'pointer-events-none z-0 w-full absolute bottom-0 left-0 opacity-5'"
/>
<div
class="pt-15 flex flex-col relative z-10"
:class="isBazretalaProject() ? 'text-white' : 'text-tertiary'"
>
<div class="font-bold mr-5 z-20">موجودی دارایی ها</div>
<div v-if="meta?.has_count_based_products" class="flex mt-4 justify-center mt-2 w-full z-20">
محصولات:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="isBazretalaProject()">
<div class="flex justify-center mt-2 w-full z-20">
دارایی نقره:
<div class="text-l mr-4 font-bold">{{ walletStore.walletInfo?.count_silver ?? 0 }}گرم</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی طلایی:
<div class="text-l mr-4 font-bold">{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }}گرم</div>
</div>
</template>
<template v-else-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 && !isBazretalaProject()" 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(cashWallet + metalRialValue) }}ریال</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="isBazretalaProject() ? 'w-48 max-w-[11rem]' : 'w-70'"
>
<div v-if="nameProject !== 'abaeigold'" 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="router.push({ name: 'fund_info', params: { id: item.product_id } })"
/>
</div>
</a-spin>
</div>
</div>
<div class="fund-page-root">
<DesktopFundView v-show="isDesktop" />
<MobileFundView v-show="!isDesktop" />
</div>
</template>
<script lang="ts" setup>
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import img from '@/assets/img/user_ly1.svg'
import empty from '@/assets/img/cash_empty.svg'
import Item from './components/Item.vue'
import { onMounted, computed } from 'vue'
import { loading } from '@/utils'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useConfigStore } from '@/store/configStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight, fundPageInventoryCards, resolveAssetsRialValue } from '@/utils/productAssets'
import { useWalletStore } from '@/store/walletStore'
import { getTradeBuyPrice, lastPrice } from '@/utils/price'
import {
getProjectFundEmptyImage,
getProjectFundEmptyText,
getProjectFundHeaderBackground,
isBazretalaProject,
} from '@/utils/config'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundPage } from './composables/useFundPage'
import DesktopFundView from './desktop/DesktopFundView.vue'
import MobileFundView from './mobile/MobileFundView.vue'
const nameProject = useConfigStore().config.name
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const walletStore = useWalletStore()
const fundHeaderBackground = getProjectFundHeaderBackground(img)
const fundEmptyImage = getProjectFundEmptyImage(empty)
const fundEmptyText = getProjectFundEmptyText()
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 metalRialValue = computed(() =>
resolveAssetsRialValue(assetsSummary.value, getTradeBuyPrice(lastPrice.value))
)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
onMounted(async () => {
try {
loading.value = true
const tasks = [loadWalletAssets(true), getLastPrices()]
if (isBazretalaProject()) {
tasks.push(walletStore.getMyWallet())
}
await Promise.all(tasks)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
const isDesktop = useDesktopBreakpoint()
useFundPage(isDesktop)
</script>
<style scoped>
.bazretala-fund-header {
background-color: #000;
}
.bazretala-fund-header::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
.fund-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,48 @@
<template>
<div :class="variant === 'desktop' ? 'dfund-info-edit' : ''">
<div v-if="variant !== 'desktop'" class="font-bold text-lg">گنج {{ info?.title }}</div>
<BasicButton
:class="variant === 'desktop' ? 'mt-4 w-full' : '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="variant === 'desktop' ? 'mt-2 w-full !text-red-600' : '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
v-if="variant !== 'desktop'"
class="mb-20 mt-2 w-full"
type="primary"
@click="transactionShow = false"
>
بازگشت
</BasicButton>
</div>
</template>
<script lang="ts" setup>
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
interface Props {
variant?: 'mobile' | 'desktop'
}
withDefaults(defineProps<Props>(), {
variant: 'mobile',
})
const { info, infoId, transactionShow, deleteFundConfirm } = useFundInfoPageContext()
</script>
@@ -0,0 +1,82 @@
<template>
<div :class="variant === 'desktop' ? 'dfund-info-form' : ''">
<div v-if="variant !== 'desktop'" class="font-bold text-lg">
{{ isDeposit ? 'واریز' : 'برداشت' }}
</div>
<a-form
ref="formRef"
:model="userFormState"
:class="variant === 'desktop' ? '!mt-0' : '!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>{{ formatGoldWalletBalanceText(walletStore?.walletInfo?.gold ?? 0) }}</div>
<div v-if="info?.wallet?.gold !== 0">
{{ `موجودی قابل برداشت گنج: ${info?.wallet?.gold ?? 0} گرم` }}
</div>
</div>
<div v-else class="mt-2 text-xs font-bold">
موجودی گنج {{ info?.title }}: {{ info?.wallet?.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
v-if="variant !== 'desktop'"
class="mb-20 mt-2 w-full"
@click="transactionShow = false"
>
بازگشت
</BasicButton>
</a-form-item>
</a-form>
</div>
</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 { formatGoldWalletBalanceText } from '@/utils/productDealMethod'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
interface Props {
variant?: 'mobile' | 'desktop'
}
withDefaults(defineProps<Props>(), {
variant: 'mobile',
})
const {
info,
isDeposit,
formRef,
userFormState,
transButtonDisable,
walletStore,
transactionShow,
transaction,
} = useFundInfoPageContext()
</script>
@@ -0,0 +1,185 @@
import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { deleteFund, fundDeposit, fundWithdraw, getFundById, getFundTransactionTable } from '@/api/fund'
import type { FundTransaction } from '@/models/fund'
import type { TableInput, TableOutput } from '@/models/table'
import type { Transaction } from '@/models/transaction'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { useWalletStore } from '@/store/walletStore'
import { useFundUserStore } from '@/store/fundStore'
import { getOneProductWallet } from '@/api/products'
import type { Product } from '@/models/products'
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'
export const FUND_INFO_PAGE_KEY: InjectionKey<ReturnType<typeof useFundInfoPage>> =
Symbol('fundInfoPage')
export function useFundInfoPage() {
const info = ref<Product>()
const items = ref<Transaction[]>([])
const tableOutput = ref<TableOutput<Transaction> | null>(null)
const pagination = ref<TableInput>({})
const transactionShow = ref<boolean>()
const editShow = ref<boolean>()
const showDeleteConfirm = ref<boolean>()
const isDeposit = ref<boolean>()
const formRef = ref<FormInstance>()
const fundStore = useFundUserStore()
const walletStore = useWalletStore()
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
}
return userFormState.value > (info.value?.wallet?.gold ?? 0)
}
return true
}
return true
})
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
}
})
function deleteFundConfirm() {
if (info.value?.wallet === undefined || info.value?.wallet?.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
}
}
async function initInfo() {
try {
loading.value = true
info.value = await getOneProductWallet(infoId)
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?.id })
tableOutput.value = await getFundTransactionTable(pagination.value)
items.value.push(...tableOutput.value.rows)
} 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 fundStore.getMyFund()
await walletStore.getMyWallet()
message.success(`با موفقیت${isDeposit.value ? ' واریز' : ' برداشت'} شد`)
transactionShow.value = false
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
})
}
function goBack() {
router.back()
}
onMounted(() => {
initInfo()
})
const context = {
info,
items,
transactionShow,
editShow,
showDeleteConfirm,
isDeposit,
formRef,
userFormState,
transButtonDisable,
itemImg,
walletStore,
deleteFundConfirm,
deleteFundFunction,
transaction,
goBack,
infoId,
}
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,78 @@
<template>
<div class="dfund-info">
<div class="dfund-info__container">
<header class="dfund-info-topbar">
<button type="button" class="dfund-info-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-info-topbar__meta">
<p class="dfund-info-topbar__eyebrow">جزئیات دارایی</p>
<h1 class="dfund-info-topbar__title">{{ info?.title ?? '—' }}</h1>
</div>
</header>
<div class="dfund-info__layout">
<section class="dfund-info__hero dfund-surface">
<a-spin :spinning="loading">
<div class="dfund-info__image">
<img v-if="info?.group_type == 'گوی'" :src="goyImg" alt="" />
<img v-if="info?.group_type == 'مکعب'" :src="mokaabImg" alt="" />
<img v-if="info?.group_type == 'سکه'" :src="sekkeImg" alt="" />
<img v-if="info?.group_type == 'مس'" :src="mesImg" alt="" />
<img v-if="info?.group_type == 'نقره'" :src="noghrehImg" alt="" />
<img
v-if="info?.group_type == 'طلا' || info?.group_type == 'آبشده' || info?.group_type == null"
:src="defaultImg"
alt=""
/>
</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 BasicIcon from '@/components/BasicPacks/BasicIcon.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 goyImg from '@/assets/img/goyImg.svg'
import mokaabImg from '@/assets/img/mokaabImg.svg'
import sekkeImg from '@/assets/img/sekkeImg.svg'
import mesImg from '@/assets/img/mesImg.jpg'
import noghrehImg from '@/assets/img/noghrehImg.webp'
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items, goBack } = useFundInfoPageContext()
</script>
+60 -286
View File
@@ -1,305 +1,79 @@
<template>
<div>
<div class="bg-white wrap-fix py-3 px-3 text-primary 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="">-->
<img v-if="info?.group_type=='گوی'" :src="goyImg">
<img v-if="info?.group_type=='مکعب'" :src="mokaabImg">
<img v-if="info?.group_type=='سکه'" :src="sekkeImg">
<img v-if="info?.group_type=='مس'" :src="mesImg">
<img v-if="info?.group_type=='نقره'" :src="noghrehImg">
<img v-if="info?.group_type=='طلا' || info?.group_type=='آبشده' || info?.group_type== null" :src="defaultImg">
</div>
</a-spin>
<div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-primary">{{ info?.wallet?.gold?? 0 }}</div>
{{info?.unit}}
</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" :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>
<!-- </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>{{ formatGoldWalletBalanceText(walletStore?.walletInfo?.gold ?? 0) }}</div>
<div v-if="info?.wallet?.gold !== 0">
{{ `موجودی قابل برداشت گنج: ${info?.wallet?.gold ?? 0} گرم` }}
</div>
</div>
<div v-else class="mt-2 text-xs font-bold">موجودی گنج {{ info.title }}: {{ info?.wallet?.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>
<div class="fund-info-page-root">
<DesktopFundInfoView v-show="isDesktop" />
<MobileFundInfoView v-show="!isDesktop" />
<BasicActionSheet v-if="!isDesktop" v-model:show="transactionShow">
<FundTransactionForm />
</BasicActionSheet>
<BasicActionSheet v-model:show="editShow">
<div class="font-bold text-lg">گنج {{ info?.title }}</div>
<BasicModal
v-else
v-model:visible="transactionShow"
:title="isDeposit ? 'واریز' : 'برداشت'"
width="480px"
destroy-on-close
custom-footer
>
<FundTransactionForm variant="desktop" />
<template #footer>
<div></div>
</template>
</BasicModal>
<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 v-if="!isDesktop" v-model:show="editShow">
<FundEditActions />
</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>
<BasicModal
v-else
v-model:visible="editShow"
:title="`گنج ${info?.title ?? ''}`"
width="420px"
destroy-on-close
custom-footer
>
<FundEditActions variant="desktop" />
<template #footer>
<div></div>
</template>
</BasicModal>
<template v-slot:button>
<div class="text-center text-red" @click="deleteFundFunction">
حذف
</div>
<div class="text-center" @click="showDeleteConfirm = false">
انصراف
</div>
<DialogVant v-model:show="showDeleteConfirm" :close-on-click-overlay="true" :time-out="-1">
<template #title>
<div class="p-5 text-sm">آیا از حذف گنج خود اطمینان دارید؟</div>
</template>
<template #button>
<div class="text-center text-red" @click="deleteFundFunction">حذف</div>
<div class="text-center" @click="showDeleteConfirm = false">انصراف</div>
</template>
</DialogVant>
</div>
</template>
<script lang="ts" setup>
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 { formatGoldWalletBalanceText } from '@/utils/productDealMethod'
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 BasicModal from '@/components/BasicPacks/BasicModal.vue'
import DialogVant from '@/components/DialogVant.vue'
import { useFundUserStore } from '@/store/fundStore'
import { getOneProductWallet } from '@/api/products'
import goyImg from '@/assets/img/goyImg.svg'
import mokaabImg from '@/assets/img/mokaabImg.svg'
import sekkeImg from "@/assets/img/sekkeImg.svg"
import mesImg from "@/assets/img/mesImg.jpg"
import noghrehImg from "@/assets/img/noghrehImg.webp"
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import type { Product } from '@/models/products'
const info = ref<Product>()
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 === undefined || info.value?.wallet?.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?.gold
}
// return isDeposit.value ? userFormState.value > walletStore?.walletInfo?.gold : userFormState.value > info.value?.wallet?.gold
} else return true
} else {
return true
}
}
)
async function initInfo() {
try {
loading.value = true
info.value = await getOneProductWallet(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?.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
}
})
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundInfoPage } from './composables/useFundInfoPage'
import DesktopFundInfoView from './desktop/DesktopFundInfoView.vue'
import MobileFundInfoView from './mobile/MobileFundInfoView.vue'
import FundTransactionForm from './components/FundTransactionForm.vue'
import FundEditActions from './components/FundEditActions.vue'
const isDesktop = useDesktopBreakpoint()
const {
info,
transactionShow,
editShow,
showDeleteConfirm,
isDeposit,
deleteFundFunction,
} = useFundInfoPage()
</script>
<style scoped>
.fund-info-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,63 @@
<template>
<div>
<div
class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"
>
<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-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<img v-if="info?.group_type == 'گوی'" :src="goyImg" alt="" />
<img v-if="info?.group_type == 'مکعب'" :src="mokaabImg" alt="" />
<img v-if="info?.group_type == 'سکه'" :src="sekkeImg" alt="" />
<img v-if="info?.group_type == 'مس'" :src="mesImg" alt="" />
<img v-if="info?.group_type == 'نقره'" :src="noghrehImg" alt="" />
<img
v-if="info?.group_type == 'طلا' || info?.group_type == 'آبشده' || info?.group_type == null"
:src="defaultImg"
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-primary">{{ 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 { 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 goyImg from '@/assets/img/goyImg.svg'
import mokaabImg from '@/assets/img/mokaabImg.svg'
import sekkeImg from '@/assets/img/sekkeImg.svg'
import mesImg from '@/assets/img/mesImg.jpg'
import noghrehImg from '@/assets/img/noghrehImg.webp'
import defaultImg from '@/assets/img/defaultAbshodeh.svg'
import { useFundInfoPageContext } from '../composables/useFundInfoPage'
const { info, items, goBack } = useFundInfoPageContext()
</script>
@@ -0,0 +1,161 @@
<template>
<div>
<div
class="bg-white w-full py-3 px-3 text-primary 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="w-screen md:w-full h-[70vh] fixed md:absolute overflow-hidden"
:class="isBazretalaProject() ? 'bazretala-fund-header' : 'text-tertiary bg-primary'"
>
<img
:src="fundHeaderBackground"
alt=""
:class="
isBazretalaProject()
? 'pointer-events-none z-0 absolute inset-0 w-full object-cover object-center'
: 'pointer-events-none z-0 w-full absolute bottom-0 left-0 opacity-5'
"
/>
<div
class="pt-15 flex flex-col relative z-10"
:class="isBazretalaProject() ? 'text-white' : 'text-tertiary'"
>
<div class="font-bold mr-5 z-20">موجودی دارایی ها</div>
<div
v-if="meta?.has_count_based_products"
class="flex mt-4 justify-center mt-2 w-full z-20"
>
محصولات:
<div class="text-l mr-4 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="isBazretalaProject()">
<div class="flex justify-center mt-2 w-full z-20">
دارایی نقره:
<div class="text-l mr-4 font-bold">
{{ walletStore.walletInfo?.count_silver ?? 0 }}گرم
</div>
</div>
<div class="flex justify-center mt-2 w-full z-20">
دارایی طلایی:
<div class="text-l mr-4 font-bold">
{{ walletStore.walletInfo?.count_gold_wallet ?? 0 }}گرم
</div>
</div>
</template>
<template v-else-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 && !isBazretalaProject()"
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="isBazretalaProject() ? 'w-48 max-w-[11rem]' : 'w-70'"
/>
<div v-if="nameProject !== 'abaeigold'" 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 { useWalletStore } from '@/store/walletStore'
import Item from '../components/Item.vue'
import { useFundPageContext } from '../composables/useFundPage'
const walletStore = useWalletStore()
const {
nameProject,
meta,
inventoryCards,
metalTotals,
primaryMetalTotal,
cashWallet,
totalRialValue,
countBasedInventory,
fundHeaderBackground,
fundEmptyImage,
fundEmptyText,
isBazretalaProject,
formatMetalTotal,
openFundInfo,
goBack,
} = useFundPageContext()
</script>
<style scoped>
.bazretala-fund-header {
background-color: #000;
}
.bazretala-fund-header::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
}
</style>
@@ -0,0 +1,117 @@
import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import router from '@/router'
import { loading } from '@/utils'
import { usePrefix } from '@/utils/usePrefix'
import { createFund, editFund, getFundById } from '@/api/fund'
import { message } from 'ant-design-vue/es'
import { useFundUserStore } from '@/store/fundStore'
import type { Fund } from '@/models/fund'
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'
export const FUND_NEW_PAGE_KEY: InjectionKey<ReturnType<typeof useFundNewPage>> =
Symbol('fundNewPage')
const types = [
{ name: 'خرید لوازم منزل' },
{ name: 'پس انداز' },
{ name: 'گردش' },
{ name: 'ازدواج' },
{ name: 'تعمیرات' },
{ name: 'هدیه' },
]
export function useFundNewPage() {
const fundStore = useFundUserStore()
const edit_id = computed(() => parseInt(router.currentRoute.value.query.id as string))
const name = ref<string>('پس انداز')
const { prefixCls } = usePrefix('input_name')
const info = ref<Fund>()
const itemImg = computed(() => {
switch (name.value) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
async function getEditInfo() {
try {
loading.value = true
info.value = await getFundById(edit_id.value)
name.value = info.value?.title ?? ''
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
async function ok() {
try {
loading.value = true
if (!name.value) {
message.error('ابتدا نام گنج را وارد کنید')
return
}
edit_id.value
? await editFund(edit_id.value, { title: name.value })
: await createFund({ title: name.value })
await fundStore.getMyFund()
message.success(`گنج با موفقیت ${edit_id.value ? 'ویرایش' : 'ساخته'} شد`)
router.back()
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
function goBack() {
router.back()
}
onMounted(() => {
if (edit_id.value) {
getEditInfo()
}
})
const context = {
types,
edit_id,
name,
prefixCls,
info,
itemImg,
ok,
goBack,
}
provide(FUND_NEW_PAGE_KEY, context)
return context
}
export function useFundNewPageContext() {
const context = inject(FUND_NEW_PAGE_KEY)
if (!context) {
throw new Error('useFundNewPageContext must be used within useFundNewPage')
}
return context
}
@@ -0,0 +1,81 @@
<template>
<div class="dfund-new">
<div class="dfund-new__container">
<header class="dfund-new-topbar">
<button type="button" class="dfund-new-topbar__back" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dfund-new-topbar__meta">
<p class="dfund-new-topbar__eyebrow">گنج</p>
<h1 class="dfund-new-topbar__title">
{{ edit_id ? `ویرایش گنج ${name}` : 'صندوق جدید' }}
</h1>
</div>
</header>
<a-spin :spinning="loading">
<section class="dfund-new__card dfund-surface">
<div class="dfund-new__preview">
<img :src="itemImg" alt="" class="dfund-new__preview-img" />
</div>
<div class="dfund-new__field" :class="[prefixCls]">
<label class="dfund-new__label">عنوان گنج</label>
<van-field
v-model="name"
label=""
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="dfund-new__tags">
<button
v-for="item in types"
:key="item.name"
type="button"
class="dfund-new__tag"
:class="{ 'dfund-new__tag--active': item.name === name }"
@click="name = item.name"
>
{{ item.name }}
</button>
</div>
<BasicButton class="dfund-new__submit" type="primary" :disabled="loading" @click="ok()">
<a-spin v-if="loading" size="small" />
<span v-else>{{ edit_id ? 'ویرایش گنج' : 'ساخت گنج' }}</span>
</BasicButton>
</section>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundNewPageContext } from '../composables/useFundNewPage'
const { types, edit_id, name, prefixCls, itemImg, ok, goBack } = useFundNewPageContext()
</script>
<style lang="less">
@prefix: ~'@{prefixCls}-input_name';
.@{prefix} {
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
}
</style>
+14 -185
View File
@@ -1,194 +1,23 @@
<template>
<div>
<div class="bg-white wrap-fix py-3 px-3 text-primary 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">{{edit_id? `ویرایش گنج ${name}`: 'صندوق جدید'}}</div>
</div>
<div @click="router.back()">
<!-- <BasicIcon icon="vuesax-linear:setting-4"/>-->
</div>
</div>
<a-spin :spinning="loading">
<div class="pt-20 flex flex-col justify-center items-center">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl"><img :src="itemImg" alt=""></div>
<!-- <div class="mt-1 text-xs">تغییر عکس</div>-->
<div class="flex mt-3 w-full relative px-5" :class="[prefixCls]">
<!-- <div class="text-primary font-bold mx-auto items-center">پس انداز</div>
<div class="absolute left-5 text-gray-400 text-xs">7/25</div>-->
<van-field
v-model="name"
label=""
class="font-bold"
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="flex overflow-x-scroll gap-2 whitespace-nowrap max-w-[320px] mt-2 px-3">
<!-- <BasicTags
:items="types"
color="green"
:has-add="false"
/>-->
<!-- <BasicButton v-for="item in types" size="small">{{item.name}}</BasicButton>-->
<div v-for="item in types"
class="border-1 px-3 py-2 flex-nowrap rounded text-xs color-transition background-color-transition"
:key="item.name"
:class="item.name === name? 'bg-primary text-white':'bg-gray-200 text-gray-600'"
@click="name = item.name">
{{ item.name }}
</div>
</div>
<!-- <div class="mt-2 font-bold flex text-xs items-center text-gray-500">
<div class="ml-1 text-base text-primary">{{ router.currentRoute.value.query.amount }}</div>
گرم
</div>
<div class="text-gray-400">موجودی صندوق {{itemName}}</div>
<div class="w-full p-5">
<div class="w-full flex gap-3">
<BasicButton class="w-full !bg-light" icon="vuesax-linear:add-square" icon-size="18">برداشت</BasicButton>
<BasicButton class="w-full !bg-light" icon="vuesax-linear:arrow-up" icon-size="18">واریز</BasicButton>
</div>
<div class="text-lg font-bold mt-5">تراکنش ها</div>
<div class="mt-5 pb-17">
<Item v-for="item in items" :is-up="item.isUp" :has-transition="item.hasTransition" :key="item"/>
</div>
</div>-->
<div class="wrap-fix flex justify-center bottom-23 px-5 z-200" @click="ok()">
<BasicButton class="rounded w-full px-5 py-2 text-center" type="primary" :disabled="loading">
<a-spin v-if="loading" size="small"/>
<div v-else>{{edit_id? 'ویرایش گنج':'ساخت گنج'}}</div>
</BasicButton>
</div>
</div>
</a-spin>
<div class="fund-new-page-root">
<DesktopFundNewView v-show="isDesktop" />
<MobileFundNewView v-show="!isDesktop" />
</div>
</template>
<script setup lang="ts">
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { useFundNewPage } from './composables/useFundNewPage'
import DesktopFundNewView from './desktop/DesktopFundNewView.vue'
import MobileFundNewView from './mobile/MobileFundNewView.vue'
import router from "@/router";
import {computed, onMounted, ref} from "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 {usePrefix} from "@/utils/usePrefix";
import {loading} from "@/utils";
import {createFund, editFund, getFundById} from "@/api/fund";
import {message} from "ant-design-vue/es";
import {useFundUserStore} from "@/store/fundStore";
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
import type {Fund} from "@/models/fund";
const fundStore = useFundUserStore()
const types = [
{
name: 'خرید لوازم منزل'
},
{
name: 'پس انداز'
},
{
name: 'گردش'
},
{
name: 'ازدواج'
},
{
name: 'تعمیرات'
},
{
name: 'هدیه'
},
]
const edit_id = computed(() => {
return parseInt(router.currentRoute.value.query.id as string)
})
const name = ref<string>('پس انداز')
const {prefixCls} = usePrefix('input_name')
const info = ref<Fund>()
async function getEditInfo() {
try {
loading.value = true
info.value = await getFundById(edit_id.value)
name.value = info.value?.title?? ''
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
onMounted(() => {
if (edit_id.value) {
getEditInfo()
}
})
const itemImg = computed(() => {
switch (name.value) {
case 'خرید لوازم منزل':
return homeImg
case 'هدیه':
return giftImg
case 'گردش':
return circulationImg
case 'ازدواج':
return loveImg
case 'تعمیرات':
return repairsImg
case 'پس انداز':
return savingImg
default:
return savingImg
}
})
async function ok() {
try {
loading.value = true
// console.log(name.value)
name.value ? edit_id.value? await editFund(edit_id.value,{title: name.value}) :await createFund({title: name.value}) : () => {
message.error('ابتدا نام گنج را وارد کنید')
return
}
await fundStore.getMyFund()
message.success(`گنج با موفقیت ${edit_id.value? 'ویرایش':'ساخته'} شد`)
router.back()
// name.value? message.warn('ss'): message.error('ابتدا نام صندوق را وارد کنید')
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
const isDesktop = useDesktopBreakpoint()
useFundNewPage()
</script>
<style lang="less">
@prefix: ~'@{prefixCls}-input_name';
.@{prefix} {
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
<style scoped>
.fund-new-page-root {
min-height: 100%;
height: 100%;
}
</style>
</style>
@@ -0,0 +1,74 @@
<template>
<div>
<div
class="bg-white wrap-fix py-3 px-3 text-primary font-bold drop-shadow flex h-12 z-20 justify-between"
>
<div class="flex">
<div class="cursor-pointer" @click="goBack()">
<BasicIcon icon="vuesax-linear:arrow-right-1" />
</div>
<div class="mr-2">{{ edit_id ? `ویرایش گنج ${name}` : 'صندوق جدید' }}</div>
</div>
</div>
<a-spin :spinning="loading">
<div class="pt-20 flex flex-col justify-center items-center">
<div class="bg-sky-100 w-[120px] aspect-square flex justify-center rounded-xl">
<img :src="itemImg" alt="" />
</div>
<div class="flex mt-3 w-full relative px-5" :class="[prefixCls]">
<van-field
v-model="name"
label=""
class="font-bold"
placeholder="عنوان را وارد کنید"
input-align="center"
:show-word-limit="true"
maxlength="25"
rows="1"
size="small"
/>
</div>
<div class="flex overflow-x-scroll gap-2 whitespace-nowrap max-w-[320px] mt-2 px-3">
<div
v-for="item in types"
:key="item.name"
class="border-1 px-3 py-2 flex-nowrap rounded text-xs color-transition background-color-transition"
:class="item.name === name ? 'bg-primary text-white' : 'bg-gray-200 text-gray-600'"
@click="name = item.name"
>
{{ item.name }}
</div>
</div>
<div class="wrap-fix flex justify-center bottom-23 px-5 z-200" @click="ok()">
<BasicButton class="rounded w-full px-5 py-2 text-center" type="primary" :disabled="loading">
<a-spin v-if="loading" size="small" />
<div v-else>{{ edit_id ? 'ویرایش گنج' : 'ساخت گنج' }}</div>
</BasicButton>
</div>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { loading } from '@/utils'
import { useFundNewPageContext } from '../composables/useFundNewPage'
const { types, edit_id, name, prefixCls, itemImg, ok, goBack } = useFundNewPageContext()
</script>
<style lang="less">
@prefix: ~'@{prefixCls}-input_name';
.@{prefix} {
.van-field__value {
@apply flex;
}
.van-field__body {
@apply w-full mr-5;
}
}
</style>
@@ -0,0 +1,52 @@
<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-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 { required } from '@/utils/inputRules'
import { useGiftCardPageContext } from '../composables/useGiftCardPage'
withDefaults(
defineProps<{
variant?: 'mobile' | 'desktop'
}>(),
{ variant: 'mobile' },
)
const {
form,
disableButton,
loading,
registerGiftCard,
} = useGiftCardPageContext()
</script>
@@ -0,0 +1,58 @@
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import { loading } from '@/utils'
import { registerCodeGiftCard } from '@/api/giftCard'
import { message } from 'ant-design-vue'
export interface GiftCardPageContext {
form: Ref<{ code: string }>
disableButton: ComputedRef<boolean>
loading: typeof loading
registerGiftCard: () => Promise<void>
}
export const GIFT_CARD_PAGE_CONTEXT_KEY: InjectionKey<GiftCardPageContext> =
Symbol('giftCardPageContext')
export function useGiftCardPage() {
const form = ref<{ code: string }>({
code: '',
})
const disableButton = computed(() => {
if (!loading.value) {
return !form.value.code || form.value.code.trim().length === 0
}
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 context: GiftCardPageContext = {
form,
disableButton,
loading,
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 a page that calls useGiftCardPage()')
}
return context
}
@@ -0,0 +1,50 @@
<template>
<div class="dgift">
<div class="dgift__container">
<header class="dgift-topbar">
<button type="button" class="dgift-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
<span>بازگشت</span>
</button>
<div class="dgift-topbar__meta">
<p class="dgift-topbar__eyebrow">هدایا</p>
<h1 class="dgift-topbar__title">ثبت کارت هدیه</h1>
</div>
</header>
<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 router from '@/router'
import GiftCardForm from '../components/GiftCardForm.vue'
</script>
@@ -0,0 +1,186 @@
/* 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;
}
.dgift-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;
}
}
.dgift-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 320px);
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(9, 61, 81, 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 16px;
}
&__submit {
width: 100%;
min-height: 44px;
margin-top: 8px;
}
}
.dgift-guide {
padding: 24px;
position: sticky;
top: 24px;
&__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;
}
svg,
.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 -67
View File
@@ -1,78 +1,24 @@
<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-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 isDesktop = useDesktopBreakpoint()
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
}
}
useGiftCardPage()
</script>
<style scoped>
.gift-card-page-root {
min-height: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,18 @@
<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">
<div class="border-gray-200 border-1 rounded-xl p-5 trans h-40 relative">
<bg_main />
<div class="z-100 relative">
<GiftCardForm variant="mobile" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Bg_main from '@/components/bg_main.vue'
import GiftCardForm from '../components/GiftCardForm.vue'
</script>
@@ -0,0 +1,79 @@
/* Desktop standalone gold trade page */
.dgold {
min-height: 100%;
background: #f6f7f9;
color: #111827;
padding: 28px 36px 40px;
}
.dgold-topbar {
display: flex;
align-items: flex-start;
gap: 16px;
max-width: 720px;
margin: 0 auto 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;
}
}
.dgold__panel {
max-width: 720px;
margin: 0 auto;
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);
overflow-y: auto;
max-height: calc(100vh - 180px);
padding-bottom: 16px;
}
.dgold__panel .mb-25vh {
margin-bottom: 2rem !important;
}
.dgold__panel .bottom-18 {
position: static !important;
margin-top: 1rem;
}
+64 -2
View File
@@ -1,5 +1,16 @@
<template>
<div class="h-35rem overflow-y-auto">
<div :class="isStandaloneDesktop ? 'dgold' : 'h-35rem overflow-y-auto'">
<header v-if="isStandaloneDesktop" class="dgold-topbar">
<button type="button" class="dgold-topbar__back" @click="router.back()">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="18" />
بازگشت
</button>
<div class="dgold-topbar__meta">
<p class="dgold-topbar__eyebrow">معامله</p>
<h1 class="dgold-topbar__title">خرید و فروش {{ tradeProductLabel }}</h1>
</div>
</header>
<div :class="isStandaloneDesktop ? 'dgold__panel' : ''">
<div class=" !overflow-y-scroll bg-white py-3 text-primary font-bold flex justify-evenly">
<!-- <div @click="isBuy = false" class="w-full text-center" :class="!isBuy?'text-primary':'text-gray-400'">خرید طلا</div>
<div @click="isBuy = true" class="w-full text-center" :class="isBuy?'text-primary':'text-gray-400'">فروش طلا</div>-->
@@ -150,7 +161,7 @@
</div>-->
</div>
<BasicActionSheet v-model:show="confirmAction">
<BasicActionSheet v-if="!isStandaloneDesktop" v-model:show="confirmAction">
<div class="font-bold text-lg"></div>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
@@ -185,6 +196,52 @@
</div>
<BasicButton class="mb-20 mt-5 w-full" type="primary" @click="ok(); confirmAction= false">ثبت نهایی</BasicButton>
</BasicActionSheet>
<BasicModal
v-else
v-model:visible="confirmAction"
title="تأیید سفارش"
width="480px"
destroy-on-close
custom-footer
>
<div class="w-full rounded-lg bg-gray-50 p-3">
<div class="flex justify-between ">
<div>وزن:</div>
<div class="flex items-center">
<div class="font-bold">{{ form.goldValue }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>مظنه:</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(Math.round(goldPriceValue)) }}</div>
<div class="text-xs mr-1">ریال</div>
</div>
</div>
<div class="flex justify-between mt-3">
<div>کارمزد:</div>
<div class="flex items-center gap-2">
<div class="font-bold text-xl">{{ feeInGrams }}</div>
<div class="text-xs mr-1">{{ weightUnit }}</div>
</div>
</div>
<div>(معادل {{ equivalentPrice }} ریال)</div>
<div class="flex justify-between mt-3">
<div>{{ isBuy ? 'قیمت قابل پرداخت:' : 'مبلغ دریافتی:' }}</div>
<div class="flex items-center">
<div class="font-bold">{{ usePriceWithoutType(payablePrice.toFixed(0)) }}</div>
<div class="text-xs mr-1">ریال</div>
</div>
</div>
</div>
<BasicButton class="mt-5 w-full" type="primary" @click="ok(); confirmAction = false">
ثبت نهایی
</BasicButton>
<template #footer>
<div></div>
</template>
</BasicModal>
<van-dialog v-model:show="noCompleteVisible" class="completeButtonStyle" confirm-button-text="تکمیل اطلاعات"
show-confirm-button @confirm="router.push({name: 'user',query:{showEdit: true}})">
<div class="flex flex-col items-center p-2">
@@ -197,6 +254,7 @@
</div>
</div>
</van-dialog>
</div>
</div>
</template>
@@ -218,6 +276,8 @@ import { getLastPrice } from '@/api/home'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import { justNumber } from '@/utils/inputRules'
import GoldSkeleton from '@/views/base/gold/components/GoldSkeleton.vue'
import GoldLoading from '@/components/GoldLoading.vue'
@@ -274,6 +334,8 @@ interface Prop {
}
const props = withDefaults(defineProps<Prop>(), {})
const isDesktop = useDesktopBreakpoint()
const isStandaloneDesktop = computed(() => isDesktop.value && !props.ProductItem)
const isBuy = ref<boolean>(true)
const tradeProductLabel = computed(() => {
+59 -12
View File
@@ -1,13 +1,17 @@
<template>
<div
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
>
<div class="flex justify-between my-2">
<div class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div class="flex justify-between my-2 items-center">
<div v-if="isBoursecuProject()" class="flex items-center gap-2">
<img :src="projectLogo" alt="" class="h-8 w-auto object-contain" />
<div class="color-transition font-bold text-primary">{{ appName }}</div>
</div>
<div v-else class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
>
<Icon icon="vuesax-linear:user" size="20" />
</div>
@@ -15,26 +19,69 @@
{{ userStore.userInfo?.name }}
</div>
</div>
<Icon
:color="y > 50 ? '#0A1B37' : '#0A1B37'"
class="color-transition"
icon="vuesax-linear:logout"
<div class="flex items-center gap-2">
<button
v-if="isBoursecuProject()"
type="button"
class="mhome-header-btn"
@click="router.push({ name: 'user' })"
>
<Icon icon="vuesax-linear:user" size="18" />
</button>
<button
type="button"
class="mhome-header-btn mhome-header-btn--logout"
@click="$emit('logout')"
/>
>
<Icon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowScroll } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import router from '@/router'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
const { y } = useWindowScroll()
const userStore = useUserStore()
const config = useConfigStore()
const projectLogo = computed(() => getProjectNavLogo())
const appName = computed(() => config.config?.name_fa || config.config?.name || 'بورس مس')
defineEmits<{
logout: []
}>()
</script>
</script>
<style scoped>
.mhome-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #0a1b37;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.mhome-header-btn--logout {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
}
</style>
@@ -1,26 +1,52 @@
<template>
<div class="bg-primary rounded-lg p-2 py-3 mt-2">
<div v-if="meta?.has_count_based_products" class="flex justify-center text-tertiary w-full z-20">
<div :class="plain ? 'py-2' : 'bg-primary rounded-lg p-2 py-3 mt-2'">
<div
v-if="!boursecuLayout && meta?.has_count_based_products"
:class="rowClass"
class="flex justify-center w-full z-20"
>
محصولات:
<div class="text-l mr-2 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="meta?.single_metal && primaryMetalTotal">
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
<template v-if="boursecuAssetRows.length">
<div
v-for="(row, index) in boursecuAssetRows"
:key="row.id"
:class="[rowClass, index > 0 ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ row.label }}:
<div class="text-l mr-2 font-bold">{{ row.value }}</div>
<BasicIcon
v-if="row.info !== 'none'"
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="handleRowInfo(row)"
/>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div :class="[rowClass, meta?.has_count_based_products ? 'mt-2' : '']" class="flex justify-center w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', primaryMetalTotal)"
/>
</div>
</template>
<template v-else>
<template v-else-if="!boursecuLayout">
<div
v-for="metal in metalTotals"
v-for="(metal, index) in metalTotals"
:key="metal.metal_id"
class="flex mt-2 justify-center text-tertiary w-full z-20"
:class="[rowClass, index > 0 || meta?.has_count_based_products ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(metal) }}</div>
@@ -28,48 +54,79 @@
v-if="metal.metal_id === metalTotals[0]?.metal_id"
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', metal)"
/>
</div>
<div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="$emit('openRialModal')"
/>
</div>
</template>
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
<BasicIcon @click="$emit('openRialModal')" icon="vuesax-linear:info-circle" size="18" color="tertiary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight } from '@/utils/productAssets'
import {
buildBoursecuHomeAssetRows,
resolveSummaryMetalWeight,
type HomeAssetRow,
} from '@/utils/productAssets'
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const props = withDefaults(
defineProps<{
plain?: boolean
boursecuLayout?: boolean
}>(),
{ plain: false, boursecuLayout: false }
)
defineEmits<{
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
const { assetsSummary } = useWalletAssets()
const rowClass = computed(() => (props.plain ? 'text-primary' : 'text-tertiary'))
const iconColor = computed(() => (props.plain ? 'primary' : 'tertiary'))
const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const boursecuAssetRows = computed(() =>
props.boursecuLayout ? buildBoursecuHomeAssetRows(assetsSummary.value) : []
)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
onMounted(() => {
void loadWalletAssets()
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function handleRowInfo(row: HomeAssetRow) {
if (row.info === 'rial') {
emit('openRialModal')
return
}
if (row.metal) {
emit('openGoldModal', row.metal)
}
}
</script>
@@ -50,7 +50,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { computed, watch } from 'vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import { usePrice } from '@/utils/usePrice'
@@ -84,10 +84,6 @@ const metalRialValue = computed(() =>
)
const totalRialValue = computed(() => cashWallet.value + metalRialValue.value)
onMounted(() => {
void loadWalletAssets()
})
watch(
() => props.show,
(open) => {
@@ -0,0 +1,41 @@
<script setup lang="ts">
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { isBoursecuProject } from '@/utils/config'
import type { HomeQuickActionItem } from '../layouts/types'
defineProps<{
action: HomeQuickActionItem
}>()
defineEmits<{
click: []
}>()
const isBoursecu = isBoursecuProject()
</script>
<template>
<button
type="button"
class="home-quick-action-box flex flex-col items-center justify-center gap-2 rounded-xl border-2 p-3 min-h-[96px] w-full"
:class="{ 'home-quick-action-box--boursecu': isBoursecu }"
@click="$emit('click')"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center">
<Icon class="text-primary" :icon="action.icon" size="28" />
</div>
<span class="text-primary text-xs font-bold text-center leading-5">{{ action.label }}</span>
</button>
</template>
<style scoped>
.home-quick-action-box {
appearance: none;
cursor: pointer;
}
.home-quick-action-box--boursecu {
background-color: #eff5f3;
border-color: #d4e3de;
}
</style>
+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>
+197 -170
View File
@@ -1,201 +1,228 @@
<template>
<div class="bg-gray-50 w-full flex justify-evenly rounded-lg py-4 mt-4 h-[15rem]">
<div
class="bg-gray-50 w-full rounded-lg py-4"
:class="[
chartContainerClass,
variant === 'desktop' ? 'mt-0' : 'mt-4',
layout === 'boursecu' && variant !== 'desktop' ? 'price-chart-box--boursecu' : '',
]"
>
<div class="w-full text-center pt-2">
<a-radio-group v-model:value="selectedValue" button-style="solid">
<div v-if="showMetalSelector" class="chart-metal-segment mb-3">
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'katod' }"
@click="emit('update:selectedMetal', 'katod')"
>
کاتد مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'maftol' }"
@click="emit('update:selectedMetal', 'maftol')"
>
مفتول مس
</button>
</div>
<a-radio-group v-model:value="selectedValue" button-style="solid" class="chart-timeline-group">
<a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button>
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group>
<div class="min-h-[200px] relative">
<div v-if="hasChartData" ref="chartEl" class="h-[200px] w-full" />
<div
v-else
class="h-[200px] flex items-center justify-center text-gray-400 text-sm"
>
داده نمودار در دسترس نیست
</div>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
type="area"
width="100%"
:height="chartHeight"
:options="chartOptions"
:series="props.priceChartSeries"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { usePrice } from '@/utils/usePrice'
import { getProjectFontFamily } from '@/utils/config'
import { useElementSize } from '@vueuse/core'
type ChartPoint = [number, number]
import type { ChartMetalType } from '../composables/usePriceChart'
const props = defineProps<{
priceChartSeries?: Array<{ name?: string; data?: unknown[] }> | null
}>()
const props = withDefaults(
defineProps<{
priceChartSeries: any[]
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
chartSelection?: string
selectedMetal?: ChartMetalType
}>(),
{
layout: 'default',
variant: 'mobile',
chartSelection: 'DAILY',
selectedMetal: 'katod',
}
)
const chartHeight = computed(() => (props.variant === 'desktop' ? 320 : 200))
const chartContainerClass = computed(() => {
if (props.variant === 'desktop') {
return 'min-h-[22rem]'
}
return props.layout === 'default' ? 'min-h-[15rem] h-[15rem]' : 'min-h-[15rem]'
})
const chartInnerClass = computed(() =>
props.variant === 'desktop' ? 'min-h-[320px]' : 'min-h-[200px]'
)
const showMetalSelector = computed(() => props.layout === 'boursecu')
const emit = defineEmits<{
updateData: [timeline: string]
'update:selectedMetal': [metal: ChartMetalType]
}>()
const configStore = useConfigStore()
const selectedValue = ref('DAILY')
const chartEl = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let renderToken = 0
const config = useConfigStore()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const selectedValue = ref(props.chartSelection)
function extractPoints(): ChartPoint[] {
const source = props.priceChartSeries
if (!Array.isArray(source) || source.length === 0) {
return []
}
const { width: chartWidth } = useElementSize(chartContainerRef)
const raw = source[0]?.data
if (!Array.isArray(raw)) {
return []
}
return raw
.map((item) => {
if (!Array.isArray(item) || item.length < 2) {
return null
}
let x = Number(item[0])
let y = Number(item[1])
if (!Number.isFinite(x) || !Number.isFinite(y) || y <= 0) {
return null
}
if (x > 0 && x < 1e11) {
x *= 1000
}
return [x, y] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
.sort((a, b) => a[0] - b[0])
}
const chartPoints = computed(() => extractPoints())
const hasChartData = computed(() => chartPoints.value.length >= 2)
const toPersianNumber = (num: number) =>
num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
function buildOption(points: ChartPoint[]): EChartsOption {
const color = configStore.config?.color || '#CEA34C'
return {
animation: true,
grid: {
left: 4,
right: 4,
top: 8,
bottom: 4,
containLabel: false
},
xAxis: {
type: 'time',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false,
scale: true
},
tooltip: {
trigger: 'axis',
confine: true,
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params
if (!row || !Array.isArray(row.value)) {
return ''
}
const [timestamp, value] = row.value as [number, number]
const date = new Date(timestamp)
const dateText = `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}`
return `${dateText}<br/>${toPersianNumber(usePrice(value, true, 'ه.ریال'))}`
}
},
series: [
{
name: 'قیمت',
type: 'line',
smooth: true,
showSymbol: false,
data: points,
lineStyle: {
width: 2,
color
},
areaStyle: {
color,
opacity: 0.25
}
}
]
}
}
function destroyChart() {
if (!chart) {
return
}
chart.dispose()
chart = null
}
async function renderChart() {
const token = ++renderToken
if (!hasChartData.value) {
destroyChart()
return
}
await nextTick()
await nextTick()
if (token !== renderToken || !chartEl.value) {
return
}
const points = chartPoints.value
try {
if (!chart || chart.isDisposed()) {
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
}
chart.setOption(buildOption(points), true)
chart.resize()
} catch (error) {
console.error('Price chart render failed:', error)
destroyChart()
}
}
watch(
() => [chartPoints.value.length, selectedValue.value, configStore.config?.color] as const,
() => {
void renderChart()
},
{ immediate: true, flush: 'post' }
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
)
onBeforeUnmount(() => {
renderToken++
destroyChart()
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
}
)
onMounted(async () => {
await nextTick()
chartMounted.value = true
})
const toPersianNumber = (num: number) => {
return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
}
const chartOptions = computed(() => {
const fontFamily = getProjectFontFamily()
return {
chart: {
id: 'نوسانات قیمت',
type: 'area',
height: 200,
toolbar: { show: false },
zoom: { autoScaleYaxis: true },
redrawOnParentResize: true,
},
grid: {
padding: { top: 0, right: 0, bottom: 0, left: 0 },
yaxis: { lines: { show: false } },
},
stroke: { width: 2 },
dataLabels: {
enabled: false,
style: {
fontFamily,
},
},
yaxis: {
tickAmount: 8,
show: false,
labels: {
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontSize: '13px', fontFamily },
},
},
xaxis: {
show: false,
type: 'datetime',
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { show: false },
},
tooltip: {
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
},
},
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontFamily },
},
colors: [config.config.color],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.9,
stops: [0, 100],
},
},
}
})
const handleUpdate = (timeline: string) => {
selectedValue.value = timeline
emit('updateData', timeline)
}
</script>
<style scoped lang="less">
@boursecu-surface-border: #d4e3de;
.price-chart-box--boursecu {
border: 2px solid @boursecu-surface-border;
}
.chart-metal-segment {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #dfe8e5;
}
.chart-metal-segment__item {
appearance: none;
cursor: pointer;
min-width: 6.5rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 8px;
background: transparent;
color: @App-primary-color;
opacity: 0.65;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.chart-metal-segment__item--active {
background: #fff;
color: @App-primary-color;
opacity: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(10, 27, 55, 0.12);
}
</style>
+35 -7
View File
@@ -1,5 +1,11 @@
<template>
<div class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in mt-6 pb-45">
<div
class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in"
:class="[
layout === 'boursecu' ? 'mt-8 pt-2' : 'mt-6',
variant === 'mobile' ? 'pb-45' : 'mt-0 pb-0',
]"
>
<div class="text-center w-full">
<div>بالاترین قیمت امروز</div>
<div v-if="price" class="flex flex-col items-center mt-3">
@@ -26,13 +32,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
import { usePriceWithoutType } from '../../../../utils/usePrice'
import type { ChartMetalType } from '../composables/usePriceChart'
import { resolveMetalDayRangePrices } from '../composables/usePriceChart'
const props = defineProps<{
price: any
}>()
const props = withDefaults(
defineProps<{
price: any
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
selectedMetal?: ChartMetalType
maftolRatio?: number
}>(),
{
layout: 'default',
variant: 'mobile',
selectedMetal: 'katod',
maftolRatio: 0,
}
)
const highPrice = computed(() => props.price?.up?.buy ?? getTradeBuyPrice(props.price))
const lowPrice = computed(() => props.price?.up?.sell ?? getTradeSellPrice(props.price))
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: props.price,
metal: props.selectedMetal,
isBoursecuLayout: props.layout === 'boursecu',
maftolRatio: props.maftolRatio,
})
)
const highPrice = computed(() => rangePrices.value.high)
const lowPrice = computed(() => rangePrices.value.low)
</script>
@@ -1,35 +1,32 @@
<template>
<div class="bg-primary rounded-lg p-2 py-1 mt-2">
<div class="p-2">
<div
v-if="userStore.userInfo?.referral_id"
class="text-tertiary font-bold w-full text-center flex justify-between gap-2 items-center"
<div class="home-referral bg-primary rounded-lg">
<div
v-if="userStore.userInfo?.referral_id"
class="home-referral__code text-tertiary font-bold"
>
<span>کد معرف:</span>
<span class="home-referral__code-value">{{ userStore.userInfo?.referral_id }}</span>
</div>
<div class="home-referral__actions">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
کد معرف:
<div class="flex gap-2">
<div class="mr-2">{{ userStore.userInfo?.referral_id }}</div>
</div>
</div>
<div class="w-full flex gap-2 mt-1">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
</div>
</template>
@@ -58,4 +55,76 @@ const copyCode = () => {
message.info('کد رونوشت شد')
}
}
</script>
</script>
<style scoped lang="less">
.home-referral {
container-type: inline-size;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 8px;
padding: 10px 12px;
overflow: hidden;
&__code {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
margin-bottom: 8px;
}
&__code-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
gap: 8px;
width: 100%;
min-width: 0;
}
&__btn {
flex: 1 1 0;
min-width: 0 !important;
max-width: 100%;
:deep(.ant-btn) {
width: 100%;
min-width: 0;
max-width: 100%;
height: auto;
padding-inline: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.ant-btn > div) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-width: 340px) {
&__actions {
flex-direction: column;
}
&__btn {
flex: none;
width: 100%;
}
}
}
</style>
@@ -42,10 +42,15 @@ import { message } from 'ant-design-vue'
import { useUserStore } from '@/store/userStore'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
const props = defineProps<{
show: boolean
referralId: string
}>()
const props = withDefaults(
defineProps<{
show: boolean
referralId?: string
}>(),
{
referralId: '',
}
)
const emit = defineEmits<{
'update:show': [value: boolean]
+84 -53
View File
@@ -2,62 +2,93 @@ import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
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 []
}
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) {
if (!force && bannersLoaded) {
return
}
if (!force && bannersPromise) {
return bannersPromise
}
bannersPromise = (async () => {
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
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() {
const banners = ref([])
const loading = ref(false)
const loadBanners = async () => {
try {
loading.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((b: { show?: boolean }) => b?.show === true)
} catch (error) {
console.error('Error loading banners:', error)
banners.value = []
} finally {
loading.value = false
}
}
return { banners, loading, loadBanners }
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,240 @@
import { computed, inject, onMounted, onUnmounted, provide, ref, type InjectionKey } from 'vue'
import router from '@/router'
import { loading, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { formatMetalAssetsInfoText, formatRialAssetsInfoText, resolveAssetsRialValue } from '@/utils/productAssets'
import { useConfigStore } from '@/store/configStore'
import { getTradeBuyPrice, lastPrice, stopLastPricePolling } from '@/utils/price'
import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours'
import { message, Modal } from 'ant-design-vue'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
import { useBanners } from './useBanners'
import { usePriceChart, type ChartMetalType } from './usePriceChart'
import type { HomeActionItem } from '../layouts/types'
import { getHomeLayoutId } from '../layouts/registry'
import { initHomePageData, resetHomePageData } from './useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
import { resetAddressPageData } from '../../address/composables/useAddressPageData'
import { resetTransactionsPageData } from '../../transactions/composables/useTransactionsPageData'
import { resetProductsPageData } from '../../products/composables/useProductsPageData'
export interface HomePageContext {
loading: typeof loading
isGaroosi: ReturnType<typeof computed<boolean>>
limitedPrice: ReturnType<typeof computed>
showClosedMessage: ReturnType<typeof computed<boolean>>
closedMessage: ReturnType<typeof computed>
mainActions: ReturnType<typeof computed<HomeActionItem[]>>
banners: ReturnType<typeof useBanners>['banners']
priceChartSeries: ReturnType<typeof usePriceChart>['priceChartSeries']
chartSelection: ReturnType<typeof usePriceChart>['chartSelection']
updateChartData: ReturnType<typeof usePriceChart>['updateChartData']
selectedChartMetal: ReturnType<typeof usePriceChart>['selectedChartMetal']
setChartMetal: (metal: ChartMetalType) => void
isBoursecuHomeLayout: ReturnType<typeof computed<boolean>>
handleBannerClick: (banner: { typeClick?: string; modalText?: string; link?: string }) => void
openGoldModal: (metal: WalletMetalTotal) => void
openRialModal: () => void
}
export const HOME_PAGE_CONTEXT_KEY: InjectionKey<HomePageContext> = Symbol('homePageContext')
export function useHomePage() {
const userStore = useUserStore()
const walletStore = useWalletStore()
const configStore = useConfigStore()
const { assetsSummary, clearWalletAssets } = useWalletAssets()
const showLogoutConfirm = ref(false)
const wholeBalanceVisible = ref(false)
const showTextBanner = ref(false)
const showShare = ref(false)
const TextBanner = ref('')
const { banners } = useBanners()
const {
updateChartData,
priceChartSeries,
chartSelection,
selectedChartMetal,
setChartMetal,
} = usePriceChart()
const isBoursecuHomeLayout = computed(() => getHomeLayoutId() === 'boursecu')
const isGaroosi = computed(() => configStore.config?.name === 'garoosi')
const closedMessage = computed(() => userStore.getValueByKey('closed_message'))
const marketClosedText = computed(() => {
if (!isStoreClosed()) return ''
return closedMessage.value || getStoreClosedMessage()
})
const showClosedMessage = computed(() => !!marketClosedText.value)
const limitedPrice = computed(() => walletStore.limitedPrice?.[0])
const goldVal = computed(() => lastPrice.value?.price_last ?? 1)
const mainActions = computed<HomeActionItem[]>(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({ name: 'buyProduct' }),
},
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: isGaroosi.value ? 'واریز و برداشت' : 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: isGaroosi.value ? 'دعوت از دوستان' : 'دارایی ها',
handler: () => (isGaroosi.value ? shareCode() : router.push({ name: 'fund' })),
},
])
async function shareCode() {
const text = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: configStore.config.name,
referralId: userStore.userInfo?.referral_id,
})
try {
await navigator.share({
title: getReferralShareTitle(configStore.config.name),
text,
})
} catch {
copyCode()
}
}
function copyCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
clearWalletAssets()
resetHomePageData()
resetWalletPageData()
resetAddressPageData()
resetTransactionsPageData()
resetProductsPageData()
removeToken()
await router.push({ name: 'login' })
window.location.reload()
}
function openGoldModal(metal: WalletMetalTotal) {
Modal.confirm({
title: formatMetalAssetsInfoText(metal),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function openRialModal() {
const summary = assetsSummary.value
const cash = summary?.cash_wallet ?? 0
const metalRial = resolveAssetsRialValue(summary, getTradeBuyPrice(lastPrice.value))
Modal.confirm({
title: 'دارایی ریالی',
content: formatRialAssetsInfoText(cash, metalRial),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function handleBannerClick(banner: { typeClick?: string; modalText?: string; link?: string }) {
const handlers: Record<string, () => void> = {
share: () => {
showShare.value = true
},
modal: () => {
if (banner.modalText) {
TextBanner.value = banner.modalText
showTextBanner.value = true
}
},
link: () => {
if (banner.link) {
if (banner.link.startsWith('http')) {
window.open(banner.link, '_blank')
} else {
router.push(banner.link)
}
}
},
}
handlers[banner.typeClick ?? '']?.()
}
const context: HomePageContext = {
loading,
isGaroosi,
limitedPrice,
showClosedMessage,
closedMessage: marketClosedText,
mainActions,
banners,
priceChartSeries,
chartSelection,
updateChartData,
selectedChartMetal,
setChartMetal,
isBoursecuHomeLayout,
handleBannerClick,
openGoldModal,
openRialModal,
}
provide(HOME_PAGE_CONTEXT_KEY, context)
onUnmounted(() => {
stopLastPricePolling()
})
onMounted(() => {
// Single shared fetch for mobile + desktop — safe to call on remount (deduped in useHomePageData)
void initHomePageData()
})
return {
userStore,
walletStore,
showLogoutConfirm,
wholeBalanceVisible,
showTextBanner,
showShare,
TextBanner,
goldVal,
logout,
}
}
export function useHomePageContext() {
const context = inject(HOME_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useHomePageContext must be used within useHomePage')
}
return context
}
@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { loading } from '@/utils'
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'
/**
* Single entry point for all homepage API calls.
* Mobile and desktop layouts must not fetch independently — they consume shared module state.
*/
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export function isHomePageDataInitialized() {
return initialized.value
}
export async function initHomePageData(force = false) {
if (!force && initialized.value) {
// Resume live price updates when returning to home without refetching datasets
startLastPricePolling()
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
loading.value = true
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 {
loading.value = false
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
stopLastPricePolling()
resetHomeBanners()
resetHomeFunds()
resetHomePriceChart()
clearProductsCatalog()
}
export function useHomePageInitState() {
return { initialized }
}
+229 -97
View File
@@ -1,113 +1,245 @@
import { ref } from 'vue'
import { getAllPrices, getChart } from '@/api/home'
import { ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
import type { Product } from '@/models/products'
import { getProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
import { isKatodMesGroup, isMaftolMesGroup } from '@/utils/productDealMethod'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol'
type ChartPoint = [number, number]
function mapTimelinePoints(items: unknown[]): ChartPoint[] {
if (!Array.isArray(items)) {
return []
}
/** 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 maftolChartRatio = ref(0)
return items
.map((item) => {
const row = item as { timestamp?: number; value?: number; price?: number }
let timestamp = Number(row.timestamp)
const value = Number(row.value ?? row.price ?? 0)
let chartFetchPromise: Promise<void> | null = null
let chartInitialized = false
let maftolOffsetPromise: Promise<void> | null = null
let maftolOffsetLoaded = false
if (!Number.isFinite(timestamp) || !Number.isFinite(value) || value <= 0) {
return null
}
if (timestamp > 0 && timestamp < 1e11) {
timestamp *= 1000
}
return [timestamp, value] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
function findKatodProduct(products: Product[]) {
return (
products.find((product) => isKatodMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('کاتد'))
)
}
async function fetchCandleFallback(): Promise<ChartPoint[]> {
function findMaftolProduct(products: Product[]) {
return (
products.find((product) => isMaftolMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('مفتول'))
)
}
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
maftolRatio: 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.maftolRatio),
low: applyChartRatio(baseLow, options.maftolRatio),
}
}
function deriveMaftolChartRatio(maftolProduct?: Product, katodProduct?: Product) {
const offsetRatio = resolvePriceOffsetRatio(maftolProduct?.price_offset_percent)
if (offsetRatio) {
return offsetRatio
}
if (!maftolProduct) {
return 0
}
const basePrice = getTradeBuyPrice(lastPrice.value)
const katodPrice = katodProduct ? getProductBuyPrice(katodProduct) : basePrice
const maftolPrice = getProductBuyPrice(maftolProduct)
const referencePrice = katodPrice || basePrice
if (!referencePrice || !maftolPrice || referencePrice === maftolPrice) {
return 0
}
return (maftolPrice - 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 data =
getHomeLayoutId() === 'boursecu' && selectedChartMetal.value === 'maftol'
? rawChartPoints.value.map(
([timestamp, value]) =>
[timestamp, applyChartRatio(value, maftolChartRatio.value)] as ChartPoint
)
: rawChartPoints.value
priceChartSeries.value[0].data = data
}
watch([selectedChartMetal, maftolChartRatio], () => {
syncChartSeries()
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
return
}
if (!force && chartFetchPromise) {
return chartFetchPromise
}
chartFetchPromise = (async () => {
try {
const rows = await getChart()
if (!Array.isArray(rows)) {
return []
}
return rows
.map((row) => {
if (!row) {
return null
}
const dateRaw = Array.isArray(row) ? row[0] : row.date
const candle = Array.isArray(row) ? row[1] : row.candle
let value = 0
if (Array.isArray(candle)) {
value = Number(candle[3] ?? candle[2] ?? candle[0] ?? 0)
} else if (candle && typeof candle === 'object') {
const candleRow = candle as { end?: number; high?: number; start?: number }
value = Number(candleRow.end ?? candleRow.high ?? candleRow.start ?? 0)
} else {
value = Number(candle ?? 0)
}
let timestamp = typeof dateRaw === 'number'
? dateRaw
: new Date(String(dateRaw)).getTime()
if (timestamp > 0 && timestamp < 1e11) {
timestamp *= 1000
}
if (!Number.isFinite(timestamp) || !Number.isFinite(value) || value <= 0) {
return null
}
return [timestamp, value] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
} catch {
return []
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 loadMaftolOffset(force = false) {
if (getHomeLayoutId() !== 'boursecu') {
maftolChartRatio.value = 0
syncChartSeries()
return
}
if (!force && maftolOffsetLoaded) {
return
}
if (!force && maftolOffsetPromise) {
return maftolOffsetPromise
}
maftolOffsetPromise = (async () => {
try {
const products = await getProductsCatalog(force)
const katodProduct = findKatodProduct(products)
const maftolProduct = findMaftolProduct(products)
maftolChartRatio.value = deriveMaftolChartRatio(maftolProduct, katodProduct)
syncChartSeries()
maftolOffsetLoaded = true
} catch (error) {
console.error('Error loading maftol chart ratio:', error)
maftolChartRatio.value = 0
syncChartSeries()
} finally {
maftolOffsetPromise = null
}
})()
return maftolOffsetPromise
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
void fetchPrices(true)
}
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
}
export function usePriceChart() {
const priceChartSeries = ref([{ name: 'قیمت', data: [] as ChartPoint[] }])
const selection = ref('DAILY')
const chartLoading = ref(false)
return {
priceChartSeries,
chartSelection,
chartLoading,
selectedChartMetal,
maftolChartRatio,
updateChartData,
fetchPrices,
loadMaftolOffset,
setChartMetal,
}
}
const fetchPrices = async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const prices = response?.data?.prices ?? response?.prices
export function fetchHomeChartPrices(force = false) {
return fetchPrices(force)
}
let points: ChartPoint[] = []
if (prices && typeof prices === 'object') {
const selectedData = prices[selection.value]
points = mapTimelinePoints(Array.isArray(selectedData) ? selectedData : [])
}
export function loadHomeMaftolOffset(force = false) {
return loadMaftolOffset(force)
}
if (points.length === 0) {
points = await fetchCandleFallback()
}
priceChartSeries.value = [{ name: 'قیمت', data: points }]
} catch (error) {
console.error('Error fetching prices:', error)
const fallback = await fetchCandleFallback()
priceChartSeries.value = [{ name: 'قیمت', data: fallback }]
} finally {
chartLoading.value = false
}
}
const updateChartData = (timeline: string) => {
selection.value = timeline
fetchPrices()
}
return { priceChartSeries, chartLoading, updateChartData, fetchPrices }
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
maftolChartRatio.value = 0
chartInitialized = false
chartFetchPromise = null
maftolOffsetLoaded = false
maftolOffsetPromise = null
}
+48 -16
View File
@@ -1,22 +1,54 @@
import { ref } from 'vue'
import { useFundUserStore } from '@/store/fundStore'
export function useUserFunds() {
const funds = ref([])
const loading = ref(false)
const fundStore = useFundUserStore()
/** Module-level fund list — shared across home layout variants. */
const funds = ref<unknown[]>([])
const fundsLoading = ref(false)
let fundsPromise: Promise<void> | null = null
let fundsLoaded = false
const loadFunds = async () => {
try {
loading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
} catch (error) {
console.error('Error loading funds:', error)
} finally {
loading.value = false
}
async function fetchFunds(force = false) {
if (!force && fundsLoaded) {
return
}
if (!force && fundsPromise) {
return fundsPromise
}
const fundStore = useFundUserStore()
fundsPromise = (async () => {
try {
fundsLoading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
fundsLoaded = true
} catch (error) {
console.error('Error loading funds:', error)
} finally {
fundsLoading.value = false
fundsPromise = null
}
})()
return { funds, loading, loadFunds }
}
return fundsPromise
}
export function useUserFunds() {
return {
funds,
loading: fundsLoading,
loadFunds: fetchFunds,
}
}
export function loadHomeFunds(force = false) {
return fetchFunds(force)
}
export function resetHomeFunds() {
funds.value = []
fundsLoaded = false
fundsPromise = null
}
@@ -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,128 @@
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { buildBoursecuHomeAssetRows, resolveSummaryMetalWeight } from '@/utils/productAssets'
import { useHomePageContext } from '../../composables/useHomePage'
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
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 totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 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: `${countBasedInventory.value} عدد`,
})
}
if (isBoursecuLayout.value) {
buildBoursecuHomeAssetRows(assetsSummary.value).forEach((row) => {
items.push({
id: row.id,
label: row.label,
value: row.value,
metal: row.metal,
showInfo: row.info !== 'none',
onInfo: row.info === 'rial' ? 'rial' : 'gold',
})
})
return items
}
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(totalRialValue.value)} ریال`,
showInfo: true,
onInfo: 'rial',
})
return items
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${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>
<div 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,20 @@
<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'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
</script>
<template>
<DesktopHomeSection
v-if="ctx.isGaroosi.value && ctx.banners.value?.length"
title="بنرها"
>
<BannerSlider
:banners="ctx.banners.value"
@bannerClick="ctx.handleBannerClick"
/>
</DesktopHomeSection>
</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,27 @@
<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 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>
<DesktopHomeSection title="نمودار قیمت" 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"
/>
</DesktopHomeSection>
</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,36 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useUserStore } from '@/store/userStore'
import { usePriceWithoutType } from '@/utils/usePrice'
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 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="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,46 @@
<template>
<div class="dhome-surface dhome-hero-price">
<span class="dhome-label">قیمت لحظه‌ای</span>
<p 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'"
>
{{ useTowDecimal(percentChange) }}%
</span>
<Vue3Odometer
:value="displayGoldVal"
class="dhome-hero-price__odometer o-text"
style="font-family: var(--App-font-family)"
/>
<span class="dhome-hero-price__unit">ریال</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import { useConfigStore } from '@/store/configStore'
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
import { useTowDecimal } from '@/utils/usePrice'
const config = useConfigStore()
const isMarketClosed = computed(
() => lastPrice?.value?.goldir === 0 && getTradeBuyPrice(lastPrice.value) === 0,
)
const displayGoldVal = computed(() => {
if (isMarketClosed.value) return 0
return getTradeBuyPrice(lastPrice.value)
})
const percentChange = computed(() => {
if (!getTradeBuyPrice(lastPrice.value)) return 0
return percentPriceChanged()
})
</script>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import { price } from '@/utils/price'
import CurrentPrice from '../../components/CurrentPrice.vue'
import PriceStats from '../../components/PriceStats.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
const ctx = useHomePageContext()
const statsLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
</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" :price="price" :layout="statsLayout" />
</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, maftolChartRatio } = usePriceChart()
const isBoursecuLayout = computed(() => getHomeLayoutId() === 'boursecu')
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: price.value,
metal: selectedChartMetal.value,
isBoursecuLayout: isBoursecuLayout.value,
maftolRatio: maftolChartRatio.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,42 @@
<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">
<BasicIcon :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,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } 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 ctx = useHomePageContext()
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-else-if="showContent">
<section class="dhome-hero-row">
<DesktopLivePrice />
<DesktopPriceRange />
</section>
<DesktopAssetsMetrics
@open-gold-modal="ctx.openGoldModal"
@open-rial-modal="ctx.openRialModal"
/>
<section class="dhome-main-grid">
<DesktopChartSection />
<DesktopQuickActionsPanel />
</section>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } 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 ctx = useHomePageContext()
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-else-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 />
<DesktopBannersPanel />
<DesktopChartSection />
</div>
<aside class="dhome-default-grid__side">
<DesktopGaroosiAssets />
<DesktopReferralPanel />
</aside>
</div>
<template v-else>
<DesktopDefaultActions />
<DesktopChartSection />
</template>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,516 @@
/* 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;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
direction: ltr;
&--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;
}
.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;
}
&: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(4, 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;
}
}
/* 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-default-grid {
grid-template-columns: 1fr;
}
.dhome-default-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+32 -277
View File
@@ -1,296 +1,51 @@
<template>
<div class="mt-10 p-5">
<div v-if="userStore.userInfo && !loading">
<!-- هشدار حداقل مبلغ -->
<div
v-if="limitedPrice !== undefined && userStore?.userInfo.trade_active !== 1"
class="text-red text-xs flex gap-1 items-center justify-center mb-2"
>
<Icon icon="vuesax-linear:info-circle" size="16"/>
حداقل مبلغ اولیه برای ثبت سفارش {{ usePriceWithoutType(limitedPrice.value as number ?? 0) }} ریال می باشد.
</div>
<div class="home-page-root">
<HomePageShell @logout="showLogoutConfirm = true" />
<!-- بخش اصلی -->
<div class="p-1 from-primary rounded-lg p-px to-transparent" :class="isGaroosi ? 'mt-0' : 'mt-5'">
<!-- دارایی‌ها (فقط گاروسی) -->
<div v-if="isGaroosi" class="flex justify-between px-2 mb-2">
<AssetItem label="دارایی طلایی:" :value="`${(userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3)} گرم`"/>
<AssetItem label="دارایی ریالی:"
:value="`${usePriceWithoutType(userStore?.userInfo?.wallet?.count_cash ?? 0)} ریال`"/>
</div>
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout" />
<!-- دکمه‌های اصلی -->
<div class="p-2 py-3 rounded-lg bg-tertiary">
<div class="flex justify-between">
<ActionButton
v-for="action in mainActions"
:key="action.name"
:action="action"
@click="action.handler"
/>
</div>
</div>
<BalanceSheet
v-model:show="wholeBalanceVisible"
:wallet-info="walletStore.walletInfo"
:gold-val="goldVal"
/>
<!-- بخش کد معرف (گاروسی) -->
<ReferralSection v-if="isGaroosi"/>
<!-- بخش دارایی‌ها (سایر پروژه‌ها) -->
<AssetsSection v-else @openGoldModal="openGoldModal" @openRialModal="openRialModal"/>
<!-- پیام بسته بودن بازار -->
<MarketClosedMessage v-if="showClosedMessage" :message="marketClosedText"/>
<!-- قیمت فعلی -->
<CurrentPrice/>
<!-- بنرها -->
<BannerSlider v-if="isGaroosi && banners?.length" :banners="banners" @bannerClick="handleBannerClick"/>
<!-- نمودار قیمت -->
<PriceChart @updateData="updateChartData" :price-chart-series="priceChartSeries"/>
<!-- آمار بالا و پایین -->
<PriceStats :price="price"/>
</div>
<!-- اسکلت لودینگ -->
<HomeSkeleton v-if="loading" class="w-full"/>
</div>
<!-- هدر -->
<AppHeader @logout="showLogoutConfirm = true"/>
<!-- دیالوگ خروج -->
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout"/>
<!-- شیت موجودی -->
<BalanceSheet v-model:show="wholeBalanceVisible" :walletInfo="walletStore.walletInfo" :goldVal="goldVal"/>
<!-- مودال‌ها -->
<BasicModal v-model:visible="showTextBanner" :custom-footer="true" destroyOnClose>
<div class="max-h-[54vh] ql-editor" v-html="TextBanner"></div>
<BasicModal v-model:visible="showTextBanner" :custom-footer="true" destroy-on-close>
<div class="max-h-[54vh] ql-editor" v-html="TextBanner" />
</BasicModal>
<ShareModal v-model:visible="showShare" :referralId="userStore.userInfo?.referral_id"/>
<ShareModal v-model:show="showShare" :referral-id="userStore.userInfo?.referral_id" />
</div>
</template>
<script lang="tsx" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import router from '@/router'
import {useWindowScroll} from '@vueuse/core'
import {loading, removeToken} from '@/utils'
import {useUserStore} from '@/store/userStore'
import {useWalletStore} from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { formatMetalAssetsInfoText, formatRialAssetsInfoText, resolveAssetsRialValue } from '@/utils/productAssets'
import {getLastPrices, getTradeBuyPrice, lastPrice, price, startLastPricePolling, stopLastPricePolling} from '@/utils/price'
import {useConfigStore} from '@/store/configStore'
import {useFundUserStore} from '@/store/fundStore'
import {usePriceWithoutType} from '../../../utils/usePrice'
import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours'
import {getConstByKey} from '@/api/const'
import {message, Modal} from 'ant-design-vue'
// Components
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import HomeSkeleton from '@/views/base/home/components/HomeSkeleton.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import AssetItem from './components/AssetItem.vue'
import ActionButton from './components/ActionButton.vue'
import ReferralSection from './components/ReferralSection.vue'
import AssetsSection from './components/AssetsSection.vue'
import MarketClosedMessage from './components/MarketClosedMessage.vue'
import CurrentPrice from './components/CurrentPrice.vue'
import BannerSlider from './components/BannerSlider.vue'
import PriceChart from './components/PriceChart.vue'
import PriceStats from './components/PriceStats.vue'
import AppHeader from './components/AppHeader.vue'
import LogoutConfirmDialog from './components/LogoutConfirmDialog.vue'
import BalanceSheet from './components/BalanceSheet.vue'
import ShareModal from './components/ShareModal.vue'
import HomePageShell from './shell/HomePageShell.vue'
import { useHomePage } from './composables/useHomePage'
// Composables
import {useBanners} from './composables/useBanners'
import {usePriceChart} from './composables/usePriceChart'
import {useUserFunds} from './composables/useUserFunds'
import {getReferralShareText, getReferralShareTitle} from '@/utils/config'
// Stores
const userStore = useUserStore()
const walletStore = useWalletStore()
const configStore = useConfigStore()
const fundStore = useFundUserStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
// Config
const isGaroosi = computed(() => configStore.config.name === 'garoosi')
const closedMessage = computed(() => userStore.getValueByKey('closed_message'))
const marketClosedText = computed(() => {
if (!isStoreClosed()) return ''
return closedMessage.value || getStoreClosedMessage()
})
const showClosedMessage = computed(() => !!marketClosedText.value)
// State
const showLogoutConfirm = ref(false)
const wholeBalanceVisible = ref(false)
const showTextBanner = ref(false)
const showShare = ref(false)
const TextBanner = ref('')
const odometerKey = ref(0)
const {x, y} = useWindowScroll()
// Composables
const {banners, loadBanners} = useBanners()
const {updateChartData, priceChartSeries, fetchPrices} = usePriceChart()
const {funds, loadFunds} = useUserFunds()
// Computed
const limitedPrice = computed(() => walletStore.limitedPrice?.[0])
const goldVal = computed(() => {
return lastPrice.value?.price_last ?? 1
})
// Actions
const mainActions = computed(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({name: 'buyProduct'})
},
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: isGaroosi.value ? 'واریز و برداشت' : 'واریز نقدی',
handler: () => router.push({name: 'wallet', query: {state: 'deposit'}})
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => wholeBalanceVisible.value = true
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: isGaroosi.value ? 'دعوت از دوستان' : 'دارایی ها',
handler: () => isGaroosi.value ? shareCode() : router.push({name: 'fund'})
}
])
// Methods
async function shareCode() {
const text = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: configStore.config.name,
referralId: userStore.userInfo?.referral_id,
})
try {
await navigator.share({
title: getReferralShareTitle(configStore.config.name),
text,
})
} catch {
copyCode()
}
}
function copyCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
useWalletAssets().clearWalletAssets()
removeToken()
await router.push({name: 'login'})
window.location.reload()
}
function openGoldModal(metal: WalletMetalTotal) {
Modal.confirm({
title: formatMetalAssetsInfoText(metal),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: {style: {display: 'none'}}
})
}
function openRialModal() {
const summary = assetsSummary.value
const cash = summary?.cash_wallet ?? 0
const metalRial = resolveAssetsRialValue(summary, getTradeBuyPrice(lastPrice.value))
Modal.confirm({
title: 'دارایی ریالی',
content: formatRialAssetsInfoText(cash, metalRial),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: {style: {display: 'none'}}
})
}
function handleBannerClick(banner) {
const handlers = {
share: () => showShare.value = true,
modal: () => {
if (banner.modalText) {
TextBanner.value = banner.modalText
showTextBanner.value = true
}
},
link: () => {
if (banner.link) {
if (banner.link.startsWith('http')) {
window.open(banner.link, '_blank')
} else {
router.push(banner.link)
}
}
}
}
handlers[banner.typeClick]?.()
}
// Lifecycle
onMounted(async () => {
try {
loading.value = true
await Promise.all([
loadFunds(),
loadWalletAssets(),
getLastPrices(),
fetchPrices(),
startLastPricePolling(),
loadBanners()
])
// شروع آپدیت قیمت
const updateInterval = setInterval(() => {
odometerKey.value++
}, 1000)
onUnmounted(() => {
clearInterval(updateInterval)
stopLastPricePolling()
})
} catch (error) {
console.error('Initialization error:', error)
} finally {
loading.value = false
}
})
const {
userStore,
walletStore,
showLogoutConfirm,
wholeBalanceVisible,
showTextBanner,
showShare,
TextBanner,
goldVal,
logout,
} = useHomePage()
</script>
<style scoped>
.home-page-root {
min-height: 100%;
height: 100%;
}
</style>
<style lang="less">
.odometer {
font-size: 60px;
@@ -317,4 +72,4 @@ label:focus-within {
background: #fff;
}
}
</style>
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
import HomeSkeleton from '../components/HomeSkeleton.vue'
import { getHomeLayoutConfig } from './registry'
import { HOME_SECTION_COMPONENTS } from './sections'
import type { HomeLayoutConfig, HomeSectionId } from './types'
const props = defineProps<{
layoutConfig?: HomeLayoutConfig
}>()
const userStore = useUserStore()
const layoutConfig = computed(() => props.layoutConfig ?? getHomeLayoutConfig())
const outerSections = computed(() =>
(layoutConfig.value.outerSections ?? []).filter((section) => section.visible !== false)
)
const innerSections = computed(() =>
layoutConfig.value.sections.filter((section) => section.visible !== false)
)
function resolveSectionComponent(sectionId: HomeSectionId) {
return HOME_SECTION_COMPONENTS[sectionId]
}
</script>
<template>
<div v-if="userStore.userInfo && !loading">
<component
:is="resolveSectionComponent(section.id)"
v-for="section in outerSections"
:key="`outer-${section.id}`"
/>
<div v-if="layoutConfig.wrapperClass" :class="layoutConfig.wrapperClass">
<component
:is="resolveSectionComponent(section.id)"
v-for="section in innerSections"
:key="section.id"
/>
</div>
<template v-else>
<component
:is="resolveSectionComponent(section.id)"
v-for="section in innerSections"
:key="section.id"
/>
</template>
</div>
<HomeSkeleton v-if="loading" class="w-full" />
</template>
@@ -0,0 +1,21 @@
import type { HomeLayoutConfig } from '../types'
/**
* چیدمان اختصاصی boursecu — فقط وقتی home_layout=boursecu یا پروژه boursecu باشد.
* شامل: دارایی بدون بک‌گراند، گرید ۶ باکس، نمودار کاتد/مفتول، و ...
*/
export const boursecuHomeLayoutConfig: HomeLayoutConfig = {
id: 'boursecu',
wrapperClass: 'rounded-lg mt-5',
outerSections: [
{ id: 'assets' },
{ id: 'boursecuQuickActions' },
{ id: 'limitedPriceAlert' },
],
sections: [
{ id: 'marketClosed' },
{ id: 'currentPrice' },
{ id: 'priceChart' },
{ id: 'priceStats' },
],
}
@@ -0,0 +1,40 @@
import type { HomeQuickActionItem } from '../types'
export const boursecuQuickActions: HomeQuickActionItem[] = [
{
id: 'buySell',
label: 'خرید و فروش',
icon: 'vuesax-linear:money-receive',
route: { name: 'buyProduct' },
},
{
id: 'wallet',
label: 'واریز و برداشت',
icon: 'vuesax-linear:wallet-1',
route: { name: 'wallet' },
},
{
id: 'assets',
label: 'دارایی ها',
icon: 'vuesax-linear:cup',
route: { name: 'fund' },
},
{
id: 'transactions',
label: 'تراکنش ها',
icon: 'vuesax-linear:receipt-item',
route: { name: 'transactions' },
},
{
id: 'physicalReceive',
label: 'تحویل فیزیکی',
icon: 'vuesax-linear:direct-inbox',
route: { name: 'physicalReceive' },
},
{
id: 'convert',
label: 'تبدیل',
icon: 'vuesax-linear:refresh-circle',
route: { name: 'buyProduct' },
},
]
@@ -0,0 +1,19 @@
import type { HomeLayoutConfig } from '../types'
/** چیدمان پیش‌فرض — همه پروژه‌ها به‌جز boursecu */
export const defaultHomeLayoutConfig: HomeLayoutConfig = {
id: 'default',
wrapperClass: 'p-1 from-primary rounded-lg p-px to-transparent mt-5',
outerSections: [{ id: 'limitedPriceAlert' }],
sections: [
{ id: 'garoosiAssets' },
{ id: 'mainActions' },
{ id: 'referral' },
{ id: 'assets' },
{ id: 'marketClosed' },
{ id: 'currentPrice' },
{ id: 'banners' },
{ id: 'priceChart' },
{ id: 'priceStats' },
],
}

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