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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 17:02:11 +03:30
co-authored by Cursor
235 changed files with 18805 additions and 5910 deletions
+59 -12
View File
@@ -1,13 +1,17 @@
<template>
<div
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
:class="y > 50 ? 'bg-white shadow opacity-95' : ''"
class="px-4 py-2 top-0 z-5 wrap-fix background-color-transition"
>
<div class="flex justify-between my-2">
<div class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div class="flex justify-between my-2 items-center">
<div v-if="isBoursecuProject()" class="flex items-center gap-2">
<img :src="projectLogo" alt="" class="h-8 w-auto object-contain" />
<div class="color-transition font-bold text-primary">{{ appName }}</div>
</div>
<div v-else class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
class="z-100000 rounded-full border-1"
:class="y > 50 ? 'text-primary border-primary' : 'text-primary border-primary'"
>
<Icon icon="vuesax-linear:user" size="20" />
</div>
@@ -15,26 +19,69 @@
{{ userStore.userInfo?.name }}
</div>
</div>
<Icon
:color="y > 50 ? '#0A1B37' : '#0A1B37'"
class="color-transition"
icon="vuesax-linear:logout"
<div class="flex items-center gap-2">
<button
v-if="isBoursecuProject()"
type="button"
class="mhome-header-btn"
@click="router.push({ name: 'user' })"
>
<Icon icon="vuesax-linear:user" size="18" />
</button>
<button
type="button"
class="mhome-header-btn mhome-header-btn--logout"
@click="$emit('logout')"
/>
>
<Icon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWindowScroll } from '@vueuse/core'
import { useUserStore } from '@/store/userStore'
import { useConfigStore } from '@/store/configStore'
import router from '@/router'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectNavLogo, isBoursecuProject } from '@/utils/config'
const { y } = useWindowScroll()
const userStore = useUserStore()
const config = useConfigStore()
const projectLogo = computed(() => getProjectNavLogo())
const appName = computed(() => config.config?.name_fa || config.config?.name || 'بورس مس')
defineEmits<{
logout: []
}>()
</script>
</script>
<style scoped>
.mhome-header-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #0a1b37;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.mhome-header-btn--logout {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
}
</style>
@@ -1,26 +1,52 @@
<template>
<div class="bg-primary rounded-lg p-2 py-3 mt-2">
<div v-if="meta?.has_count_based_products" class="flex justify-center text-tertiary w-full z-20">
<div :class="plain ? 'py-2' : 'bg-primary rounded-lg p-2 py-3 mt-2'">
<div
v-if="!boursecuLayout && meta?.has_count_based_products"
:class="rowClass"
class="flex justify-center w-full z-20"
>
محصولات:
<div class="text-l mr-2 font-bold">{{ countBasedInventory }} عدد</div>
</div>
<template v-if="meta?.single_metal && primaryMetalTotal">
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
<template v-if="boursecuAssetRows.length">
<div
v-for="(row, index) in boursecuAssetRows"
:key="row.id"
:class="[rowClass, index > 0 ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ row.label }}:
<div class="text-l mr-2 font-bold">{{ row.value }}</div>
<BasicIcon
v-if="row.info !== 'none'"
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="handleRowInfo(row)"
/>
</div>
</template>
<template v-else-if="meta?.single_metal && primaryMetalTotal">
<div :class="[rowClass, meta?.has_count_based_products ? 'mt-2' : '']" class="flex justify-center w-full z-20">
{{ primaryMetalTotal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(primaryMetalTotal) }}</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', primaryMetalTotal)"
/>
</div>
</template>
<template v-else>
<template v-else-if="!boursecuLayout">
<div
v-for="metal in metalTotals"
v-for="(metal, index) in metalTotals"
:key="metal.metal_id"
class="flex mt-2 justify-center text-tertiary w-full z-20"
:class="[rowClass, index > 0 || meta?.has_count_based_products ? 'mt-2' : '']"
class="flex justify-center w-full z-20"
>
{{ metal.label }}:
<div class="text-l mr-2 font-bold">{{ formatMetalTotal(metal) }}</div>
@@ -28,48 +54,79 @@
v-if="metal.metal_id === metalTotals[0]?.metal_id"
icon="vuesax-linear:info-circle"
size="18"
color="tertiary"
:color="iconColor"
@click="$emit('openGoldModal', metal)"
/>
</div>
<div :class="[rowClass, 'mt-2']" class="flex justify-center w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(totalRialValue) }} ریال</div>
<BasicIcon
icon="vuesax-linear:info-circle"
size="18"
:color="iconColor"
@click="$emit('openRialModal')"
/>
</div>
</template>
<div class="flex mt-2 justify-center text-tertiary w-full z-20">
دارایی ریالی:
<div class="text-l mr-2 font-bold">{{ usePriceWithoutType(cashWallet) }} ریال</div>
<BasicIcon @click="$emit('openRialModal')" icon="vuesax-linear:info-circle" size="18" color="tertiary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { resolveSummaryMetalWeight } from '@/utils/productAssets'
import {
buildBoursecuHomeAssetRows,
resolveSummaryMetalWeight,
type HomeAssetRow,
} from '@/utils/productAssets'
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const props = withDefaults(
defineProps<{
plain?: boolean
boursecuLayout?: boolean
}>(),
{ plain: false, boursecuLayout: false }
)
defineEmits<{
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
const { assetsSummary } = useWalletAssets()
const rowClass = computed(() => (props.plain ? 'text-primary' : 'text-tertiary'))
const iconColor = computed(() => (props.plain ? 'primary' : 'tertiary'))
const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const primaryMetalTotal = computed(() => metalTotals.value[0])
const cashWallet = computed(() => assetsSummary.value?.cash_wallet ?? 0)
const boursecuAssetRows = computed(() =>
props.boursecuLayout ? buildBoursecuHomeAssetRows(assetsSummary.value) : []
)
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0)
)
onMounted(() => {
void loadWalletAssets()
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function handleRowInfo(row: HomeAssetRow) {
if (row.info === 'rial') {
emit('openRialModal')
return
}
if (row.metal) {
emit('openGoldModal', row.metal)
}
}
</script>
@@ -50,7 +50,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { computed, watch } from 'vue'
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicInfoItem from '@/components/BasicPacks/BasicInfoItem.vue'
import { usePrice } from '@/utils/usePrice'
@@ -84,10 +84,6 @@ const metalRialValue = computed(() =>
)
const totalRialValue = computed(() => cashWallet.value + metalRialValue.value)
onMounted(() => {
void loadWalletAssets()
})
watch(
() => props.show,
(open) => {
@@ -0,0 +1,41 @@
<script setup lang="ts">
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { isBoursecuProject } from '@/utils/config'
import type { HomeQuickActionItem } from '../layouts/types'
defineProps<{
action: HomeQuickActionItem
}>()
defineEmits<{
click: []
}>()
const isBoursecu = isBoursecuProject()
</script>
<template>
<button
type="button"
class="home-quick-action-box flex flex-col items-center justify-center gap-2 rounded-xl border-2 p-3 min-h-[96px] w-full"
:class="{ 'home-quick-action-box--boursecu': isBoursecu }"
@click="$emit('click')"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center">
<Icon class="text-primary" :icon="action.icon" size="28" />
</div>
<span class="text-primary text-xs font-bold text-center leading-5">{{ action.label }}</span>
</button>
</template>
<style scoped>
.home-quick-action-box {
appearance: none;
cursor: pointer;
}
.home-quick-action-box--boursecu {
background-color: #eff5f3;
border-color: #d4e3de;
}
</style>
+46 -55
View File
@@ -1,63 +1,54 @@
<template>
<div ref="skeletonRatio" class="h-fit">
<content-loader :width="skeletonSize.width" class="bg-white" height="100vh">
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="0"/>
<rect :width="skeletonSize.width" height="5" rx="3" ry="3" x="0" y="84"/>
<rect :width="5" height="85" rx="3" ry="3" x="0" y="0"/>
<rect :width="5" :x="skeletonSize.width-5" height="85" rx="3" ry="3" y="0"/>
<rect :width="2" :x="skeletonSize.width/2" height="60" rx="3" ry="3" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-20" :x="skeletonSize.width/2 + 10" height="20" rx="10" ry="10" y="15"/>
<rect :width="skeletonSize.width/2-40" :x="20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-40" :x="skeletonSize.width/2 + 20" height="10" rx="5" ry="5" y="40"/>
<rect :width="skeletonSize.width/2-30" :x="15" height="18" rx="10" ry="10" y="60"/>
<rect :width="skeletonSize.width/2-30" :x="skeletonSize.width/2 + 15" height="18" rx="10" ry="10" y="60"/>
<circle :cx="skeletonSize.width/4-skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/4+skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width/2+ skeletonSize.width/8" cy="145" r="20"/>
<circle :cx="skeletonSize.width - skeletonSize.width/8" cy="145" r="20"/>
<rect :width="50" :x="skeletonSize.width/4-skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/4+skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width/2+ skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="50" :x="skeletonSize.width - skeletonSize.width/8 - 25" height="15" rx="3" ry="3" y="170"/>
<rect :width="skeletonSize.width - 45" :x="45" height="20" rx="3" ry="3" y="230"/>
<rect :width="skeletonSize.width" :x="0" height="80" rx="3" ry="3" y="260"/>
<rect :width="skeletonSize.width/2 - 5" :x="0" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width/2 - 5" :x="skeletonSize.width/2 + 5" height="50" rx="3" ry="3" y="360"/>
<rect :width="skeletonSize.width" :x="0" height="170" rx="3" ry="3" y="440"/>
<!-- <rect height="20" rx="3" ry="3" width="100" x="0" y="0"/>
<rect :width="skeletonSize.width/4 + 40" :x="skeletonSize.width/2" height="20" rx="3" ry="3" y="0"/>
<rect height="20" rx="3" ry="3" width="80" x="0" y="30"/>
<rect :x="skeletonSize.width - 40" height="40" rx="12" ry="12" width="40" y="0"/>
<rect :width="skeletonSize.width" :x="0" :y="skeletonSize.height-5" height="2" rx="12" ry="12"/>-->
<!-- <circle :cx="skeletonSize.width - 20" :cy="skeletonSize.height/2 - 10" r="20" />-->
<!-- <rect :x="10" y="20" rx="3" ry="3" :width="skeletonSize.width - 20" height="150"/>
<circle :cx="skeletonSize.width/2" cy="170" r="15" />
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 -10" y="190" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 + 20" height="20"/>
<rect :x="skeletonSize.width/2 - skeletonSize.width/3 +20" y="220" rx="3" ry="3" :width="skeletonSize.width/3 + skeletonSize.width/3 - 40" height="20"/>-->
<!-- <rect x="90" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="180" y="0" rx="3" ry="3" width="80" height="20" />
<rect x="270" y="0" rx="3" ry="3" width="80" height="20" ♥/>-->
<div ref="skeletonRatio" class="h-fit w-full min-h-[620px]">
<content-loader
:width="skeletonWidth"
class="bg-white"
:height="skeletonHeight"
>
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="0" />
<rect :width="skeletonWidth" height="5" rx="3" ry="3" x="0" y="84" />
<rect width="5" height="85" rx="3" ry="3" x="0" y="0" />
<rect width="5" :x="skeletonWidth - 5" height="85" rx="3" ry="3" y="0" />
<rect width="2" :x="skeletonWidth / 2" height="60" rx="3" ry="3" y="15" />
<rect :width="halfWidth(20)" x="10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(20)" :x="skeletonWidth / 2 + 10" height="20" rx="10" ry="10" y="15" />
<rect :width="halfWidth(40)" x="20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(40)" :x="skeletonWidth / 2 + 20" height="10" rx="5" ry="5" y="40" />
<rect :width="halfWidth(30)" x="15" height="18" rx="10" ry="10" y="60" />
<rect :width="halfWidth(30)" :x="skeletonWidth / 2 + 15" height="18" rx="10" ry="10" y="60" />
<circle :cx="skeletonWidth / 4 - skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 4 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth / 2 + skeletonWidth / 8" cy="145" r="20" />
<circle :cx="skeletonWidth - skeletonWidth / 8" cy="145" r="20" />
<rect width="50" :x="skeletonWidth / 4 - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 4 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth / 2 + skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect width="50" :x="skeletonWidth - skeletonWidth / 8 - 25" height="15" rx="3" ry="3" y="170" />
<rect :width="fullWidth(45)" x="45" height="20" rx="3" ry="3" y="230" />
<rect :width="skeletonWidth" x="0" height="80" rx="3" ry="3" y="260" />
<rect :width="halfWidth(10)" x="0" height="50" rx="3" ry="3" y="360" />
<rect :width="halfWidth(10)" :x="skeletonWidth / 2 + 5" height="50" rx="3" ry="3" y="360" />
<rect :width="skeletonWidth" x="0" height="170" rx="3" ry="3" y="440" />
</content-loader>
</div>
</template>
<script lang="ts" setup>
import {reactive, ref, watch} from "vue";
import {useElementSize} from "@vueuse/core";
import {ContentLoader} from "vue-content-loader";
import { computed, ref } from 'vue'
import { useElementSize } from '@vueuse/core'
import { ContentLoader } from 'vue-content-loader'
const skeletonRatio = ref(null)
const skeletonSize = reactive(
useElementSize(skeletonRatio, {width: 0, height: 0}, {box: 'border-box'})
)
/*watch(skeletonSize, () => {
console.log(skeletonSize)
})*/
const skeletonHeight = 620
const skeletonRatio = ref<HTMLElement | null>(null)
const { width } = useElementSize(skeletonRatio, { width: 0, height: 0 }, { box: 'border-box' })
const skeletonWidth = computed(() => Math.max(width.value, 320))
function halfWidth(padding: number) {
return Math.max(skeletonWidth.value / 2 - padding, 0)
}
function fullWidth(padding: number) {
return Math.max(skeletonWidth.value - padding, 0)
}
</script>
<style scoped>
</style>
+197 -170
View File
@@ -1,201 +1,228 @@
<template>
<div class="bg-gray-50 w-full flex justify-evenly rounded-lg py-4 mt-4 h-[15rem]">
<div
class="bg-gray-50 w-full rounded-lg py-4"
:class="[
chartContainerClass,
variant === 'desktop' ? 'mt-0' : 'mt-4',
layout === 'boursecu' && variant !== 'desktop' ? 'price-chart-box--boursecu' : '',
]"
>
<div class="w-full text-center pt-2">
<a-radio-group v-model:value="selectedValue" button-style="solid">
<div v-if="showMetalSelector" class="chart-metal-segment mb-3">
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'katod' }"
@click="emit('update:selectedMetal', 'katod')"
>
کاتد مس
</button>
<button
type="button"
class="chart-metal-segment__item"
:class="{ 'chart-metal-segment__item--active': selectedMetal === 'maftol' }"
@click="emit('update:selectedMetal', 'maftol')"
>
مفتول مس
</button>
</div>
<a-radio-group v-model:value="selectedValue" button-style="solid" class="chart-timeline-group">
<a-radio-button value="DAILY" @click="handleUpdate('DAILY')">24 ساعت اخیر</a-radio-button>
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group>
<div class="min-h-[200px] relative">
<div v-if="hasChartData" ref="chartEl" class="h-[200px] w-full" />
<div
v-else
class="h-[200px] flex items-center justify-center text-gray-400 text-sm"
>
داده نمودار در دسترس نیست
</div>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
type="area"
width="100%"
:height="chartHeight"
:options="chartOptions"
:series="props.priceChartSeries"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useConfigStore } from '@/store/configStore'
import { usePrice } from '@/utils/usePrice'
import { getProjectFontFamily } from '@/utils/config'
import { useElementSize } from '@vueuse/core'
type ChartPoint = [number, number]
import type { ChartMetalType } from '../composables/usePriceChart'
const props = defineProps<{
priceChartSeries?: Array<{ name?: string; data?: unknown[] }> | null
}>()
const props = withDefaults(
defineProps<{
priceChartSeries: any[]
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
chartSelection?: string
selectedMetal?: ChartMetalType
}>(),
{
layout: 'default',
variant: 'mobile',
chartSelection: 'DAILY',
selectedMetal: 'katod',
}
)
const chartHeight = computed(() => (props.variant === 'desktop' ? 320 : 200))
const chartContainerClass = computed(() => {
if (props.variant === 'desktop') {
return 'min-h-[22rem]'
}
return props.layout === 'default' ? 'min-h-[15rem] h-[15rem]' : 'min-h-[15rem]'
})
const chartInnerClass = computed(() =>
props.variant === 'desktop' ? 'min-h-[320px]' : 'min-h-[200px]'
)
const showMetalSelector = computed(() => props.layout === 'boursecu')
const emit = defineEmits<{
updateData: [timeline: string]
'update:selectedMetal': [metal: ChartMetalType]
}>()
const configStore = useConfigStore()
const selectedValue = ref('DAILY')
const chartEl = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
let renderToken = 0
const config = useConfigStore()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const selectedValue = ref(props.chartSelection)
function extractPoints(): ChartPoint[] {
const source = props.priceChartSeries
if (!Array.isArray(source) || source.length === 0) {
return []
}
const { width: chartWidth } = useElementSize(chartContainerRef)
const raw = source[0]?.data
if (!Array.isArray(raw)) {
return []
}
return raw
.map((item) => {
if (!Array.isArray(item) || item.length < 2) {
return null
}
let x = Number(item[0])
let y = Number(item[1])
if (!Number.isFinite(x) || !Number.isFinite(y) || y <= 0) {
return null
}
if (x > 0 && x < 1e11) {
x *= 1000
}
return [x, y] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
.sort((a, b) => a[0] - b[0])
}
const chartPoints = computed(() => extractPoints())
const hasChartData = computed(() => chartPoints.value.length >= 2)
const toPersianNumber = (num: number) =>
num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
function buildOption(points: ChartPoint[]): EChartsOption {
const color = configStore.config?.color || '#CEA34C'
return {
animation: true,
grid: {
left: 4,
right: 4,
top: 8,
bottom: 4,
containLabel: false
},
xAxis: {
type: 'time',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false,
scale: true
},
tooltip: {
trigger: 'axis',
confine: true,
formatter: (params) => {
const row = Array.isArray(params) ? params[0] : params
if (!row || !Array.isArray(row.value)) {
return ''
}
const [timestamp, value] = row.value as [number, number]
const date = new Date(timestamp)
const dateText = `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}`
return `${dateText}<br/>${toPersianNumber(usePrice(value, true, 'ه.ریال'))}`
}
},
series: [
{
name: 'قیمت',
type: 'line',
smooth: true,
showSymbol: false,
data: points,
lineStyle: {
width: 2,
color
},
areaStyle: {
color,
opacity: 0.25
}
}
]
}
}
function destroyChart() {
if (!chart) {
return
}
chart.dispose()
chart = null
}
async function renderChart() {
const token = ++renderToken
if (!hasChartData.value) {
destroyChart()
return
}
await nextTick()
await nextTick()
if (token !== renderToken || !chartEl.value) {
return
}
const points = chartPoints.value
try {
if (!chart || chart.isDisposed()) {
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
}
chart.setOption(buildOption(points), true)
chart.resize()
} catch (error) {
console.error('Price chart render failed:', error)
destroyChart()
}
}
watch(
() => [chartPoints.value.length, selectedValue.value, configStore.config?.color] as const,
() => {
void renderChart()
},
{ immediate: true, flush: 'post' }
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
)
onBeforeUnmount(() => {
renderToken++
destroyChart()
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
}
)
onMounted(async () => {
await nextTick()
chartMounted.value = true
})
const toPersianNumber = (num: number) => {
return num.toString().replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[d])
}
const chartOptions = computed(() => {
const fontFamily = getProjectFontFamily()
return {
chart: {
id: 'نوسانات قیمت',
type: 'area',
height: 200,
toolbar: { show: false },
zoom: { autoScaleYaxis: true },
redrawOnParentResize: true,
},
grid: {
padding: { top: 0, right: 0, bottom: 0, left: 0 },
yaxis: { lines: { show: false } },
},
stroke: { width: 2 },
dataLabels: {
enabled: false,
style: {
fontFamily,
},
},
yaxis: {
tickAmount: 8,
show: false,
labels: {
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontSize: '13px', fontFamily },
},
},
xaxis: {
show: false,
type: 'datetime',
axisTicks: { show: false },
tooltip: { enabled: false },
labels: { show: false },
},
tooltip: {
x: {
formatter: (value: number) => {
const date = new Date(value)
return `${date.toLocaleDateString('fa-IR')} - ${date.toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})}`
},
},
formatter: (val: number) => toPersianNumber(usePrice(val, true, 'ه.ریال')),
style: { fontFamily },
},
colors: [config.config.color],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.7,
opacityTo: 0.9,
stops: [0, 100],
},
},
}
})
const handleUpdate = (timeline: string) => {
selectedValue.value = timeline
emit('updateData', timeline)
}
</script>
<style scoped lang="less">
@boursecu-surface-border: #d4e3de;
.price-chart-box--boursecu {
border: 2px solid @boursecu-surface-border;
}
.chart-metal-segment {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: #dfe8e5;
}
.chart-metal-segment__item {
appearance: none;
cursor: pointer;
min-width: 6.5rem;
padding: 0.4rem 0.75rem;
border: none;
border-radius: 8px;
background: transparent;
color: @App-primary-color;
opacity: 0.65;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.chart-metal-segment__item--active {
background: #fff;
color: @App-primary-color;
opacity: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(10, 27, 55, 0.12);
}
</style>
+35 -7
View File
@@ -1,5 +1,11 @@
<template>
<div class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in mt-6 pb-45">
<div
class="w-full flex justify-evenly rounded-lg py-2 home_appear_fade_in"
:class="[
layout === 'boursecu' ? 'mt-8 pt-2' : 'mt-6',
variant === 'mobile' ? 'pb-45' : 'mt-0 pb-0',
]"
>
<div class="text-center w-full">
<div>بالاترین قیمت امروز</div>
<div v-if="price" class="flex flex-col items-center mt-3">
@@ -26,13 +32,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
import { usePriceWithoutType } from '../../../../utils/usePrice'
import type { ChartMetalType } from '../composables/usePriceChart'
import { resolveMetalDayRangePrices } from '../composables/usePriceChart'
const props = defineProps<{
price: any
}>()
const props = withDefaults(
defineProps<{
price: any
layout?: 'default' | 'boursecu'
variant?: 'mobile' | 'desktop'
selectedMetal?: ChartMetalType
maftolRatio?: number
}>(),
{
layout: 'default',
variant: 'mobile',
selectedMetal: 'katod',
maftolRatio: 0,
}
)
const highPrice = computed(() => props.price?.up?.buy ?? getTradeBuyPrice(props.price))
const lowPrice = computed(() => props.price?.up?.sell ?? getTradeSellPrice(props.price))
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: props.price,
metal: props.selectedMetal,
isBoursecuLayout: props.layout === 'boursecu',
maftolRatio: props.maftolRatio,
})
)
const highPrice = computed(() => rangePrices.value.high)
const lowPrice = computed(() => rangePrices.value.low)
</script>
@@ -1,35 +1,32 @@
<template>
<div class="bg-primary rounded-lg p-2 py-1 mt-2">
<div class="p-2">
<div
v-if="userStore.userInfo?.referral_id"
class="text-tertiary font-bold w-full text-center flex justify-between gap-2 items-center"
<div class="home-referral bg-primary rounded-lg">
<div
v-if="userStore.userInfo?.referral_id"
class="home-referral__code text-tertiary font-bold"
>
<span>کد معرف:</span>
<span class="home-referral__code-value">{{ userStore.userInfo?.referral_id }}</span>
</div>
<div class="home-referral__actions">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
کد معرف:
<div class="flex gap-2">
<div class="mr-2">{{ userStore.userInfo?.referral_id }}</div>
</div>
</div>
<div class="w-full flex gap-2 mt-1">
<BasicButton
icon="vuesax-linear:share"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="shareCode"
>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="flex-1 !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
دعوت از دوستان
</BasicButton>
<BasicButton
icon="vuesax-linear:copy"
size="small"
class="home-referral__btn !bg-primary !border-tertiary !text-tertiary"
icon-size="18"
@click.stop="copyCode"
>
رونوشت
</BasicButton>
</div>
</div>
</template>
@@ -58,4 +55,76 @@ const copyCode = () => {
message.info('کد رونوشت شد')
}
}
</script>
</script>
<style scoped lang="less">
.home-referral {
container-type: inline-size;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
margin-top: 8px;
padding: 10px 12px;
overflow: hidden;
&__code {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
margin-bottom: 8px;
}
&__code-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
gap: 8px;
width: 100%;
min-width: 0;
}
&__btn {
flex: 1 1 0;
min-width: 0 !important;
max-width: 100%;
:deep(.ant-btn) {
width: 100%;
min-width: 0;
max-width: 100%;
height: auto;
padding-inline: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.ant-btn > div) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@container (max-width: 340px) {
&__actions {
flex-direction: column;
}
&__btn {
flex: none;
width: 100%;
}
}
}
</style>
@@ -42,10 +42,15 @@ import { message } from 'ant-design-vue'
import { useUserStore } from '@/store/userStore'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
const props = defineProps<{
show: boolean
referralId: string
}>()
const props = withDefaults(
defineProps<{
show: boolean
referralId?: string
}>(),
{
referralId: '',
}
)
const emit = defineEmits<{
'update:show': [value: boolean]
+84 -53
View File
@@ -2,62 +2,93 @@ import { ref } from 'vue'
import { getConstByKey } from '@/api/const'
function parseJsonArray(raw: unknown): unknown[] {
if (raw == null || raw === '') {
return []
}
if (Array.isArray(raw)) {
return raw
}
if (typeof raw !== 'string') {
return []
}
const trimmed = raw.trim()
if (!trimmed) {
return []
}
if (raw == null || raw === '') {
return []
}
if (Array.isArray(raw)) {
return raw
}
if (typeof raw !== 'string') {
return []
}
const trimmed = raw.trim()
if (!trimmed) {
return []
}
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/** Module-level banner state — shared by mobile and desktop home layouts. */
const banners = ref<unknown[]>([])
const bannersLoading = ref(false)
let bannersPromise: Promise<void> | null = null
let bannersLoaded = false
async function fetchBanners(force = false) {
if (!force && bannersLoaded) {
return
}
if (!force && bannersPromise) {
return bannersPromise
}
bannersPromise = (async () => {
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
bannersLoading.value = true
const response = await getConstByKey('header_banner')
if (!response) {
banners.value = []
return
}
let list: unknown[] = []
if (typeof response === 'string') {
list = parseJsonArray(response)
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
list = parseJsonArray((response as { value?: unknown }).value)
} else if (Array.isArray(response) && response.length > 0) {
const first = response[0] as { value?: unknown; icon?: unknown }
if (first.value != null) {
list = parseJsonArray(first.value)
} else if (first.icon) {
list = response
}
}
banners.value = list.filter((banner) => (banner as { show?: boolean }).show === true)
bannersLoaded = true
} catch (error) {
console.error('Error loading banners:', error)
banners.value = []
} finally {
bannersLoading.value = false
bannersPromise = null
}
})()
return bannersPromise
}
export function useBanners() {
const banners = ref([])
const loading = ref(false)
const loadBanners = async () => {
try {
loading.value = true
const response = await getConstByKey('header_banner')
if (!response) {
banners.value = []
return
}
let list: unknown[] = []
if (typeof response === 'string') {
list = parseJsonArray(response)
} else if (typeof response === 'object' && !Array.isArray(response) && 'value' in response) {
list = parseJsonArray((response as { value?: unknown }).value)
} else if (Array.isArray(response) && response.length > 0) {
const first = response[0] as { value?: unknown; icon?: unknown }
if (first.value != null) {
list = parseJsonArray(first.value)
} else if (first.icon) {
list = response
}
}
banners.value = list.filter((b: { show?: boolean }) => b?.show === true)
} catch (error) {
console.error('Error loading banners:', error)
banners.value = []
} finally {
loading.value = false
}
}
return { banners, loading, loadBanners }
return {
banners,
loading: bannersLoading,
loadBanners: fetchBanners,
}
}
export function loadHomeBanners(force = false) {
return fetchBanners(force)
}
export function resetHomeBanners() {
banners.value = []
bannersLoaded = false
bannersPromise = null
}
@@ -0,0 +1,240 @@
import { computed, inject, onMounted, onUnmounted, provide, ref, type InjectionKey } from 'vue'
import router from '@/router'
import { loading, removeToken } from '@/utils'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { formatMetalAssetsInfoText, formatRialAssetsInfoText, resolveAssetsRialValue } from '@/utils/productAssets'
import { useConfigStore } from '@/store/configStore'
import { getTradeBuyPrice, lastPrice, stopLastPricePolling } from '@/utils/price'
import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours'
import { message, Modal } from 'ant-design-vue'
import { getReferralShareText, getReferralShareTitle } from '@/utils/config'
import { useBanners } from './useBanners'
import { usePriceChart, type ChartMetalType } from './usePriceChart'
import type { HomeActionItem } from '../layouts/types'
import { getHomeLayoutId } from '../layouts/registry'
import { initHomePageData, resetHomePageData } from './useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
import { resetAddressPageData } from '../../address/composables/useAddressPageData'
import { resetTransactionsPageData } from '../../transactions/composables/useTransactionsPageData'
import { resetProductsPageData } from '../../products/composables/useProductsPageData'
export interface HomePageContext {
loading: typeof loading
isGaroosi: ReturnType<typeof computed<boolean>>
limitedPrice: ReturnType<typeof computed>
showClosedMessage: ReturnType<typeof computed<boolean>>
closedMessage: ReturnType<typeof computed>
mainActions: ReturnType<typeof computed<HomeActionItem[]>>
banners: ReturnType<typeof useBanners>['banners']
priceChartSeries: ReturnType<typeof usePriceChart>['priceChartSeries']
chartSelection: ReturnType<typeof usePriceChart>['chartSelection']
updateChartData: ReturnType<typeof usePriceChart>['updateChartData']
selectedChartMetal: ReturnType<typeof usePriceChart>['selectedChartMetal']
setChartMetal: (metal: ChartMetalType) => void
isBoursecuHomeLayout: ReturnType<typeof computed<boolean>>
handleBannerClick: (banner: { typeClick?: string; modalText?: string; link?: string }) => void
openGoldModal: (metal: WalletMetalTotal) => void
openRialModal: () => void
}
export const HOME_PAGE_CONTEXT_KEY: InjectionKey<HomePageContext> = Symbol('homePageContext')
export function useHomePage() {
const userStore = useUserStore()
const walletStore = useWalletStore()
const configStore = useConfigStore()
const { assetsSummary, clearWalletAssets } = useWalletAssets()
const showLogoutConfirm = ref(false)
const wholeBalanceVisible = ref(false)
const showTextBanner = ref(false)
const showShare = ref(false)
const TextBanner = ref('')
const { banners } = useBanners()
const {
updateChartData,
priceChartSeries,
chartSelection,
selectedChartMetal,
setChartMetal,
} = usePriceChart()
const isBoursecuHomeLayout = computed(() => getHomeLayoutId() === 'boursecu')
const isGaroosi = computed(() => configStore.config?.name === 'garoosi')
const closedMessage = computed(() => userStore.getValueByKey('closed_message'))
const marketClosedText = computed(() => {
if (!isStoreClosed()) return ''
return closedMessage.value || getStoreClosedMessage()
})
const showClosedMessage = computed(() => !!marketClosedText.value)
const limitedPrice = computed(() => walletStore.limitedPrice?.[0])
const goldVal = computed(() => lastPrice.value?.price_last ?? 1)
const mainActions = computed<HomeActionItem[]>(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({ name: 'buyProduct' }),
},
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: isGaroosi.value ? 'واریز و برداشت' : 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: isGaroosi.value ? 'دعوت از دوستان' : 'دارایی ها',
handler: () => (isGaroosi.value ? shareCode() : router.push({ name: 'fund' })),
},
])
async function shareCode() {
const text = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: configStore.config.name,
referralId: userStore.userInfo?.referral_id,
})
try {
await navigator.share({
title: getReferralShareTitle(configStore.config.name),
text,
})
} catch {
copyCode()
}
}
function copyCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
clearWalletAssets()
resetHomePageData()
resetWalletPageData()
resetAddressPageData()
resetTransactionsPageData()
resetProductsPageData()
removeToken()
await router.push({ name: 'login' })
window.location.reload()
}
function openGoldModal(metal: WalletMetalTotal) {
Modal.confirm({
title: formatMetalAssetsInfoText(metal),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function openRialModal() {
const summary = assetsSummary.value
const cash = summary?.cash_wallet ?? 0
const metalRial = resolveAssetsRialValue(summary, getTradeBuyPrice(lastPrice.value))
Modal.confirm({
title: 'دارایی ریالی',
content: formatRialAssetsInfoText(cash, metalRial),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: { style: { display: 'none' } },
})
}
function handleBannerClick(banner: { typeClick?: string; modalText?: string; link?: string }) {
const handlers: Record<string, () => void> = {
share: () => {
showShare.value = true
},
modal: () => {
if (banner.modalText) {
TextBanner.value = banner.modalText
showTextBanner.value = true
}
},
link: () => {
if (banner.link) {
if (banner.link.startsWith('http')) {
window.open(banner.link, '_blank')
} else {
router.push(banner.link)
}
}
},
}
handlers[banner.typeClick ?? '']?.()
}
const context: HomePageContext = {
loading,
isGaroosi,
limitedPrice,
showClosedMessage,
closedMessage: marketClosedText,
mainActions,
banners,
priceChartSeries,
chartSelection,
updateChartData,
selectedChartMetal,
setChartMetal,
isBoursecuHomeLayout,
handleBannerClick,
openGoldModal,
openRialModal,
}
provide(HOME_PAGE_CONTEXT_KEY, context)
onUnmounted(() => {
stopLastPricePolling()
})
onMounted(() => {
// Single shared fetch for mobile + desktop — safe to call on remount (deduped in useHomePageData)
void initHomePageData()
})
return {
userStore,
walletStore,
showLogoutConfirm,
wholeBalanceVisible,
showTextBanner,
showShare,
TextBanner,
goldVal,
logout,
}
}
export function useHomePageContext() {
const context = inject(HOME_PAGE_CONTEXT_KEY)
if (!context) {
throw new Error('useHomePageContext must be used within useHomePage')
}
return context
}
@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { loading } from '@/utils'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { clearProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { fetchHomeChartPrices, loadHomeMaftolOffset, resetHomePriceChart } from './usePriceChart'
import { loadHomeBanners, resetHomeBanners } from './useBanners'
import { loadHomeFunds, resetHomeFunds } from './useUserFunds'
/**
* Single entry point for all homepage API calls.
* Mobile and desktop layouts must not fetch independently — they consume shared module state.
*/
let initPromise: Promise<void> | null = null
const initialized = ref(false)
export function isHomePageDataInitialized() {
return initialized.value
}
export async function initHomePageData(force = false) {
if (!force && initialized.value) {
// Resume live price updates when returning to home without refetching datasets
startLastPricePolling()
return
}
if (!force && initPromise) {
return initPromise
}
initPromise = (async () => {
try {
loading.value = true
const { loadWalletAssets } = useWalletAssets()
await Promise.all([
loadHomeFunds(force),
loadWalletAssets(force),
getLastPrices(),
fetchHomeChartPrices(force),
startLastPricePolling(),
loadHomeBanners(force),
])
if (getHomeLayoutId() === 'boursecu') {
await loadHomeMaftolOffset(force)
}
initialized.value = true
} catch (error) {
console.error('Home page initialization error:', error)
} finally {
loading.value = false
initPromise = null
}
})()
return initPromise
}
/** Clears homepage caches — call on logout or when a full refresh is required. */
export function resetHomePageData() {
initialized.value = false
initPromise = null
stopLastPricePolling()
resetHomeBanners()
resetHomeFunds()
resetHomePriceChart()
clearProductsCatalog()
}
export function useHomePageInitState() {
return { initialized }
}
+229 -97
View File
@@ -1,113 +1,245 @@
import { ref } from 'vue'
import { getAllPrices, getChart } from '@/api/home'
import { ref, watch } from 'vue'
import { getAllPrices } from '@/api/home'
import type { Product } from '@/models/products'
import { getProductsCatalog } from '@/composables/useProductsCatalog'
import { getHomeLayoutId } from '../layouts/registry'
import { getProductBuyPrice, resolvePriceOffsetRatio } from '@/utils/productPrice'
import { isKatodMesGroup, isMaftolMesGroup } from '@/utils/productDealMethod'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol'
type ChartPoint = [number, number]
function mapTimelinePoints(items: unknown[]): ChartPoint[] {
if (!Array.isArray(items)) {
return []
}
/** Module-level chart state — one dataset shared by mobile and desktop PriceChart instances. */
const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const maftolChartRatio = ref(0)
return items
.map((item) => {
const row = item as { timestamp?: number; value?: number; price?: number }
let timestamp = Number(row.timestamp)
const value = Number(row.value ?? row.price ?? 0)
let chartFetchPromise: Promise<void> | null = null
let chartInitialized = false
let maftolOffsetPromise: Promise<void> | null = null
let maftolOffsetLoaded = false
if (!Number.isFinite(timestamp) || !Number.isFinite(value) || value <= 0) {
return null
}
if (timestamp > 0 && timestamp < 1e11) {
timestamp *= 1000
}
return [timestamp, value] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
function findKatodProduct(products: Product[]) {
return (
products.find((product) => isKatodMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('کاتد'))
)
}
async function fetchCandleFallback(): Promise<ChartPoint[]> {
function findMaftolProduct(products: Product[]) {
return (
products.find((product) => isMaftolMesGroup(product.group_type))
?? products.find((product) => (product.title ?? '').includes('مفتول'))
)
}
function applyChartRatio(basePrice: number, ratio: number) {
if (!ratio) {
return basePrice
}
return Math.round(basePrice + basePrice * ratio)
}
/** بالاترین/پایین‌ترین امروز — برای boursecu با نسبت مفتول نسبت به کاتد */
export function resolveMetalDayRangePrices(options: {
price: { up?: { buy?: number; sell?: number }; buy?: number; sell?: number; price_last?: number } | null | undefined
metal: ChartMetalType
isBoursecuLayout: boolean
maftolRatio: number
}) {
const baseHigh = options.price?.up?.buy ?? getTradeBuyPrice(options.price as Parameters<typeof getTradeBuyPrice>[0])
const baseLow = options.price?.up?.sell ?? getTradeSellPrice(options.price as Parameters<typeof getTradeSellPrice>[0])
if (!options.isBoursecuLayout || options.metal === 'katod') {
return { high: baseHigh, low: baseLow }
}
return {
high: applyChartRatio(baseHigh, options.maftolRatio),
low: applyChartRatio(baseLow, options.maftolRatio),
}
}
function deriveMaftolChartRatio(maftolProduct?: Product, katodProduct?: Product) {
const offsetRatio = resolvePriceOffsetRatio(maftolProduct?.price_offset_percent)
if (offsetRatio) {
return offsetRatio
}
if (!maftolProduct) {
return 0
}
const basePrice = getTradeBuyPrice(lastPrice.value)
const katodPrice = katodProduct ? getProductBuyPrice(katodProduct) : basePrice
const maftolPrice = getProductBuyPrice(maftolProduct)
const referencePrice = katodPrice || basePrice
if (!referencePrice || !maftolPrice || referencePrice === maftolPrice) {
return 0
}
return (maftolPrice - referencePrice) / referencePrice
}
function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
}
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null {
if (!response || typeof response !== 'object') {
return null
}
const body = response as Record<string, unknown>
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
}
return null
}
function buildChartPoints(items: Array<{ timestamp: number; value: number }>): ChartPoint[] {
return items.map((item) => [
normalizeChartTimestamp(item.timestamp),
item.value - 1,
])
}
function syncChartSeries() {
const data =
getHomeLayoutId() === 'boursecu' && selectedChartMetal.value === 'maftol'
? rawChartPoints.value.map(
([timestamp, value]) =>
[timestamp, applyChartRatio(value, maftolChartRatio.value)] as ChartPoint
)
: rawChartPoints.value
priceChartSeries.value[0].data = data
}
watch([selectedChartMetal, maftolChartRatio], () => {
syncChartSeries()
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
return
}
if (!force && chartFetchPromise) {
return chartFetchPromise
}
chartFetchPromise = (async () => {
try {
const rows = await getChart()
if (!Array.isArray(rows)) {
return []
}
return rows
.map((row) => {
if (!row) {
return null
}
const dateRaw = Array.isArray(row) ? row[0] : row.date
const candle = Array.isArray(row) ? row[1] : row.candle
let value = 0
if (Array.isArray(candle)) {
value = Number(candle[3] ?? candle[2] ?? candle[0] ?? 0)
} else if (candle && typeof candle === 'object') {
const candleRow = candle as { end?: number; high?: number; start?: number }
value = Number(candleRow.end ?? candleRow.high ?? candleRow.start ?? 0)
} else {
value = Number(candle ?? 0)
}
let timestamp = typeof dateRaw === 'number'
? dateRaw
: new Date(String(dateRaw)).getTime()
if (timestamp > 0 && timestamp < 1e11) {
timestamp *= 1000
}
if (!Number.isFinite(timestamp) || !Number.isFinite(value) || value <= 0) {
return null
}
return [timestamp, value] as ChartPoint
})
.filter((point): point is ChartPoint => point !== null)
} catch {
return []
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
chartLoading.value = false
chartFetchPromise = null
}
})()
return chartFetchPromise
}
async function loadMaftolOffset(force = false) {
if (getHomeLayoutId() !== 'boursecu') {
maftolChartRatio.value = 0
syncChartSeries()
return
}
if (!force && maftolOffsetLoaded) {
return
}
if (!force && maftolOffsetPromise) {
return maftolOffsetPromise
}
maftolOffsetPromise = (async () => {
try {
const products = await getProductsCatalog(force)
const katodProduct = findKatodProduct(products)
const maftolProduct = findMaftolProduct(products)
maftolChartRatio.value = deriveMaftolChartRatio(maftolProduct, katodProduct)
syncChartSeries()
maftolOffsetLoaded = true
} catch (error) {
console.error('Error loading maftol chart ratio:', error)
maftolChartRatio.value = 0
syncChartSeries()
} finally {
maftolOffsetPromise = null
}
})()
return maftolOffsetPromise
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
void fetchPrices(true)
}
function setChartMetal(metal: ChartMetalType) {
selectedChartMetal.value = metal
}
export function usePriceChart() {
const priceChartSeries = ref([{ name: 'قیمت', data: [] as ChartPoint[] }])
const selection = ref('DAILY')
const chartLoading = ref(false)
return {
priceChartSeries,
chartSelection,
chartLoading,
selectedChartMetal,
maftolChartRatio,
updateChartData,
fetchPrices,
loadMaftolOffset,
setChartMetal,
}
}
const fetchPrices = async () => {
try {
chartLoading.value = true
const response = await getAllPrices()
const prices = response?.data?.prices ?? response?.prices
export function fetchHomeChartPrices(force = false) {
return fetchPrices(force)
}
let points: ChartPoint[] = []
if (prices && typeof prices === 'object') {
const selectedData = prices[selection.value]
points = mapTimelinePoints(Array.isArray(selectedData) ? selectedData : [])
}
export function loadHomeMaftolOffset(force = false) {
return loadMaftolOffset(force)
}
if (points.length === 0) {
points = await fetchCandleFallback()
}
priceChartSeries.value = [{ name: 'قیمت', data: points }]
} catch (error) {
console.error('Error fetching prices:', error)
const fallback = await fetchCandleFallback()
priceChartSeries.value = [{ name: 'قیمت', data: fallback }]
} finally {
chartLoading.value = false
}
}
const updateChartData = (timeline: string) => {
selection.value = timeline
fetchPrices()
}
return { priceChartSeries, chartLoading, updateChartData, fetchPrices }
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
maftolChartRatio.value = 0
chartInitialized = false
chartFetchPromise = null
maftolOffsetLoaded = false
maftolOffsetPromise = null
}
+48 -16
View File
@@ -1,22 +1,54 @@
import { ref } from 'vue'
import { useFundUserStore } from '@/store/fundStore'
export function useUserFunds() {
const funds = ref([])
const loading = ref(false)
const fundStore = useFundUserStore()
/** Module-level fund list — shared across home layout variants. */
const funds = ref<unknown[]>([])
const fundsLoading = ref(false)
let fundsPromise: Promise<void> | null = null
let fundsLoaded = false
const loadFunds = async () => {
try {
loading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
} catch (error) {
console.error('Error loading funds:', error)
} finally {
loading.value = false
}
async function fetchFunds(force = false) {
if (!force && fundsLoaded) {
return
}
if (!force && fundsPromise) {
return fundsPromise
}
const fundStore = useFundUserStore()
fundsPromise = (async () => {
try {
fundsLoading.value = true
const result = await fundStore.getMyFund()
funds.value = result?.rows || []
fundsLoaded = true
} catch (error) {
console.error('Error loading funds:', error)
} finally {
fundsLoading.value = false
fundsPromise = null
}
})()
return { funds, loading, loadFunds }
}
return fundsPromise
}
export function useUserFunds() {
return {
funds,
loading: fundsLoading,
loadFunds: fetchFunds,
}
}
export function loadHomeFunds(force = false) {
return fetchFunds(force)
}
export function resetHomeFunds() {
funds.value = []
fundsLoaded = false
fundsPromise = null
}
@@ -0,0 +1,17 @@
<template>
<BoursecuDesktopHome v-if="layoutId === 'boursecu'" @logout="$emit('logout')" />
<DefaultDesktopHome v-else @logout="$emit('logout')" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { getHomeLayoutId } from '../layouts/registry'
import BoursecuDesktopHome from './layouts/BoursecuDesktopHome.vue'
import DefaultDesktopHome from './layouts/DefaultDesktopHome.vue'
defineEmits<{
logout: []
}>()
const layoutId = computed(() => getHomeLayoutId())
</script>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import AssetsSection from '../../components/AssetsSection.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import type { WalletMetalTotal } from '@/models/walletAssets'
defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
</script>
<template>
<DesktopHomeCard title="دارایی‌های من">
<AssetsSection
plain
class="home-desktop-assets"
@open-gold-modal="$emit('openGoldModal', $event)"
@open-rial-modal="$emit('openRialModal')"
/>
</DesktopHomeCard>
</template>
<style scoped>
:deep(.home-desktop-assets) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
:deep(.home-desktop-assets > div) {
margin-top: 0 !important;
justify-content: flex-start !important;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background: #eff5f3;
border: 1px solid #d4e3de;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { usePriceWithoutType } from '@/utils/usePrice'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { buildBoursecuHomeAssetRows, resolveSummaryMetalWeight } from '@/utils/productAssets'
import { useHomePageContext } from '../../composables/useHomePage'
const emit = defineEmits<{
openGoldModal: [metal: WalletMetalTotal]
openRialModal: []
}>()
const { assetsSummary } = useWalletAssets()
const ctx = useHomePageContext()
const isBoursecuLayout = computed(() => ctx.isBoursecuHomeLayout.value)
const meta = computed(() => assetsSummary.value?.meta)
const metalTotals = computed(() => assetsSummary.value?.metal_totals ?? [])
const inventoryCards = computed(() => assetsSummary.value?.inventory_cards ?? [])
const totalRialValue = computed(() => assetsSummary.value?.total_rial_value ?? 0)
const countBasedInventory = computed(() =>
(assetsSummary.value?.count_products ?? []).reduce((sum, item) => sum + Number(item.quantity ?? 0), 0),
)
type MetricItem = {
id: string
label: string
value: string
metal?: WalletMetalTotal
showInfo?: boolean
onInfo?: 'gold' | 'rial'
}
const metrics = computed<MetricItem[]>(() => {
const items: MetricItem[] = []
if (meta.value?.has_count_based_products) {
items.push({
id: 'products',
label: 'محصولات',
value: `${countBasedInventory.value} عدد`,
})
}
if (isBoursecuLayout.value) {
buildBoursecuHomeAssetRows(assetsSummary.value).forEach((row) => {
items.push({
id: row.id,
label: row.label,
value: row.value,
metal: row.metal,
showInfo: row.info !== 'none',
onInfo: row.info === 'rial' ? 'rial' : 'gold',
})
})
return items
}
if (meta.value?.single_metal && metalTotals.value[0]) {
const metal = metalTotals.value[0]
items.push({
id: `metal-${metal.metal_id}`,
label: metal.label,
value: formatMetalTotal(metal),
metal,
showInfo: true,
onInfo: 'gold',
})
} else {
metalTotals.value.forEach((metal, index) => {
items.push({
id: `metal-${metal.metal_id}`,
label: metal.label,
value: formatMetalTotal(metal),
metal,
showInfo: index === 0,
onInfo: 'gold',
})
})
}
items.push({
id: 'rial',
label: 'دارایی ریالی',
value: `${usePriceWithoutType(totalRialValue.value)} ریال`,
showInfo: true,
onInfo: 'rial',
})
return items
})
function formatMetalTotal(metal: WalletMetalTotal) {
return `${resolveSummaryMetalWeight(metal, inventoryCards.value)}${metal.unit}`
}
function handleInfo(metric: MetricItem) {
if (metric.onInfo === 'rial') {
emit('openRialModal')
return
}
if (metric.metal) {
emit('openGoldModal', metric.metal)
}
}
</script>
<template>
<div class="dhome-metrics">
<article v-for="metric in metrics" :key="metric.id" class="dhome-metric-tile">
<div class="dhome-metric-tile__head">
<span class="dhome-metric-tile__label">{{ metric.label }}</span>
<button
v-if="metric.showInfo"
type="button"
class="dhome-metric-tile__info"
aria-label="اطلاعات بیشتر"
@click="handleInfo(metric)"
>
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
</button>
</div>
<p class="dhome-metric-tile__value">{{ metric.value }}</p>
</article>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { inject } from 'vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import BannerSlider from '../../components/BannerSlider.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
</script>
<template>
<DesktopHomeSection
v-if="ctx.isGaroosi.value && ctx.banners.value?.length"
title="بنرها"
>
<BannerSlider
:banners="ctx.banners.value"
@bannerClick="ctx.handleBannerClick"
/>
</DesktopHomeSection>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import PriceChart from '../../components/PriceChart.vue'
import DesktopHomeCard from '../components/DesktopHomeCard.vue'
const ctx = useHomePageContext()
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
<template>
<DesktopHomeCard title="نمودار قیمت">
<PriceChart
variant="desktop"
:price-chart-series="priceChartSeries"
:layout="chartLayout"
:chart-selection="chartSelection"
:selected-metal="selectedChartMetal"
@update-data="ctx.updateChartData"
@update:selected-metal="ctx.setChartMetal"
/>
</DesktopHomeCard>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import PriceChart from '../../components/PriceChart.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
const ctx = useHomePageContext()
const chartLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
<template>
<DesktopHomeSection title="نمودار قیمت" class="dhome-chart">
<PriceChart
variant="desktop"
:price-chart-series="priceChartSeries"
:layout="chartLayout"
:chart-selection="chartSelection"
:selected-metal="selectedChartMetal"
@update-data="ctx.updateChartData"
@update:selected-metal="ctx.setChartMetal"
/>
</DesktopHomeSection>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { toValue } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { useHomePageContext } from '../../composables/useHomePage'
import type { HomeActionItem } from '../../layouts/types'
const ctx = useHomePageContext()
function runAction(action: HomeActionItem) {
action.handler()
}
</script>
<template>
<DesktopHomeSection title="عملیات">
<div class="dhome-default-actions">
<button
v-for="action in toValue(ctx.mainActions)"
:key="action.name"
type="button"
class="dhome-default-action"
@click="runAction(action)"
>
<span class="dhome-default-action__icon">
<BasicIcon :icon="action.icon" size="20" />
</span>
<span class="dhome-default-action__label">{{ action.label }}</span>
</button>
</div>
</DesktopHomeSection>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { inject } from 'vue'
import { useUserStore } from '@/store/userStore'
import { usePriceWithoutType } from '@/utils/usePrice'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const userStore = useUserStore()
</script>
<template>
<DesktopHomeSection v-if="ctx.isGaroosi.value" title="دارایی‌ها">
<div class="dhome-garoosi-assets">
<div class="dhome-garoosi-assets__item">
<span class="dhome-garoosi-assets__label">دارایی طلایی</span>
<span class="dhome-garoosi-assets__value">
{{ (userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3) }} گرم
</span>
</div>
<div class="dhome-garoosi-assets__divider" />
<div class="dhome-garoosi-assets__item">
<span class="dhome-garoosi-assets__label">دارایی ریالی</span>
<span class="dhome-garoosi-assets__value">
{{ usePriceWithoutType(userStore?.userInfo?.wallet?.count_cash ?? 0) }} ریال
</span>
</div>
</div>
</DesktopHomeSection>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useUserStore } from '@/store/userStore'
import { usePriceWithoutType } from '@/utils/usePrice'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const userStore = useUserStore()
const showLimitedPrice = computed(
() =>
ctx.limitedPrice.value !== undefined &&
userStore.userInfo?.trade_active !== 1,
)
const limitedPriceText = computed(() =>
usePriceWithoutType(Number(ctx.limitedPrice.value?.value ?? 0)),
)
</script>
<template>
<div v-if="showLimitedPrice || ctx.showClosedMessage.value" class="dhome-alerts">
<div v-if="showLimitedPrice" class="dhome-alert dhome-alert--warning">
<BasicIcon icon="vuesax-linear:info-circle" size="14" />
<span>
حداقل مبلغ اولیه برای ثبت سفارش {{ limitedPriceText }} ریال می‌باشد.
</span>
</div>
<div
v-if="ctx.showClosedMessage.value"
class="dhome-alert dhome-alert--info"
v-html="ctx.closedMessage.value"
/>
</div>
</template>
@@ -0,0 +1,12 @@
<template>
<section class="rounded-xl border border-[#d4e3de] bg-white p-5 shadow-sm">
<h2 v-if="title" class="mb-4 text-base font-bold text-primary">{{ title }}</h2>
<slot />
</section>
</template>
<script lang="ts" setup>
defineProps<{
title?: string
}>()
</script>
@@ -0,0 +1,49 @@
<template>
<header class="flex items-center justify-between gap-4">
<div>
<p class="text-sm text-gray-500">خوش آمدید</p>
<h1 class="text-xl font-bold text-gray-900">
{{ userStore.userInfo?.name ?? 'کاربر' }}
</h1>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="flex items-center gap-2 rounded-lg border border-[#d4e3de] bg-[#eff5f3] px-4 py-2 text-sm font-bold text-primary transition hover:bg-[#e4efea]"
@click="router.push({ name: 'user' })"
>
<BasicIcon icon="vuesax-linear:user" size="18" />
<span>حساب کاربری</span>
</button>
<button
v-if="showLogout"
type="button"
class="flex items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm font-bold text-gray-600 transition hover:bg-gray-50"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</div>
</header>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import router from '@/router'
withDefaults(
defineProps<{
showLogout?: boolean
}>(),
{ showLogout: true },
)
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
</script>
@@ -0,0 +1,15 @@
<template>
<section class="dhome-surface">
<header v-if="title" class="dhome-section__header">
<h2 class="dhome-section__title">{{ title }}</h2>
<slot name="header-extra" />
</header>
<slot />
</section>
</template>
<script lang="ts" setup>
defineProps<{
title?: string
}>()
</script>
@@ -0,0 +1,15 @@
<template>
<div class="dhome" :class="{ 'dhome--boursecu': isBoursecu }">
<div class="dhome__container">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import { isBoursecuProject } from '@/utils/config'
const isBoursecu = isBoursecuProject()
</script>
<style lang="less" src="../styles/desktop-home.less"></style>
@@ -0,0 +1,43 @@
<template>
<header class="dhome-topbar">
<div>
<p class="dhome-topbar__eyebrow">نمای کلی</p>
<h1 class="dhome-topbar__title">{{ userStore.userInfo?.name ?? 'کاربر' }}</h1>
</div>
<div class="dhome-topbar__actions">
<button type="button" class="dhome-btn-ghost" @click="router.push({ name: 'user' })">
<BasicIcon icon="vuesax-linear:user" size="16" />
<span>حساب کاربری</span>
</button>
<button
v-if="showLogout"
type="button"
class="dhome-btn-ghost"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="16" />
<span>خروج</span>
</button>
</div>
</header>
</template>
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import router from '@/router'
withDefaults(
defineProps<{
showLogout?: boolean
}>(),
{ showLogout: true },
)
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
</script>
@@ -0,0 +1,46 @@
<template>
<div class="dhome-surface dhome-hero-price">
<span class="dhome-label">قیمت لحظه‌ای</span>
<p class="dhome-hero-price__brand">{{ config.config?.name_fa }}</p>
<div class="dhome-hero-price__value-row">
<span
v-if="percentChange !== 0"
class="dhome-hero-price__delta"
:class="percentChange >= 0 ? 'dhome-hero-price__delta--up' : 'dhome-hero-price__delta--down'"
>
{{ useTowDecimal(percentChange) }}%
</span>
<Vue3Odometer
:value="displayGoldVal"
class="dhome-hero-price__odometer o-text"
style="font-family: var(--App-font-family)"
/>
<span class="dhome-hero-price__unit">ریال</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Vue3Odometer from 'vue3-odometer'
import 'odometer/themes/odometer-theme-default.css'
import { useConfigStore } from '@/store/configStore'
import { getTradeBuyPrice, lastPrice, percentPriceChanged } from '@/utils/price'
import { useTowDecimal } from '@/utils/usePrice'
const config = useConfigStore()
const isMarketClosed = computed(
() => lastPrice?.value?.goldir === 0 && getTradeBuyPrice(lastPrice.value) === 0,
)
const displayGoldVal = computed(() => {
if (isMarketClosed.value) return 0
return getTradeBuyPrice(lastPrice.value)
})
const percentChange = computed(() => {
if (!getTradeBuyPrice(lastPrice.value)) return 0
return percentPriceChanged()
})
</script>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, toValue } from 'vue'
import { useHomePageContext } from '../../composables/useHomePage'
import { price } from '@/utils/price'
import CurrentPrice from '../../components/CurrentPrice.vue'
import PriceStats from '../../components/PriceStats.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
const ctx = useHomePageContext()
const statsLayout = computed(() => (toValue(ctx.isBoursecuHomeLayout) ? 'boursecu' : 'default'))
</script>
<template>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<DesktopHomeCard title="قیمت لحظه‌ای">
<CurrentPrice class="home-desktop-current-price" />
</DesktopHomeCard>
<DesktopHomeCard title="بازه قیمت امروز">
<PriceStats variant="desktop" :price="price" :layout="statsLayout" />
</DesktopHomeCard>
</div>
</template>
<style scoped>
:deep(.home-desktop-current-price) {
margin-top: 0;
border: none;
padding-top: 0;
padding-bottom: 0;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { price } from '@/utils/price'
import { usePriceWithoutType } from '@/utils/usePrice'
import { getHomeLayoutId } from '../../layouts/registry'
import { resolveMetalDayRangePrices, usePriceChart } from '../../composables/usePriceChart'
const { selectedChartMetal, maftolChartRatio } = usePriceChart()
const isBoursecuLayout = computed(() => getHomeLayoutId() === 'boursecu')
const rangePrices = computed(() =>
resolveMetalDayRangePrices({
price: price.value,
metal: selectedChartMetal.value,
isBoursecuLayout: isBoursecuLayout.value,
maftolRatio: maftolChartRatio.value,
})
)
const highPrice = computed(() => rangePrices.value.high)
const lowPrice = computed(() => rangePrices.value.low)
</script>
<template>
<div class="dhome-surface dhome-price-range">
<div class="dhome-price-range__item">
<span class="dhome-label">بیشترین امروز</span>
<span class="dhome-price-range__value dhome-price-range__value--high">
{{ usePriceWithoutType(highPrice) }}
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
</span>
</div>
<div class="dhome-price-range__divider" />
<div class="dhome-price-range__item">
<span class="dhome-label">کمترین امروز</span>
<span class="dhome-price-range__value dhome-price-range__value--low">
{{ usePriceWithoutType(lowPrice) }}
<span class="text-xs font-medium text-gray-400 mr-1">ریال</span>
</span>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import router from '@/router'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
const props = withDefaults(
defineProps<{
actions?: HomeQuickActionItem[]
}>(),
{
actions: () => boursecuQuickActions,
},
)
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
}
</script>
<template>
<DesktopHomeCard title="دسترسی سریع">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<HomeQuickActionBox
v-for="action in props.actions"
:key="action.id"
:action="action"
class="home-desktop-quick-action"
@click="handleClick(action)"
/>
</div>
</DesktopHomeCard>
</template>
<style scoped>
:deep(.home-desktop-quick-action) {
min-height: 108px;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
const props = withDefaults(
defineProps<{
actions?: HomeQuickActionItem[]
title?: string
}>(),
{
actions: () => boursecuQuickActions,
title: 'میانبرها',
},
)
function navigate(action: HomeQuickActionItem) {
router.push(action.route)
}
</script>
<template>
<DesktopHomeSection :title="title">
<nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی">
<button
v-for="action in props.actions"
:key="action.id"
type="button"
class="dhome-action"
@click="navigate(action)"
>
<span class="dhome-action__icon">
<BasicIcon :icon="action.icon" size="18" />
</span>
<span class="dhome-action__label">{{ action.label }}</span>
<BasicIcon icon="vuesax-linear:arrow-left" size="14" class="dhome-action__chevron" />
</button>
</nav>
</DesktopHomeSection>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { inject } from 'vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import ReferralSection from '../../components/ReferralSection.vue'
import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
</script>
<template>
<DesktopHomeSection v-if="ctx.isGaroosi.value" title="دعوت از دوستان">
<ReferralSection />
</DesktopHomeSection>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
import { useHomePageContext } from '../../composables/useHomePage'
import HomeSkeleton from '../../components/HomeSkeleton.vue'
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
import DesktopHomeTopbar from '../components/DesktopHomeTopbar.vue'
import DesktopHomeAlerts from '../components/DesktopHomeAlerts.vue'
import DesktopLivePrice from '../components/DesktopLivePrice.vue'
import DesktopPriceRange from '../components/DesktopPriceRange.vue'
import DesktopAssetsMetrics from '../components/DesktopAssetsMetrics.vue'
import DesktopChartSection from '../components/DesktopChartSection.vue'
import DesktopQuickActionsPanel from '../components/DesktopQuickActionsPanel.vue'
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
const ctx = useHomePageContext()
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-else-if="showContent">
<section class="dhome-hero-row">
<DesktopLivePrice />
<DesktopPriceRange />
</section>
<DesktopAssetsMetrics
@open-gold-modal="ctx.openGoldModal"
@open-rial-modal="ctx.openRialModal"
/>
<section class="dhome-main-grid">
<DesktopChartSection />
<DesktopQuickActionsPanel />
</section>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
import { useHomePageContext } from '../../composables/useHomePage'
import HomeSkeleton from '../../components/HomeSkeleton.vue'
import DesktopHomeShell from '../components/DesktopHomeShell.vue'
import DesktopHomeTopbar from '../components/DesktopHomeTopbar.vue'
import DesktopHomeAlerts from '../components/DesktopHomeAlerts.vue'
import DesktopLivePrice from '../components/DesktopLivePrice.vue'
import DesktopPriceRange from '../components/DesktopPriceRange.vue'
import DesktopAssetsMetrics from '../components/DesktopAssetsMetrics.vue'
import DesktopChartSection from '../components/DesktopChartSection.vue'
import DesktopDefaultActions from '../components/DesktopDefaultActions.vue'
import DesktopGaroosiAssets from '../components/DesktopGaroosiAssets.vue'
import DesktopReferralPanel from '../components/DesktopReferralPanel.vue'
import DesktopBannersPanel from '../components/DesktopBannersPanel.vue'
defineEmits<{
logout: []
}>()
const userStore = useUserStore()
const ctx = useHomePageContext()
const showSkeleton = computed(() => loading.value || !userStore.userInfo)
const showContent = computed(() => !loading.value && Boolean(userStore.userInfo))
</script>
<template>
<DesktopHomeShell>
<DesktopHomeTopbar @logout="$emit('logout')" />
<DesktopHomeAlerts />
<HomeSkeleton v-if="showSkeleton" />
<template v-else-if="showContent">
<section class="dhome-hero-row">
<DesktopLivePrice />
<DesktopPriceRange />
</section>
<DesktopAssetsMetrics
v-if="!ctx.isGaroosi.value"
@open-gold-modal="ctx.openGoldModal"
@open-rial-modal="ctx.openRialModal"
/>
<div v-if="ctx.isGaroosi.value" class="dhome-default-grid">
<div class="dhome-default-grid__main">
<DesktopDefaultActions />
<DesktopBannersPanel />
<DesktopChartSection />
</div>
<aside class="dhome-default-grid__side">
<DesktopGaroosiAssets />
<DesktopReferralPanel />
</aside>
</div>
<template v-else>
<DesktopDefaultActions />
<DesktopChartSection />
</template>
</template>
</DesktopHomeShell>
</template>
@@ -0,0 +1,516 @@
/* Premium desktop home — SaaS dashboard design tokens */
.dhome {
min-height: 100%;
background: #f6f7f9;
color: #111827;
font-feature-settings: 'ss01' on;
}
.dhome__container {
max-width: 1180px;
margin: 0 auto;
padding: 28px 36px 40px;
}
.dhome-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: #9ca3af;
margin-bottom: 6px;
}
.dhome-surface {
background: #ffffff;
border-radius: 14px;
padding: 22px 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-width: 0;
overflow: hidden;
.home-referral {
margin-top: 0;
}
}
.dhome-section {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
&__title {
font-size: 13px;
font-weight: 600;
color: #374151;
letter-spacing: -0.01em;
}
}
/* Topbar */
.dhome-topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
&__eyebrow {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
margin-bottom: 4px;
}
&__title {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.03em;
color: #111827;
line-height: 1.2;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.dhome-btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: #111827;
background: rgba(15, 23, 42, 0.04);
}
}
/* Alerts */
.dhome-alerts {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.dhome-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
&--warning {
background: #fef3f2;
color: #b42318;
}
&--info {
background: #f0f4ff;
color: #344054;
}
}
/* Hero price */
.dhome-hero-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
margin-bottom: 16px;
}
.dhome-hero-price {
&__brand {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px;
}
&__value-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
}
&__odometer {
font-size: 32px !important;
font-weight: 600 !important;
letter-spacing: -0.03em;
color: #111827;
direction: ltr;
line-height: 1;
}
&__unit {
font-size: 14px;
font-weight: 500;
color: #9ca3af;
}
&__delta {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
direction: ltr;
&--up {
background: #ecfdf3;
color: #027a48;
}
&--down {
background: #fef3f2;
color: #b42318;
}
}
}
.dhome-price-range {
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
height: 100%;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__value {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
direction: ltr;
text-align: right;
}
&__value--high {
color: #027a48;
}
&__value--low {
color: #b42318;
}
&__divider {
height: 1px;
background: rgba(15, 23, 42, 0.06);
}
}
/* Asset metrics */
.dhome-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.dhome-metric-tile {
background: #ffffff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
&__label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
&__info {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: #f3f4f6;
color: #374151;
}
}
&__value {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
line-height: 1.2;
}
}
/* Main dashboard grid */
.dhome-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 272px;
gap: 16px;
align-items: start;
}
.dhome-chart {
:deep(.bg-gray-50) {
background: transparent !important;
border-radius: 0;
padding-top: 0;
padding-bottom: 0;
}
:deep(.w-full.text-center.pt-2) {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
:deep(.chart-metal-segment) {
display: inline-flex;
background: #f3f4f6;
border-radius: 8px;
padding: 3px;
margin-bottom: 0 !important;
flex-shrink: 0;
}
:deep(.chart-metal-segment__item) {
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
:deep(.chart-timeline-group) {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
width: 100%;
margin-bottom: 4px;
}
:deep(.chart-timeline-group .ant-radio-button-wrapper) {
margin: 0;
}
}
/* Quick actions */
.dhome-actions {
display: flex;
flex-direction: column;
gap: 2px;
}
.dhome-action {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 11px 10px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: background 0.15s ease;
&:hover {
background: #f9fafb;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #f3f4f6;
color: @App-primary-color;
flex-shrink: 0;
transition: background 0.15s ease;
}
&:hover &__icon {
background: #eef2f6;
}
&__label {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #374151;
}
&__chevron {
color: #d1d5db;
flex-shrink: 0;
}
}
/* Default layout grid */
.dhome-default-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 16px;
align-items: start;
}
.dhome-default-grid__main,
.dhome-default-grid__side {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.dhome-default-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.dhome-default-action {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 18px 12px;
border: none;
border-radius: 12px;
background: #f9fafb;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, transform 0.15s ease;
&:hover {
background: #f3f4f6;
transform: translateY(-1px);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #ffffff;
color: @App-primary-color;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
&__label {
font-size: 12px;
font-weight: 600;
color: #374151;
text-align: center;
line-height: 1.4;
}
}
/* Garoosi assets strip */
.dhome-garoosi-assets {
display: flex;
flex-direction: column;
gap: 14px;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
font-size: 12px;
font-weight: 500;
color: #6b7280;
}
&__value {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
color: #111827;
direction: ltr;
text-align: right;
}
&__divider {
height: 1px;
background: rgba(15, 23, 42, 0.06);
}
}
/* Boursecu subtle accent */
.dhome--boursecu {
.dhome-action__icon {
background: rgba(19, 48, 80, 0.06);
color: #133050;
}
.dhome-hero-price__odometer {
color: #133050;
}
}
@media (max-width: 1024px) {
.dhome__container {
padding: 24px 28px 36px;
}
.dhome-hero-row,
.dhome-main-grid,
.dhome-default-grid {
grid-template-columns: 1fr;
}
.dhome-default-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+32 -277
View File
@@ -1,296 +1,51 @@
<template>
<div class="mt-10 p-5">
<div v-if="userStore.userInfo && !loading">
<!-- هشدار حداقل مبلغ -->
<div
v-if="limitedPrice !== undefined && userStore?.userInfo.trade_active !== 1"
class="text-red text-xs flex gap-1 items-center justify-center mb-2"
>
<Icon icon="vuesax-linear:info-circle" size="16"/>
حداقل مبلغ اولیه برای ثبت سفارش {{ usePriceWithoutType(limitedPrice.value as number ?? 0) }} ریال می باشد.
</div>
<div class="home-page-root">
<HomePageShell @logout="showLogoutConfirm = true" />
<!-- بخش اصلی -->
<div class="p-1 from-primary rounded-lg p-px to-transparent" :class="isGaroosi ? 'mt-0' : 'mt-5'">
<!-- دارایی‌ها (فقط گاروسی) -->
<div v-if="isGaroosi" class="flex justify-between px-2 mb-2">
<AssetItem label="دارایی طلایی:" :value="`${(userStore?.userInfo?.wallet?.count_gold ?? 0).toFixed(3)} گرم`"/>
<AssetItem label="دارایی ریالی:"
:value="`${usePriceWithoutType(userStore?.userInfo?.wallet?.count_cash ?? 0)} ریال`"/>
</div>
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout" />
<!-- دکمه‌های اصلی -->
<div class="p-2 py-3 rounded-lg bg-tertiary">
<div class="flex justify-between">
<ActionButton
v-for="action in mainActions"
:key="action.name"
:action="action"
@click="action.handler"
/>
</div>
</div>
<BalanceSheet
v-model:show="wholeBalanceVisible"
:wallet-info="walletStore.walletInfo"
:gold-val="goldVal"
/>
<!-- بخش کد معرف (گاروسی) -->
<ReferralSection v-if="isGaroosi"/>
<!-- بخش دارایی‌ها (سایر پروژه‌ها) -->
<AssetsSection v-else @openGoldModal="openGoldModal" @openRialModal="openRialModal"/>
<!-- پیام بسته بودن بازار -->
<MarketClosedMessage v-if="showClosedMessage" :message="marketClosedText"/>
<!-- قیمت فعلی -->
<CurrentPrice/>
<!-- بنرها -->
<BannerSlider v-if="isGaroosi && banners?.length" :banners="banners" @bannerClick="handleBannerClick"/>
<!-- نمودار قیمت -->
<PriceChart @updateData="updateChartData" :price-chart-series="priceChartSeries"/>
<!-- آمار بالا و پایین -->
<PriceStats :price="price"/>
</div>
<!-- اسکلت لودینگ -->
<HomeSkeleton v-if="loading" class="w-full"/>
</div>
<!-- هدر -->
<AppHeader @logout="showLogoutConfirm = true"/>
<!-- دیالوگ خروج -->
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout"/>
<!-- شیت موجودی -->
<BalanceSheet v-model:show="wholeBalanceVisible" :walletInfo="walletStore.walletInfo" :goldVal="goldVal"/>
<!-- مودال‌ها -->
<BasicModal v-model:visible="showTextBanner" :custom-footer="true" destroyOnClose>
<div class="max-h-[54vh] ql-editor" v-html="TextBanner"></div>
<BasicModal v-model:visible="showTextBanner" :custom-footer="true" destroy-on-close>
<div class="max-h-[54vh] ql-editor" v-html="TextBanner" />
</BasicModal>
<ShareModal v-model:visible="showShare" :referralId="userStore.userInfo?.referral_id"/>
<ShareModal v-model:show="showShare" :referral-id="userStore.userInfo?.referral_id" />
</div>
</template>
<script lang="tsx" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import router from '@/router'
import {useWindowScroll} from '@vueuse/core'
import {loading, removeToken} from '@/utils'
import {useUserStore} from '@/store/userStore'
import {useWalletStore} from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import type { WalletMetalTotal } from '@/models/walletAssets'
import { formatMetalAssetsInfoText, formatRialAssetsInfoText, resolveAssetsRialValue } from '@/utils/productAssets'
import {getLastPrices, getTradeBuyPrice, lastPrice, price, startLastPricePolling, stopLastPricePolling} from '@/utils/price'
import {useConfigStore} from '@/store/configStore'
import {useFundUserStore} from '@/store/fundStore'
import {usePriceWithoutType} from '../../../utils/usePrice'
import { getStoreClosedMessage, isStoreClosed } from '@/utils/storeHours'
import {getConstByKey} from '@/api/const'
import {message, Modal} from 'ant-design-vue'
// Components
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import HomeSkeleton from '@/views/base/home/components/HomeSkeleton.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import AssetItem from './components/AssetItem.vue'
import ActionButton from './components/ActionButton.vue'
import ReferralSection from './components/ReferralSection.vue'
import AssetsSection from './components/AssetsSection.vue'
import MarketClosedMessage from './components/MarketClosedMessage.vue'
import CurrentPrice from './components/CurrentPrice.vue'
import BannerSlider from './components/BannerSlider.vue'
import PriceChart from './components/PriceChart.vue'
import PriceStats from './components/PriceStats.vue'
import AppHeader from './components/AppHeader.vue'
import LogoutConfirmDialog from './components/LogoutConfirmDialog.vue'
import BalanceSheet from './components/BalanceSheet.vue'
import ShareModal from './components/ShareModal.vue'
import HomePageShell from './shell/HomePageShell.vue'
import { useHomePage } from './composables/useHomePage'
// Composables
import {useBanners} from './composables/useBanners'
import {usePriceChart} from './composables/usePriceChart'
import {useUserFunds} from './composables/useUserFunds'
import {getReferralShareText, getReferralShareTitle} from '@/utils/config'
// Stores
const userStore = useUserStore()
const walletStore = useWalletStore()
const configStore = useConfigStore()
const fundStore = useFundUserStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
// Config
const isGaroosi = computed(() => configStore.config.name === 'garoosi')
const closedMessage = computed(() => userStore.getValueByKey('closed_message'))
const marketClosedText = computed(() => {
if (!isStoreClosed()) return ''
return closedMessage.value || getStoreClosedMessage()
})
const showClosedMessage = computed(() => !!marketClosedText.value)
// State
const showLogoutConfirm = ref(false)
const wholeBalanceVisible = ref(false)
const showTextBanner = ref(false)
const showShare = ref(false)
const TextBanner = ref('')
const odometerKey = ref(0)
const {x, y} = useWindowScroll()
// Composables
const {banners, loadBanners} = useBanners()
const {updateChartData, priceChartSeries, fetchPrices} = usePriceChart()
const {funds, loadFunds} = useUserFunds()
// Computed
const limitedPrice = computed(() => walletStore.limitedPrice?.[0])
const goldVal = computed(() => {
return lastPrice.value?.price_last ?? 1
})
// Actions
const mainActions = computed(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({name: 'buyProduct'})
},
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: isGaroosi.value ? 'واریز و برداشت' : 'واریز نقدی',
handler: () => router.push({name: 'wallet', query: {state: 'deposit'}})
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => wholeBalanceVisible.value = true
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: isGaroosi.value ? 'دعوت از دوستان' : 'دارایی ها',
handler: () => isGaroosi.value ? shareCode() : router.push({name: 'fund'})
}
])
// Methods
async function shareCode() {
const text = getReferralShareText({
userName: userStore.userInfo?.name,
projectName: configStore.config.name,
referralId: userStore.userInfo?.referral_id,
})
try {
await navigator.share({
title: getReferralShareTitle(configStore.config.name),
text,
})
} catch {
copyCode()
}
}
function copyCode() {
const code = userStore.userInfo?.referral_id
if (code) {
navigator.clipboard.writeText(code)
message.info('کد رونوشت شد')
}
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
useWalletAssets().clearWalletAssets()
removeToken()
await router.push({name: 'login'})
window.location.reload()
}
function openGoldModal(metal: WalletMetalTotal) {
Modal.confirm({
title: formatMetalAssetsInfoText(metal),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: {style: {display: 'none'}}
})
}
function openRialModal() {
const summary = assetsSummary.value
const cash = summary?.cash_wallet ?? 0
const metalRial = resolveAssetsRialValue(summary, getTradeBuyPrice(lastPrice.value))
Modal.confirm({
title: 'دارایی ریالی',
content: formatRialAssetsInfoText(cash, metalRial),
okText: 'متوجه شدم',
centered: true,
cancelButtonProps: {style: {display: 'none'}}
})
}
function handleBannerClick(banner) {
const handlers = {
share: () => showShare.value = true,
modal: () => {
if (banner.modalText) {
TextBanner.value = banner.modalText
showTextBanner.value = true
}
},
link: () => {
if (banner.link) {
if (banner.link.startsWith('http')) {
window.open(banner.link, '_blank')
} else {
router.push(banner.link)
}
}
}
}
handlers[banner.typeClick]?.()
}
// Lifecycle
onMounted(async () => {
try {
loading.value = true
await Promise.all([
loadFunds(),
loadWalletAssets(),
getLastPrices(),
fetchPrices(),
startLastPricePolling(),
loadBanners()
])
// شروع آپدیت قیمت
const updateInterval = setInterval(() => {
odometerKey.value++
}, 1000)
onUnmounted(() => {
clearInterval(updateInterval)
stopLastPricePolling()
})
} catch (error) {
console.error('Initialization error:', error)
} finally {
loading.value = false
}
})
const {
userStore,
walletStore,
showLogoutConfirm,
wholeBalanceVisible,
showTextBanner,
showShare,
TextBanner,
goldVal,
logout,
} = useHomePage()
</script>
<style scoped>
.home-page-root {
min-height: 100%;
height: 100%;
}
</style>
<style lang="less">
.odometer {
font-size: 60px;
@@ -317,4 +72,4 @@ label:focus-within {
background: #fff;
}
}
</style>
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/userStore'
import { loading } from '@/utils'
import HomeSkeleton from '../components/HomeSkeleton.vue'
import { getHomeLayoutConfig } from './registry'
import { HOME_SECTION_COMPONENTS } from './sections'
import type { HomeLayoutConfig, HomeSectionId } from './types'
const props = defineProps<{
layoutConfig?: HomeLayoutConfig
}>()
const userStore = useUserStore()
const layoutConfig = computed(() => props.layoutConfig ?? getHomeLayoutConfig())
const outerSections = computed(() =>
(layoutConfig.value.outerSections ?? []).filter((section) => section.visible !== false)
)
const innerSections = computed(() =>
layoutConfig.value.sections.filter((section) => section.visible !== false)
)
function resolveSectionComponent(sectionId: HomeSectionId) {
return HOME_SECTION_COMPONENTS[sectionId]
}
</script>
<template>
<div v-if="userStore.userInfo && !loading">
<component
:is="resolveSectionComponent(section.id)"
v-for="section in outerSections"
:key="`outer-${section.id}`"
/>
<div v-if="layoutConfig.wrapperClass" :class="layoutConfig.wrapperClass">
<component
:is="resolveSectionComponent(section.id)"
v-for="section in innerSections"
:key="section.id"
/>
</div>
<template v-else>
<component
:is="resolveSectionComponent(section.id)"
v-for="section in innerSections"
:key="section.id"
/>
</template>
</div>
<HomeSkeleton v-if="loading" class="w-full" />
</template>
@@ -0,0 +1,21 @@
import type { HomeLayoutConfig } from '../types'
/**
* چیدمان اختصاصی boursecu — فقط وقتی home_layout=boursecu یا پروژه boursecu باشد.
* شامل: دارایی بدون بک‌گراند، گرید ۶ باکس، نمودار کاتد/مفتول، و ...
*/
export const boursecuHomeLayoutConfig: HomeLayoutConfig = {
id: 'boursecu',
wrapperClass: 'rounded-lg mt-5',
outerSections: [
{ id: 'assets' },
{ id: 'boursecuQuickActions' },
{ id: 'limitedPriceAlert' },
],
sections: [
{ id: 'marketClosed' },
{ id: 'currentPrice' },
{ id: 'priceChart' },
{ id: 'priceStats' },
],
}
@@ -0,0 +1,40 @@
import type { HomeQuickActionItem } from '../types'
export const boursecuQuickActions: HomeQuickActionItem[] = [
{
id: 'buySell',
label: 'خرید و فروش',
icon: 'vuesax-linear:money-receive',
route: { name: 'buyProduct' },
},
{
id: 'wallet',
label: 'واریز و برداشت',
icon: 'vuesax-linear:wallet-1',
route: { name: 'wallet' },
},
{
id: 'assets',
label: 'دارایی ها',
icon: 'vuesax-linear:cup',
route: { name: 'fund' },
},
{
id: 'transactions',
label: 'تراکنش ها',
icon: 'vuesax-linear:receipt-item',
route: { name: 'transactions' },
},
{
id: 'physicalReceive',
label: 'تحویل فیزیکی',
icon: 'vuesax-linear:direct-inbox',
route: { name: 'physicalReceive' },
},
{
id: 'convert',
label: 'تبدیل',
icon: 'vuesax-linear:refresh-circle',
route: { name: 'buyProduct' },
},
]
@@ -0,0 +1,19 @@
import type { HomeLayoutConfig } from '../types'
/** چیدمان پیش‌فرض — همه پروژه‌ها به‌جز boursecu */
export const defaultHomeLayoutConfig: HomeLayoutConfig = {
id: 'default',
wrapperClass: 'p-1 from-primary rounded-lg p-px to-transparent mt-5',
outerSections: [{ id: 'limitedPriceAlert' }],
sections: [
{ id: 'garoosiAssets' },
{ id: 'mainActions' },
{ id: 'referral' },
{ id: 'assets' },
{ id: 'marketClosed' },
{ id: 'currentPrice' },
{ id: 'banners' },
{ id: 'priceChart' },
{ id: 'priceStats' },
],
}
+47
View File
@@ -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
}
+41
View File
@@ -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>