Port modular features into boursecu and fix desktop login UX.

Add support chat, piggy funds, PWA banner, notification/SSE sync, and messenger legacy guards while keeping boursecu theme; also balance login panels and unblock login navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 20:14:46 +02:00
co-authored by Cursor
parent be896fe247
commit 42aef88064
55 changed files with 7488 additions and 434 deletions
@@ -33,6 +33,20 @@
</span>
</span>
</button>
<button
v-if="chatEnabled"
type="button"
class="mhome-header-btn mhome-header-btn--notify"
aria-label="چت پشتیبانی"
@click.stop="goSupportChat"
>
<span class="mhome-header-btn__icon-wrap">
<Icon icon="vuesax-linear:messages-2" size="18" />
<span v-if="chatUnreadCount > 0" class="mhome-header-btn__badge">
{{ chatUnreadCount > 99 ? '99+' : chatUnreadCount }}
</span>
</span>
</button>
<button
type="button"
class="mhome-header-btn"
@@ -67,6 +81,7 @@ import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
const headerRef = ref<HTMLElement | null>(null)
const isHeaderScrolled = ref(false)
@@ -74,6 +89,8 @@ const canNativeInstall = ref(false)
const route = useRoute()
const projectLogo = computed(() => getProjectMobileNavLogo())
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const {
enabled: notificationEnabled,
unreadCount,
@@ -98,6 +115,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' })
}
function goSupportChat() {
void router.push({ name: 'support' })
}
let intersectionObserver: IntersectionObserver | null = null
let scrollTarget: HTMLElement | Window | null = null
+31 -8
View File
@@ -39,10 +39,10 @@
<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 ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<div id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
:key="chartRenderKey"
type="area"
width="100%"
:height="chartHeight"
@@ -66,7 +66,6 @@ import {
shouldUseKatodVisualChartTransform,
} from '@/utils/chartPriceAxis'
import { useSettingsStore } from '@/store/settingsStore'
import { useElementSize } from '@vueuse/core'
import { price } from '@/utils/price'
import type { ChartMetalType } from '../composables/usePriceChart'
@@ -117,12 +116,10 @@ const emit = defineEmits<{
const config = useConfigStore()
const settingsStore = useSettingsStore()
const { selectedMetalChartRatio } = usePriceChart()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const renderNonce = ref(0)
const selectedValue = ref(props.chartSelection)
const { width: chartWidth } = useElementSize(chartContainerRef)
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const amplifyKatodChart = computed(() =>
shouldAmplifyKatodMesChart({
@@ -175,20 +172,46 @@ const chartYBounds = computed(() => {
return computeKatodMesVisualYBounds(prices)
})
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
/** Do not gate on measured width — useElementSize can stay 0 until a reflow (e.g. tab click). */
const canRenderChart = computed(() => chartMounted.value && seriesLength.value > 0)
const chartRenderKey = computed(
() =>
`${props.layout}-${props.variant}-${props.selectedMetal}-${props.chartSelection}-${seriesLength.value}-${renderNonce.value}`
)
function scheduleChartRemount() {
void nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
renderNonce.value += 1
})
})
})
}
watch(
() => props.chartSelection,
(timeline) => {
selectedValue.value = timeline
if (seriesLength.value > 0) {
scheduleChartRemount()
}
}
)
// Data often arrives before/while first layout; Apex can mount blank until remount.
watch(seriesLength, (len, prev) => {
if (len > 0 && (prev ?? 0) === 0) {
scheduleChartRemount()
}
})
onMounted(async () => {
await nextTick()
chartMounted.value = true
if (seriesLength.value > 0) {
scheduleChartRemount()
}
})
const chartOptions = computed(() => {
+40 -27
View File
@@ -81,34 +81,47 @@ export function useHomePage() {
const showClosedMessage = computed(() => !!marketClosedText.value)
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: 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
const mainActions = computed<HomeActionItem[]>(() => {
const actions: HomeActionItem[] = [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({ name: 'buyProduct' }),
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: 'دارایی ها',
handler: () => router.push({ name: 'fund' }),
},
])
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: 'دارایی ها',
handler: () => router.push({ name: 'fund' }),
},
]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
actions.push({
name: 'piggy',
icon: 'vuesax-linear:safe-home',
label: 'قلک',
handler: () => router.push({ name: 'piggy' }),
})
}
return actions
})
function copyCode() {
const code = userStore.userInfo?.referral_id
@@ -4,8 +4,10 @@ import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProd
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
export type ChartTimeline = 'DAILY' | 'WEEKLY' | 'MONTHLY'
type ChartPoint = [number, number]
type PricesMap = Record<string, Array<{ timestamp: number; value: number }>>
/**
* مارک‌آپ ثابت روی قیمت بورس کالا برای نمودار:
@@ -22,7 +24,8 @@ const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const cachedPrices = ref<PricesMap | null>(null)
const chartSelection = ref<ChartTimeline>('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const catalogProducts = getProductsCatalogRef()
@@ -67,7 +70,7 @@ 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 {
function resolvePricesMap(response: unknown): PricesMap | null {
if (!response || typeof response !== 'object') {
return null
}
@@ -76,11 +79,11 @@ function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp:
const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices
return (nested as { prices: PricesMap }).prices
}
if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
return body.prices as PricesMap
}
return null
@@ -95,9 +98,21 @@ function buildChartPoints(items: Array<{ timestamp: number; value: number }>): C
function syncChartSeries() {
const ratio = selectedMetalChartRatio.value
priceChartSeries.value[0].data = rawChartPoints.value.map(
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
)
// New array so Vue invalidates parent computeds (nested .data mutation did not).
priceChartSeries.value = [
{
name: 'قیمت',
data: rawChartPoints.value.map(
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
),
},
]
}
function applyTimelineFromCache(timeline: ChartTimeline = chartSelection.value) {
const selectedData = cachedPrices.value?.[timeline] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
}
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
@@ -105,8 +120,8 @@ watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
if (!force && chartInitialized && cachedPrices.value) {
applyTimelineFromCache()
return
}
@@ -119,10 +134,9 @@ async function fetchPrices(force = false) {
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
cachedPrices.value = data
applyTimelineFromCache()
chartInitialized = Boolean(data)
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
@@ -160,7 +174,11 @@ async function loadMetalChartOffsets(force = false) {
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
chartSelection.value = timeline as ChartTimeline
if (cachedPrices.value) {
applyTimelineFromCache(chartSelection.value)
return
}
void fetchPrices(true)
}
@@ -197,6 +215,7 @@ export function loadHomeMaftolOffset(force = false) {
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
cachedPrices.value = null
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
chartInitialized = false
@@ -7,7 +7,11 @@ import DesktopHomeCard from '../components/DesktopHomeCard.vue'
const ctx = useHomePageContext()
const chartLayout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
@@ -16,7 +16,11 @@ const props = withDefaults(
const ctx = useHomePageContext()
const chartLayout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
@@ -18,7 +18,7 @@
@click="onInstall"
>
<BasicIcon icon="vuesax-linear:mobile" size="16" />
<span>{{ canNativeInstall ? 'نصب وب اپ' : 'آموزش نصب' }}</span>
<span>{{ canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب' }}</span>
</button>
<button
v-if="notificationEnabled"
@@ -35,6 +35,21 @@
</span>
<span>اعلان</span>
</button>
<button
v-if="chatEnabled"
type="button"
class="dhome-btn-ghost dhome-btn-ghost--notify"
aria-label="چت پشتیبانی"
@click="goSupportChat"
>
<span class="dhome-notify-wrap">
<BasicIcon icon="vuesax-linear:messages-2" size="16" />
<span v-if="chatUnreadCount > 0" class="dhome-notify-badge">
{{ chatUnreadCount > 99 ? '99+' : chatUnreadCount }}
</span>
</span>
<span>چت</span>
</button>
<button type="button" class="dhome-btn-ghost" aria-label="بروزرسانی" @click="reloadApp">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
</button>
@@ -52,7 +67,7 @@
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
@@ -63,6 +78,7 @@ import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
import router from '@/router'
withDefaults(
@@ -78,6 +94,8 @@ defineEmits<{
const userStore = useUserStore()
const canNativeInstall = ref(false)
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const {
enabled: notificationEnabled,
unreadCount,
@@ -102,6 +120,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' })
}
function goSupportChat() {
void router.push({ name: 'support' })
}
function reloadApp() {
window.location.reload()
}
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
@@ -10,10 +13,28 @@ const props = withDefaults(
actions?: HomeQuickActionItem[]
}>(),
{
actions: () => boursecuQuickActions,
actions: undefined,
},
)
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
}
@@ -23,7 +44,7 @@ function handleClick(action: HomeQuickActionItem) {
<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"
v-for="action in resolvedActions"
:key="action.id"
:action="action"
class="home-desktop-quick-action"
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
@@ -11,11 +14,29 @@ const props = withDefaults(
title?: string
}>(),
{
actions: () => boursecuQuickActions,
actions: undefined,
title: 'میانبرها',
},
)
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function navigate(action: HomeQuickActionItem) {
router.push(action.route)
}
@@ -25,7 +46,7 @@ function navigate(action: HomeQuickActionItem) {
<DesktopHomeSection :title="title">
<nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی">
<button
v-for="action in props.actions"
v-for="action in resolvedActions"
:key="action.id"
type="button"
class="dhome-action"
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import { boursecuQuickActions } from '../configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../types'
@@ -9,10 +12,28 @@ const props = withDefaults(
actions?: HomeQuickActionItem[]
}>(),
{
actions: () => boursecuQuickActions,
actions: undefined,
}
)
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) {
router.push(action.route)
}
@@ -21,7 +42,7 @@ function handleClick(action: HomeQuickActionItem) {
<template>
<div class="grid grid-cols-3 gap-3 mt-4">
<HomeQuickActionBox
v-for="action in props.actions"
v-for="action in resolvedActions"
:key="action.id"
:action="action"
@click="handleClick(action)"
@@ -5,7 +5,12 @@ import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const layout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries))
const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
// Track length so chart updates even if the array reference is reused.
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>