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
+106 -46
View File
@@ -1,35 +1,65 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue'
import { getAppNotifications, getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification'
import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState'
import { APP_Z_TOAST } from '@/utils/zIndex'
const unreadCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
let started = false
let lastStreamNotifId = 0
let hydratedUnread = false
let lastShownNotifId = 0
let toastConfigured = false
let hydrateFallbackTimer: ReturnType<typeof setTimeout> | null = null
function ensureNotifToastConfig() {
if (toastConfigured) return
toastConfigured = true
notification.config({
placement: 'topRight',
top: 'calc(var(--app-home-header-offset, 4.25rem) + 0.5rem)',
duration: 5,
})
}
function notificationsEnabled(): boolean {
return Boolean(getToken()) && useSettingsStore().isSettingTruthy('has_page_notification')
}
function clearHydrateFallback() {
if (hydrateFallbackTimer) {
clearTimeout(hydrateFallbackTimer)
hydrateFallbackTimer = null
}
}
/** از SSE رویداد unread-count — بدون HTTP */
function applyUnreadCountFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
const next = Number(payload.unread_count ?? 0) || 0
unreadCount.value = Math.max(0, next)
hydratedUnread = true
clearHydrateFallback()
}
/** فقط وقتی SSE در دسترس نیست یا برای ریفرش دستی صفحه اعلان‌ها */
async function refreshUnreadCount() {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
try {
const data = await getAppNotificationsUnreadCount()
const next = Number(data?.unread_count ?? 0) || 0
const prev = unreadCount.value
// اگر SSE نرسید ولی تعداد خوانده‌نشده زیاد شد → نوتیف بساز
if (hydratedUnread && next > prev) {
void notifyFromLatestUnread()
}
unreadCount.value = next
unreadCount.value = Number(data?.unread_count ?? 0) || 0
hydratedUnread = true
} catch {
// silent — badge optional
@@ -42,12 +72,14 @@ function clearUnreadLocally() {
function showInAppToast(title: string, body: string) {
try {
ensureNotifToastConfig()
notification.open({
message: title || 'اعلان جدید',
description: body || undefined,
placement: 'topRight',
duration: 5,
class: 'app-notif-toast',
style: { zIndex: APP_Z_TOAST },
onClick: () => {
void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
@@ -84,25 +116,9 @@ async function presentNotification(payload: {
})
}
async function notifyFromLatestUnread() {
try {
const rows = await getAppNotifications()
const latest = (Array.isArray(rows) ? rows : []).find((row) => !row.is_read)
if (!latest) return
await presentNotification({
id: latest.id,
title: latest.title,
body: latest.body,
})
} catch {
// ignore
}
}
/** از SSE زنده — بدون Firebase / subscription */
/** از SSE رویداد app_notification — بدون polling HTTP */
function bumpUnreadFromStream(payload: Record<string, unknown>) {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
if (!notificationsEnabled()) {
return
}
@@ -114,13 +130,50 @@ function bumpUnreadFromStream(payload: Record<string, unknown>) {
lastStreamNotifId = id
}
unreadCount.value = Math.max(0, unreadCount.value) + 1
if (payload.unread_count !== undefined && payload.unread_count !== null) {
applyUnreadCountFromStream(payload)
} else {
unreadCount.value = Math.max(0, unreadCount.value) + 1
hydratedUnread = true
clearHydrateFallback()
}
void presentNotification({
id,
title: String(payload.title ?? ''),
body: String(payload.body ?? ''),
})
void refreshUnreadCount()
}
/**
* بدون interval: اگر استریم قیمت روشن است منتظر رویداد SSE می‌مانیم؛
* فقط وقتی wss خاموش است یا SSE تا ۱۲ثانیه نیامد، یک‌بار HTTP می‌زنیم.
*/
function scheduleHydrate() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
return
}
if (!priceWssEnabled.value) {
void refreshUnreadCount()
return
}
if (priceSocketConnected.value || hydratedUnread) {
clearHydrateFallback()
return
}
clearHydrateFallback()
hydrateFallbackTimer = setTimeout(() => {
hydrateFallbackTimer = null
if (!hydratedUnread && notificationsEnabled() && !priceSocketConnected.value) {
void refreshUnreadCount()
}
}, 12000)
}
export function useAppNotificationBadge() {
@@ -128,25 +181,27 @@ export function useAppNotificationBadge() {
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => {
if (started) {
void refreshUnreadCount()
return
}
started = true
void refreshUnreadCount()
// هر ۲۰ثانیه — اگر SSE قطع بود هم نوتیف دیرتر می‌آید
pollTimer = setInterval(() => {
void refreshUnreadCount()
}, 20000)
scheduleHydrate()
})
onUnmounted(() => {
// keep poll for other mounted headers
// module-level badge — keep state for other headers
})
watch(enabled, (on) => {
if (!on) unreadCount.value = 0
else void refreshUnreadCount()
if (!on) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
} else {
scheduleHydrate()
}
})
watch([priceSocketConnected, priceWssEnabled], () => {
if (!started) return
scheduleHydrate()
})
return {
@@ -157,4 +212,9 @@ export function useAppNotificationBadge() {
}
}
export { refreshUnreadCount, clearUnreadLocally, bumpUnreadFromStream }
export {
refreshUnreadCount,
clearUnreadLocally,
bumpUnreadFromStream,
applyUnreadCountFromStream,
}
+60 -19
View File
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useConfigStore } from '@/store/configStore'
import {
getProjectBaleContactLink,
@@ -13,7 +14,6 @@ import {
loadSupportMessengerFromSettings,
messengerNeedsClipboardCopy,
openSupportMessengerEntry,
openSupportMessengerWithMessage,
resolveDepositGuideText,
resolveDepositSupportMessage,
resolveSupportMessengerEntries,
@@ -33,16 +33,29 @@ import {
hasDepositFieldValue,
} from '@/utils/depositGuideText'
import { useSettingsStore } from '@/store/settingsStore'
import { isUserSupportChatEnabled } from '@/utils/chat'
/** plain: منوی شناور / درباره ما — بدون پیش‌متن | deposit: مودال واریز — با deposit_support_message_text */
type SupportPickerIntent = 'plain' | 'deposit'
const CHAT_PICKER_ENTRY: ResolvedSupportMessengerEntry = {
messenger: 'chat',
id: 'support-chat',
label: 'چت پشتیبانی',
icon: 'vuesax-linear:messages-2',
accent: '#16a34a',
url: '',
}
export function useSupportMessenger() {
const config = useConfigStore()
const settingsStore = useSettingsStore()
const router = useRouter()
const supportPickerOpen = supportMessengerPickerOpen
const supportPickerIntent = ref<SupportPickerIntent>('plain')
const isChatEnabled = computed(() => isUserSupportChatEnabled())
const legacySupportLink = computed(() => {
if (config.config?.name === 'garoosi' || isBazretalaProject()) {
return getProjectBaleContactLink()
@@ -52,26 +65,40 @@ export function useSupportMessenger() {
const depositSupportMessage = computed(() => resolveDepositSupportMessage())
/** لینک‌های تمیز بدون پیش‌متن — متن فقط موقع باز کردن (قصد deposit) اضافه می‌شود */
const supportMessengerEntries = computed(() =>
resolveSupportMessengerEntries(supportMessengerSettings.value),
)
const pickerEntries = computed(() => {
const list = [...supportMessengerEntries.value]
if (isChatEnabled.value) {
list.unshift(CHAT_PICKER_ENTRY)
}
return list
})
const primaryMessengerEntry = computed(() => supportMessengerEntries.value[0] ?? null)
const messengerType = computed(
() => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(),
)
const messengerMeta = computed(() =>
primaryMessengerEntry.value
const messengerMeta = computed(() => {
if (isChatEnabled.value && supportMessengerEntries.value.length === 0) {
return {
label: CHAT_PICKER_ENTRY.label,
icon: CHAT_PICKER_ENTRY.icon,
accent: CHAT_PICKER_ENTRY.accent,
}
}
return primaryMessengerEntry.value
? {
label: primaryMessengerEntry.value.label,
icon: primaryMessengerEntry.value.icon,
accent: primaryMessengerEntry.value.accent,
}
: resolveSupportMessengerMeta(messengerType.value),
)
: resolveSupportMessengerMeta(messengerType.value)
})
const messengerLink = computed(() =>
primaryMessengerEntry.value?.url
@@ -82,8 +109,16 @@ export function useSupportMessenger() {
),
)
function goToSupportChat() {
void router.push({ name: 'support' })
return true
}
async function openMessengerEntry(entry: ResolvedSupportMessengerEntry, text?: string) {
// فقط وقتی از مودال واریز آمده‌ایم پیش‌نویس می‌گذاریم؛ منوی شناور همیشه خالی
if (entry.messenger === 'chat') {
return goToSupportChat()
}
const messageText =
text !== undefined
? text.trim()
@@ -112,21 +147,25 @@ export function useSupportMessenger() {
const draft =
intent === 'deposit' ? depositSupportMessage.value.trim() : ''
const entries = supportMessengerEntries.value
const messengerEntries = supportMessengerEntries.value
const entries = pickerEntries.value
// لیست خالی + چت خاموش → هیچ‌کار
if (entries.length === 0) {
return openSupportMessengerWithMessage(
supportMessengerSettings.value.messenger,
supportMessengerSettings.value.messengerId,
draft,
legacySupportLink.value,
)
return false
}
// فقط چت (لیست پیام‌رسان خالی) → مستقیم چت
if (isChatEnabled.value && messengerEntries.length === 0) {
return goToSupportChat()
}
// فقط یک گزینه (چت یا یک پیام‌رسان) → مستقیم همان
if (entries.length === 1) {
return openMessengerEntry(entries[0], draft)
}
// چند گزینه (چت + لینک‌ها یا چند پیام‌رسان) → منوی انتخاب
const now = Date.now()
if (supportPickerOpen.value) {
if (now - supportPickerOpenedAt < 450) return true
@@ -139,12 +178,10 @@ export function useSupportMessenger() {
return true
}
/** منوی شناور / صفحات درباره ما — بدون پیش‌نویس پیام */
async function openSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('plain', anchorEl)
}
/** مودال راهنمای واریز — با متن deposit_support_message_text */
async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('deposit', anchorEl)
}
@@ -225,8 +262,10 @@ export function useSupportMessenger() {
)
}
const hasMessengerLink = computed(() => supportMessengerEntries.value.length > 0 || !!messengerLink.value)
const hasMultipleMessengers = computed(() => supportMessengerEntries.value.length > 1)
const hasMessengerLink = computed(
() => isChatEnabled.value || supportMessengerEntries.value.length > 0,
)
const hasMultipleMessengers = computed(() => pickerEntries.value.length > 1)
const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value)
const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value)
const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value))
@@ -234,7 +273,8 @@ export function useSupportMessenger() {
return {
supportPickerOpen,
supportPickerAnchor: supportMessengerPickerAnchor,
supportMessengerEntries,
supportMessengerEntries: pickerEntries,
isChatEnabled,
messengerType,
messengerMeta,
messengerLink,
@@ -256,6 +296,7 @@ export function useSupportMessenger() {
openDepositSupportMessenger,
openMessengerEntry,
handleSupportMessengerClick,
goToSupportChat,
hasMessengerLink,
hasMultipleMessengers,
hasSupportPhoneLink,