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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user