Port modular app notifications, pargar date display, and closed-store product UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 16:29:33 +02:00
co-authored by Cursor
parent 4fd12277e7
commit e6dea15752
33 changed files with 1253 additions and 47 deletions
+160
View File
@@ -0,0 +1,160 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue'
import { getAppNotifications, getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore'
import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification'
const unreadCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
let started = false
let lastStreamNotifId = 0
let hydratedUnread = false
let lastShownNotifId = 0
async function refreshUnreadCount() {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
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
hydratedUnread = true
} catch {
// silent — badge optional
}
}
function clearUnreadLocally() {
unreadCount.value = 0
}
function showInAppToast(title: string, body: string) {
try {
notification.open({
message: title || 'اعلان جدید',
description: body || undefined,
placement: 'topRight',
duration: 5,
class: 'app-notif-toast',
onClick: () => {
void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
})
},
})
} catch {
// ignore
}
}
async function presentNotification(payload: {
id?: number
title?: string
body?: string
}) {
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastShownNotifId) {
return
}
if (id > 0) {
lastShownNotifId = id
}
const title = String(payload.title ?? '').trim() || 'اعلان جدید'
const body = String(payload.body ?? '').trim()
showInAppToast(title, body)
await showBrowserNotification({
title,
body,
tag: id > 0 ? `app-notification-${id}` : undefined,
url: '/appNotifications',
})
}
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 */
function bumpUnreadFromStream(payload: Record<string, unknown>) {
const settingsStore = useSettingsStore()
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
return
}
const id = Number(payload.id ?? 0)
if (id > 0 && id === lastStreamNotifId) {
return
}
if (id > 0) {
lastStreamNotifId = id
}
unreadCount.value = Math.max(0, unreadCount.value) + 1
void presentNotification({
id,
title: String(payload.title ?? ''),
body: String(payload.body ?? ''),
})
void refreshUnreadCount()
}
export function useAppNotificationBadge() {
const settingsStore = useSettingsStore()
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => {
if (started) {
void refreshUnreadCount()
return
}
started = true
void refreshUnreadCount()
// هر ۲۰ثانیه — اگر SSE قطع بود هم نوتیف دیرتر می‌آید
pollTimer = setInterval(() => {
void refreshUnreadCount()
}, 20000)
})
onUnmounted(() => {
// keep poll for other mounted headers
})
watch(enabled, (on) => {
if (!on) unreadCount.value = 0
else void refreshUnreadCount()
})
return {
enabled,
unreadCount,
refreshUnreadCount,
clearUnreadLocally,
}
}
export { refreshUnreadCount, clearUnreadLocally, bumpUnreadFromStream }