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
+150
View File
@@ -0,0 +1,150 @@
/** نوتیف مرورگر بدون Firebase — فقط با کلیک کاربر (gesture) قابل درخواست است. */
export type BrowserNotifState =
| 'unsupported'
| 'needs-pwa'
| 'default'
| 'granted'
| 'denied'
function isStandalonePwa() {
if (typeof window === 'undefined') return false
return (
window.matchMedia('(display-mode: standalone)').matches ||
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
)
}
/** فقط دستگاه‌های واقعی اپل موبایل — نه مک با ترک‌پد */
function isAppleMobileDevice() {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
if (/iPhone|iPod/i.test(ua)) return true
if (/iPad/i.test(ua)) return true
const isTouchMac = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
if (!isTouchMac) return false
if (/CriOS|FxiOS|EdgiOS/i.test(ua)) return true
if (/Chrome|Edg|Firefox|OPR/i.test(ua) && !/Mobile/i.test(ua)) return false
return true
}
export function getBrowserNotificationState(): BrowserNotifState {
if (typeof window === 'undefined') return 'unsupported'
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) {
return 'unsupported'
}
const hasApi = 'Notification' in window
if (isAppleMobileDevice() && !isStandalonePwa() && !hasApi) {
return 'needs-pwa'
}
if (!hasApi) {
return 'unsupported'
}
const permission = Notification.permission
if (permission === 'granted') return 'granted'
if (permission === 'denied') return 'denied'
return 'default'
}
export function canRequestBrowserNotification() {
return getBrowserNotificationState() === 'default'
}
export async function requestBrowserNotificationPermission(): Promise<BrowserNotifState> {
const before = getBrowserNotificationState()
if (before === 'granted' || before === 'denied' || before === 'unsupported' || before === 'needs-pwa') {
return before
}
if (!('Notification' in window)) {
return getBrowserNotificationState()
}
try {
const result = await Notification.requestPermission()
if (result === 'granted') return 'granted'
if (result === 'denied') return 'denied'
return 'default'
} catch {
return getBrowserNotificationState()
}
}
function resolveNotifIcon() {
try {
return new URL('/favicon.ico', window.location.origin).href
} catch {
return undefined
}
}
export type ShowBrowserNotificationOptions = {
title: string
body?: string
tag?: string
url?: string
}
/** نمایش نوتیف سیستم؛ ترجیح با Service Worker (معتبرتر روی Chrome/Android). */
export async function showBrowserNotification(options: ShowBrowserNotificationOptions): Promise<boolean> {
if (getBrowserNotificationState() !== 'granted') {
return false
}
const title = options.title || 'اعلان جدید'
const body = options.body || ''
const tag = options.tag || `app-notification-${Date.now()}`
const url = options.url || '/appNotifications'
const icon = resolveNotifIcon()
// ۱) Service Worker — روی Chrome/Android پایدارتر است
try {
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.ready
if (reg?.showNotification) {
await reg.showNotification(title, {
body,
tag,
icon,
badge: icon,
data: { url },
renotify: true,
})
return true
}
}
} catch (error) {
console.warn('SW showNotification failed, falling back', error)
}
// ۲) Notification constructor
try {
const n = new Notification(title, {
body,
tag,
icon,
dir: 'rtl',
lang: 'fa',
})
n.onclick = () => {
window.focus()
n.close()
if (url) {
void import('@/router').then((mod) => {
mod.default.push(url).catch(() => undefined)
})
}
}
return true
} catch (error) {
console.warn('Notification constructor failed', error)
return false
}
}
+26
View File
@@ -375,6 +375,24 @@ function handleAssetsPayload(payload: unknown) {
void applyAssetsFromStream(payload)
}
function handleAppNotificationPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyAppNotificationFromStream(payload as Record<string, unknown>)
}
async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
bumpUnreadFromStream(payload)
} catch (error) {
console.warn('App notification SSE apply failed', error)
}
}
function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => {
try {
@@ -400,6 +418,14 @@ function bindStreamEvents(source: EventSource) {
}
})
source.addEventListener('app_notification', (event) => {
try {
handleAppNotificationPayload(JSON.parse(event.data))
} catch (error) {
console.warn('App notification SSE parse failed', error)
}
})
source.addEventListener('ping', () => {
touchStreamActivity()
})
+59
View File
@@ -0,0 +1,59 @@
/**
* روی بعضی buildهای iOS PWA، env(safe-area-inset-*) داخل CSS var صفر می‌ماند
* در حالی که محتوا واقعاً زیر نوار وضعیت رسم می‌شود. این تابع مقدار واقعی را
* با یک probe می‌خواند و روی :root می‌نشاند.
*/
export function syncSafeAreaInsets() {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return
}
const probe = document.createElement('div')
probe.setAttribute('aria-hidden', 'true')
probe.style.cssText = [
'position:fixed',
'top:0',
'left:0',
'width:0',
'height:0',
'padding:env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
'visibility:hidden',
'pointer-events:none',
'z-index:-1',
].join(';')
document.documentElement.appendChild(probe)
const style = getComputedStyle(probe)
const top = style.paddingTop || '0px'
const right = style.paddingRight || '0px'
const bottom = style.paddingBottom || '0px'
const left = style.paddingLeft || '0px'
probe.remove()
const root = document.documentElement
root.style.setProperty('--app-safe-top', top)
root.style.setProperty('--app-safe-bottom', bottom)
root.style.setProperty('--app-safe-right', right)
root.style.setProperty('--app-safe-left', left)
// اگر env صفر بود ولی PWA standalone هستیم، حداقل فضای نوار وضعیت را جبران کن
const topPx = Number.parseFloat(top) || 0
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
// iOS Safari legacy
Boolean((window.navigator as Navigator & { standalone?: boolean }).standalone)
if (isStandalone && topPx < 1) {
// Dynamic Island ≈ ۵۹px؛ بدون env حداقل این را جبران می‌کنیم
root.style.setProperty('--app-safe-top', '59px')
}
}
export function initSafeAreaInsets() {
syncSafeAreaInsets()
window.addEventListener('orientationchange', () => {
window.setTimeout(syncSafeAreaInsets, 100)
})
window.visualViewport?.addEventListener('resize', syncSafeAreaInsets)
}
+15
View File
@@ -197,20 +197,35 @@ export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
const settings = useSettingsStore()
return settings.isSettingTruthy('show_home_price_when_closed')
}
/**
* وضعیت نمایش قیمت:
* - closed → فروشگاه بسته است (ساعت کاری، سیگنال سرور، یا نبود مظنه)
* - open → نمایش عادی قیمت
*
* اگر show_home_price_when_closed روشن باشد و قیمت موجود باشد، به‌جای closed قیمت نشان داده می‌شود.
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
const p = lastPrice.value
const showWhenClosed = shouldShowHomePriceWhenClosed()
if (p?.status === 'closed' || p?.closed === true) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
}
if (isStoreClosed()) {
if (showWhenClosed && hasLivePriceQuote()) {
return 'open'
}
return 'closed'
}