Merge feature/responsive-layout-refactor into modular for desktop layouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Component } from 'vue'
|
||||
import { getConfigApp, isBoursecuProject } from '@/utils/config'
|
||||
import type { HomeLayoutConfig, HomeLayoutId } from './types'
|
||||
import { defaultHomeLayoutConfig } from './configs/default'
|
||||
import { boursecuHomeLayoutConfig } from './configs/boursecu'
|
||||
import HomeLayoutRenderer from './HomeLayoutRenderer.vue'
|
||||
|
||||
const HOME_LAYOUT_CONFIGS: Record<HomeLayoutId, HomeLayoutConfig> = {
|
||||
default: defaultHomeLayoutConfig,
|
||||
boursecu: boursecuHomeLayoutConfig,
|
||||
}
|
||||
|
||||
const HOME_LAYOUT_COMPONENTS: Record<HomeLayoutId, Component> = {
|
||||
default: HomeLayoutRenderer,
|
||||
boursecu: HomeLayoutRenderer,
|
||||
}
|
||||
|
||||
export const DEFAULT_HOME_LAYOUT_ID: HomeLayoutId = 'default'
|
||||
|
||||
export function isHomeLayoutId(value: unknown): value is HomeLayoutId {
|
||||
return typeof value === 'string' && value in HOME_LAYOUT_CONFIGS
|
||||
}
|
||||
|
||||
export function getHomeLayoutId(): HomeLayoutId {
|
||||
const adminLayout = getConfigApp('home_layout')
|
||||
if (isHomeLayoutId(adminLayout)) {
|
||||
return adminLayout
|
||||
}
|
||||
|
||||
if (isBoursecuProject()) {
|
||||
return 'boursecu'
|
||||
}
|
||||
|
||||
return DEFAULT_HOME_LAYOUT_ID
|
||||
}
|
||||
|
||||
export function getHomeLayoutConfig(layoutId = getHomeLayoutId()): HomeLayoutConfig {
|
||||
return HOME_LAYOUT_CONFIGS[layoutId] ?? HOME_LAYOUT_CONFIGS[DEFAULT_HOME_LAYOUT_ID]
|
||||
}
|
||||
|
||||
export function resolveHomeLayoutComponent(layoutId = getHomeLayoutId()): Component {
|
||||
return HOME_LAYOUT_COMPONENTS[layoutId] ?? HOME_LAYOUT_COMPONENTS[DEFAULT_HOME_LAYOUT_ID]
|
||||
}
|
||||
|
||||
export function registerHomeLayoutConfig(layoutId: HomeLayoutId, config: HomeLayoutConfig) {
|
||||
HOME_LAYOUT_CONFIGS[layoutId] = config
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import AssetsSection from '../../components/AssetsSection.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const isPlainAssets = computed(() => ctx.isBoursecuHomeLayout.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AssetsSection
|
||||
v-if="!ctx.isGaroosi.value"
|
||||
:plain="isPlainAssets"
|
||||
:boursecu-layout="isPlainAssets"
|
||||
@open-gold-modal="ctx.openGoldModal"
|
||||
@open-rial-modal="ctx.openRialModal"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import BannerSlider from '../../components/BannerSlider.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BannerSlider
|
||||
v-if="ctx.isGaroosi.value && ctx.banners.value?.length"
|
||||
:banners="ctx.banners.value"
|
||||
@bannerClick="ctx.handleBannerClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import router from '@/router'
|
||||
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
|
||||
import { boursecuQuickActions } from '../configs/boursecuQuickActions'
|
||||
import type { HomeQuickActionItem } from '../types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
actions?: HomeQuickActionItem[]
|
||||
}>(),
|
||||
{
|
||||
actions: () => boursecuQuickActions,
|
||||
}
|
||||
)
|
||||
|
||||
function handleClick(action: HomeQuickActionItem) {
|
||||
router.push(action.route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-3 mt-4">
|
||||
<HomeQuickActionBox
|
||||
v-for="action in props.actions"
|
||||
:key="action.id"
|
||||
:action="action"
|
||||
@click="handleClick(action)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CurrentPrice from '../../components/CurrentPrice.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CurrentPrice />
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import AssetItem from '../../components/AssetItem.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ctx.isGaroosi.value" 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>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { usePriceWithoutType } from '@/utils/usePrice'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isVisible = computed(
|
||||
() =>
|
||||
ctx.limitedPrice.value !== undefined &&
|
||||
userStore.userInfo?.trade_active !== 1
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="text-red text-xs flex gap-1 items-center justify-center mb-2"
|
||||
>
|
||||
<Icon icon="vuesax-linear:info-circle" size="16" />
|
||||
حداقل مبلغ اولیه برای ثبت سفارش
|
||||
{{ usePriceWithoutType(Number(ctx.limitedPrice.value?.value ?? 0)) }}
|
||||
ریال می باشد.
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import ActionButton from '../../components/ActionButton.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 py-3 rounded-lg bg-tertiary">
|
||||
<div class="flex justify-between">
|
||||
<ActionButton
|
||||
v-for="action in ctx.mainActions.value"
|
||||
:key="action.name"
|
||||
:action="action"
|
||||
@click="action.handler"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import MarketClosedMessage from '../../components/MarketClosedMessage.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarketClosedMessage
|
||||
v-if="ctx.showClosedMessage.value"
|
||||
:message="ctx.closedMessage.value as string"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, toValue } from 'vue'
|
||||
import PriceChart from '../../components/PriceChart.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const layout = 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>
|
||||
<PriceChart
|
||||
:price-chart-series="priceChartSeries"
|
||||
:layout="layout"
|
||||
:chart-selection="chartSelection"
|
||||
:selected-metal="selectedChartMetal"
|
||||
@update-data="ctx.updateChartData"
|
||||
@update:selected-metal="ctx.setChartMetal"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue'
|
||||
import PriceStats from '../../components/PriceStats.vue'
|
||||
import { price } from '@/utils/price'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
import { usePriceChart } from '../../composables/usePriceChart'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
const { maftolChartRatio } = usePriceChart()
|
||||
const layout = computed(() => (ctx.isBoursecuHomeLayout.value ? 'boursecu' : 'default'))
|
||||
const selectedMetal = computed(() => ctx.selectedChartMetal.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PriceStats
|
||||
:price="price"
|
||||
:layout="layout"
|
||||
:selected-metal="selectedMetal"
|
||||
:maftol-ratio="maftolChartRatio"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import ReferralSection from '../../components/ReferralSection.vue'
|
||||
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
|
||||
|
||||
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReferralSection v-if="ctx.isGaroosi.value" />
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { HomeSectionId } from '../types'
|
||||
import LimitedPriceAlertSection from './LimitedPriceAlertSection.vue'
|
||||
import GaroosiAssetsSection from './GaroosiAssetsSection.vue'
|
||||
import MainActionsSection from './MainActionsSection.vue'
|
||||
import BoursecuQuickActionsSection from './BoursecuQuickActionsSection.vue'
|
||||
import ReferralSectionBlock from './ReferralSectionBlock.vue'
|
||||
import AssetsSectionBlock from './AssetsSectionBlock.vue'
|
||||
import MarketClosedSection from './MarketClosedSection.vue'
|
||||
import CurrentPriceSection from './CurrentPriceSection.vue'
|
||||
import BannersSection from './BannersSection.vue'
|
||||
import PriceChartSection from './PriceChartSection.vue'
|
||||
import PriceStatsSection from './PriceStatsSection.vue'
|
||||
|
||||
export const HOME_SECTION_COMPONENTS: Record<HomeSectionId, Component> = {
|
||||
limitedPriceAlert: LimitedPriceAlertSection,
|
||||
garoosiAssets: GaroosiAssetsSection,
|
||||
mainActions: MainActionsSection,
|
||||
boursecuQuickActions: BoursecuQuickActionsSection,
|
||||
referral: ReferralSectionBlock,
|
||||
assets: AssetsSectionBlock,
|
||||
marketClosed: MarketClosedSection,
|
||||
currentPrice: CurrentPriceSection,
|
||||
banners: BannersSection,
|
||||
priceChart: PriceChartSection,
|
||||
priceStats: PriceStatsSection,
|
||||
}
|
||||
|
||||
export function registerHomeSection(id: HomeSectionId, component: Component) {
|
||||
HOME_SECTION_COMPONENTS[id] = component
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** چیدمانهای صفحه اصلی — هر پروژه/ادمین میتواند یکی را انتخاب کند */
|
||||
export type HomeLayoutId = 'default' | 'boursecu'
|
||||
|
||||
export type HomeSectionId =
|
||||
| 'limitedPriceAlert'
|
||||
| 'garoosiAssets'
|
||||
| 'mainActions'
|
||||
| 'boursecuQuickActions'
|
||||
| 'referral'
|
||||
| 'assets'
|
||||
| 'marketClosed'
|
||||
| 'currentPrice'
|
||||
| 'banners'
|
||||
| 'priceChart'
|
||||
| 'priceStats'
|
||||
|
||||
export interface HomeQuickActionItem {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
route: { name: string; query?: Record<string, string> }
|
||||
}
|
||||
|
||||
export interface HomeLayoutSectionConfig {
|
||||
id: HomeSectionId
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export interface HomeLayoutConfig {
|
||||
id: HomeLayoutId
|
||||
wrapperClass?: string
|
||||
outerSections?: HomeLayoutSectionConfig[]
|
||||
sections: HomeLayoutSectionConfig[]
|
||||
}
|
||||
|
||||
export interface HomeActionItem {
|
||||
name: string
|
||||
icon: string
|
||||
label: string
|
||||
handler: () => void
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="mt-10 p-5">
|
||||
<component :is="layoutComponent" :layout-config="layoutConfig" />
|
||||
<AppHeader @logout="$emit('logout')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import {
|
||||
getHomeLayoutConfig,
|
||||
getHomeLayoutId,
|
||||
resolveHomeLayoutComponent,
|
||||
} from '../layouts/registry'
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const layoutComponent = computed(() => resolveHomeLayoutComponent())
|
||||
const layoutConfig = computed(() => getHomeLayoutConfig(getHomeLayoutId()))
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<!-- v-show: breakpoint switches must not destroy layouts or re-run child mount hooks -->
|
||||
<MobileHomeView v-show="!isDesktop" @logout="$emit('logout')" />
|
||||
<HomeDesktopView v-show="isDesktop" @logout="$emit('logout')" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import MobileHomeView from '../mobile/MobileHomeView.vue'
|
||||
import HomeDesktopView from '../desktop/HomeDesktopView.vue'
|
||||
|
||||
defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
</script>
|
||||
Reference in New Issue
Block a user