diff --git a/index.html b/index.html index f9c5aa7..3183b63 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,8 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> + + در حال بارگذاری... diff --git a/src/App.vue b/src/App.vue index edb6fb0..b933cf4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -209,6 +209,9 @@ watch( return } resetAppSurfaceBackground() + void import('@/utils/safeAreaInsets').then(({ syncSafeAreaInsets }) => { + syncSafeAreaInsets() + }) }, { immediate: true }, ) @@ -230,7 +233,12 @@ watch( /* ارتفاع نوار اعتبار + safe-area؛ منوی پایین با bottom: var(--app-credit-footer-offset) بالاتر از این نوار می‌نشیند */ :root { --app-credit-bar-inner: 28px; + /* constant برای iOS قدیمی؛ env باید مستقیم هم در مصرف‌کننده‌ها تکرار شود */ + --app-safe-top: 0px; + --app-safe-top: constant(safe-area-inset-top); --app-safe-top: env(safe-area-inset-top, 0px); + --app-safe-bottom: 0px; + --app-safe-bottom: constant(safe-area-inset-bottom); --app-safe-bottom: env(safe-area-inset-bottom, 0px); --app-home-header-offset: calc(4.25rem + var(--app-safe-top)); --app-credit-footer-offset: calc(var(--app-credit-bar-inner) + var(--app-safe-bottom)); @@ -270,6 +278,12 @@ watch( box-sizing: content-box; } +/* toast اعلان‌های اپ باید بالای نوار اعتبار باشد */ +.ant-notification .app-notif-toast, +.app-notif-toast { + z-index: 1000001 !important; +} + .app-credit-footer--mobile { width: 100%; max-width: 426px; diff --git a/src/api/appNotifications.ts b/src/api/appNotifications.ts new file mode 100644 index 0000000..099fd86 --- /dev/null +++ b/src/api/appNotifications.ts @@ -0,0 +1,24 @@ +import useAxios from '@/utils/useAxios' + +export type AppNotificationSeverity = 'info' | 'success' | 'warning' | 'danger' + +export interface CustomerAppNotification { + id: number + title: string + body: string + severity: AppNotificationSeverity + created_at?: string + is_read: boolean +} + +export const getAppNotifications = async () => + await useAxios.get(`/customer/app_notifications`) + +export const getAppNotificationsUnreadCount = async () => + await useAxios.get<{ unread_count: number }>(`/customer/app_notifications/unread-count`) + +export const markAppNotificationRead = async (id: number) => + await useAxios.post(`/customer/app_notifications/${id}/read`) + +export const markAllAppNotificationsRead = async () => + await useAxios.post(`/customer/app_notifications/read-all`) diff --git a/src/app/setupRouter.ts b/src/app/setupRouter.ts index 97a3027..1592b59 100644 --- a/src/app/setupRouter.ts +++ b/src/app/setupRouter.ts @@ -76,6 +76,28 @@ export function setupRouterGuards() { return goToLogin(next) } + if (to.name === 'notifyTrigger') { + const { useSettingsStore } = await import('@/store/settingsStore') + const settingsStore = useSettingsStore() + if (!settingsStore.loaded) { + await settingsStore.fetchSettings() + } + if (!settingsStore.isSettingTruthy('has_page_price_trigger')) { + return next({ name: (from.name as string) || 'user' }) + } + } + + if (to.name === 'appNotifications') { + const { useSettingsStore } = await import('@/store/settingsStore') + const settingsStore = useSettingsStore() + if (!settingsStore.loaded) { + await settingsStore.fetchSettings() + } + if (!settingsStore.isSettingTruthy('has_page_notification')) { + return next({ name: (from.name as string) || 'home' }) + } + } + hasFingerPrint = !!userStore.userInfo?.finger_print if (isApple && userStore.userInfo?.finger_print) { diff --git a/src/composables/useAppNotificationBadge.ts b/src/composables/useAppNotificationBadge.ts new file mode 100644 index 0000000..c63352b --- /dev/null +++ b/src/composables/useAppNotificationBadge.ts @@ -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 | 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) { + 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 } diff --git a/src/layouts/desktop/DesktopSidebar.vue b/src/layouts/desktop/DesktopSidebar.vue index b438e78..c30f163 100644 --- a/src/layouts/desktop/DesktopSidebar.vue +++ b/src/layouts/desktop/DesktopSidebar.vue @@ -45,6 +45,7 @@ diff --git a/src/layouts/styles/mobile-page-common.less b/src/layouts/styles/mobile-page-common.less index f912a37..cbb9a05 100644 --- a/src/layouts/styles/mobile-page-common.less +++ b/src/layouts/styles/mobile-page-common.less @@ -2,24 +2,25 @@ :root { --mpage-header-bar: 2.75rem; - --mpage-header-offset: calc(4rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); - --mpage-subheader-offset: calc(var(--mpage-header-bar) + var(--app-safe-top, env(safe-area-inset-top, 0px))); - --mpage-content-with-subheader: calc(6.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); - --mpage-scroll-max-height: calc(100dvh - 50px - var(--app-safe-top, env(safe-area-inset-top, 0px))); - --mobile-info-support-block: calc(5rem + var(--app-safe-bottom, env(safe-area-inset-bottom, 0px))); + /* وابسته به --app-safe-top تا با sync JS روی iOS درست بماند */ + --mpage-header-offset: calc(4rem + var(--app-safe-top, 0px)); + --mpage-subheader-offset: calc(var(--mpage-header-bar) + var(--app-safe-top, 0px)); + --mpage-content-with-subheader: calc(6.5rem + var(--app-safe-top, 0px)); + --mpage-scroll-max-height: calc(100dvh - 50px - var(--app-safe-top, 0px)); + --mobile-info-support-block: calc(5rem + var(--app-safe-bottom, 0px)); } /* Fixed top bar: background fills under status bar; content sits below safe area */ .mobile-top-chrome { top: 0; box-sizing: border-box; - padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + padding-top: calc(0.75rem + var(--app-safe-top, 0px)); } .mobile-top-chrome.h-12, .mobile-top-chrome.min-h-12 { height: auto; - min-height: calc(3rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + min-height: calc(3rem + var(--app-safe-top, 0px)); } .mpage-header { @@ -29,7 +30,8 @@ top: 0; box-sizing: border-box; padding: 0.75rem; - padding-top: calc(0.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + padding-top: calc(0.75rem + var(--app-safe-top, 0px)); + min-height: calc(2.75rem + var(--app-safe-top, 0px)); background: #fff; color: @App-primary-color; font-size: 1rem; @@ -85,15 +87,15 @@ } .mobile-page-body--pt10 { - padding-top: calc(2.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + padding-top: calc(2.5rem + var(--app-safe-top, 0px)); } .mobile-page-body--pt15 { - padding-top: calc(3.75rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + padding-top: calc(3.75rem + var(--app-safe-top, 0px)); } .mobile-page-body--pt20 { - padding-top: calc(5rem + var(--app-safe-top, env(safe-area-inset-top, 0px))); + padding-top: calc(5rem + var(--app-safe-top, 0px)); } /* Info pages (about, educate): fixed shell, scroll inside content box only */ diff --git a/src/main.ts b/src/main.ts index 1bf1365..1d18c5c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import 'virtual:uno.css' import '@unocss/reset/tailwind-compat.css' import { initPwaInstallCapture } from '@/modules/pwa/manifest' +import { initSafeAreaInsets } from '@/utils/safeAreaInsets' import { bootstrapApp } from '@/app/bootstrap' export { @@ -12,5 +13,6 @@ export { setFingerPosition, } from '@/app/authBoot' +initSafeAreaInsets() initPwaInstallCapture() void bootstrapApp() diff --git a/src/modules/pwa/manifest.ts b/src/modules/pwa/manifest.ts index 65674ec..b57b59e 100644 --- a/src/modules/pwa/manifest.ts +++ b/src/modules/pwa/manifest.ts @@ -401,6 +401,7 @@ export async function applyPreparedManifestToDocument( upsertMeta('apple-mobile-web-app-title', title) upsertMeta('mobile-web-app-capable', 'yes') upsertMeta('apple-mobile-web-app-capable', 'yes') + upsertMeta('apple-mobile-web-app-status-bar-style', 'black-translucent') applyAppleTouchIconsFromManifest(normalized) diff --git a/src/router/index.ts b/src/router/index.ts index 81f7134..ac8a03e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -61,6 +61,11 @@ const router = createRouter({ name: 'notifyTrigger', component: () => import('@/views/base/priceTrigger/index.vue') }, + { + path: 'appNotifications', + name: 'appNotifications', + component: () => import('@/views/base/appNotifications/index.vue') + }, { path: 'address', name: 'address', diff --git a/src/sw.ts b/src/sw.ts index 2417552..c29884a 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,7 +1,37 @@ import { precacheAndRoute } from 'workbox-precaching' + declare let self: ServiceWorkerGlobalScope + self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting() }) -// self.__WB_MANIFEST is default injection point + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const targetUrl = String(event.notification.data?.url || '/appNotifications') + + event.waitUntil( + (async () => { + const allClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }) + for (const client of allClients) { + if ('focus' in client) { + await client.focus() + if ('navigate' in client) { + try { + await (client as WindowClient).navigate(targetUrl) + } catch { + // ignore + } + } + return + } + } + await self.clients.openWindow(targetUrl) + })(), + ) +}) + precacheAndRoute(self.__WB_MANIFEST) diff --git a/src/utils/browserNotification.ts b/src/utils/browserNotification.ts new file mode 100644 index 0000000..e9fa3bd --- /dev/null +++ b/src/utils/browserNotification.ts @@ -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 { + 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 { + 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 + } +} diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 47dd13e..988acc8 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -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) +} + +async function applyAppNotificationFromStream(payload: Record) { + 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() }) diff --git a/src/utils/safeAreaInsets.ts b/src/utils/safeAreaInsets.ts new file mode 100644 index 0000000..9f08363 --- /dev/null +++ b/src/utils/safeAreaInsets.ts @@ -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) +} diff --git a/src/utils/storeHours.ts b/src/utils/storeHours.ts index ecad229..34f152d 100644 --- a/src/utils/storeHours.ts +++ b/src/utils/storeHours.ts @@ -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' } diff --git a/src/views/base/appNotifications/components/AppNotificationCard.vue b/src/views/base/appNotifications/components/AppNotificationCard.vue new file mode 100644 index 0000000..8fce7a7 --- /dev/null +++ b/src/views/base/appNotifications/components/AppNotificationCard.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/views/base/appNotifications/components/BrowserNotificationPermissionBanner.vue b/src/views/base/appNotifications/components/BrowserNotificationPermissionBanner.vue new file mode 100644 index 0000000..9867ca8 --- /dev/null +++ b/src/views/base/appNotifications/components/BrowserNotificationPermissionBanner.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/views/base/appNotifications/composables/notificationDisplay.ts b/src/views/base/appNotifications/composables/notificationDisplay.ts new file mode 100644 index 0000000..723c2eb --- /dev/null +++ b/src/views/base/appNotifications/composables/notificationDisplay.ts @@ -0,0 +1,24 @@ +const PREVIEW_MAX = 90 + +export function severityLabel(severity: string) { + switch (severity) { + case 'success': + return 'موفق' + case 'warning': + return 'هشدار' + case 'danger': + return 'مهم' + default: + return 'اطلاعات' + } +} + +export function previewText(body: string, max = PREVIEW_MAX): string { + const text = String(body ?? '').replace(/\s+/g, ' ').trim() + if (text.length <= max) return text + return `${text.slice(0, max).trim()}…` +} + +export function needsPreview(body: string, max = PREVIEW_MAX): boolean { + return String(body ?? '').replace(/\s+/g, ' ').trim().length > max +} diff --git a/src/views/base/appNotifications/composables/useAppNotificationsPage.ts b/src/views/base/appNotifications/composables/useAppNotificationsPage.ts new file mode 100644 index 0000000..8d354b2 --- /dev/null +++ b/src/views/base/appNotifications/composables/useAppNotificationsPage.ts @@ -0,0 +1,120 @@ +import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue' +import { + getAppNotifications, + markAllAppNotificationsRead, + markAppNotificationRead, + type CustomerAppNotification, +} from '@/api/appNotifications' +import { useSettingsStore } from '@/store/settingsStore' + +export interface AppNotificationsPageContext { + loading: ReturnType> + items: ReturnType> + detailVisible: ReturnType> + selectedItem: ReturnType> + enabled: ReturnType> + refresh: () => Promise + openItem: (id: number) => Promise + closeDetail: () => void + markRead: (id: number) => Promise + markAllRead: () => Promise +} + +export const APP_NOTIFICATIONS_PAGE_KEY: InjectionKey = + Symbol('appNotificationsPage') + +export function useAppNotificationsPage() { + const settingsStore = useSettingsStore() + const loading = ref(false) + const items = ref([]) + const detailVisible = ref(false) + const selectedItem = ref(null) + + const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification')) + + async function refresh() { + if (!enabled.value) { + items.value = [] + return + } + loading.value = true + try { + const data = await getAppNotifications() + items.value = Array.isArray(data) ? data : [] + } catch (e) { + console.error(e) + items.value = [] + } finally { + loading.value = false + } + } + + async function markRead(id: number) { + const row = items.value.find((item) => item.id === id) + if (!row || row.is_read) return + + try { + await markAppNotificationRead(id) + row.is_read = true + if (selectedItem.value?.id === id) { + selectedItem.value = { ...selectedItem.value, is_read: true } + } + const { refreshUnreadCount } = await import('@/composables/useAppNotificationBadge') + void refreshUnreadCount() + } catch (e) { + console.error(e) + } + } + + /** باز کردن جزئیات در مدال/اکشن‌شیت + سین */ + async function openItem(id: number) { + const row = items.value.find((item) => item.id === id) + if (!row) return + selectedItem.value = row + detailVisible.value = true + await markRead(id) + } + + function closeDetail() { + detailVisible.value = false + selectedItem.value = null + } + + async function markAllRead() { + try { + await markAllAppNotificationsRead() + items.value = items.value.map((item) => ({ ...item, is_read: true })) + const { clearUnreadLocally } = await import('@/composables/useAppNotificationBadge') + clearUnreadLocally() + } catch (e) { + console.error(e) + } + } + + onMounted(() => { + void refresh() + }) + + const ctx: AppNotificationsPageContext = { + loading, + items, + detailVisible, + selectedItem, + enabled, + refresh, + openItem, + closeDetail, + markRead, + markAllRead, + } + provide(APP_NOTIFICATIONS_PAGE_KEY, ctx) + return ctx +} + +export function useAppNotificationsPageContext() { + const ctx = inject(APP_NOTIFICATIONS_PAGE_KEY) + if (!ctx) { + throw new Error('useAppNotificationsPageContext must be used within useAppNotificationsPage') + } + return ctx +} diff --git a/src/views/base/appNotifications/desktop/DesktopAppNotificationsView.vue b/src/views/base/appNotifications/desktop/DesktopAppNotificationsView.vue new file mode 100644 index 0000000..965e5cf --- /dev/null +++ b/src/views/base/appNotifications/desktop/DesktopAppNotificationsView.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/src/views/base/appNotifications/index.vue b/src/views/base/appNotifications/index.vue new file mode 100644 index 0000000..b521ea0 --- /dev/null +++ b/src/views/base/appNotifications/index.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/views/base/appNotifications/mobile/MobileAppNotificationsView.vue b/src/views/base/appNotifications/mobile/MobileAppNotificationsView.vue new file mode 100644 index 0000000..3b70d7c --- /dev/null +++ b/src/views/base/appNotifications/mobile/MobileAppNotificationsView.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/views/base/fund/info/components/Item.vue b/src/views/base/fund/info/components/Item.vue index f5eea2b..2af5cf4 100644 --- a/src/views/base/fund/info/components/Item.vue +++ b/src/views/base/fund/info/components/Item.vue @@ -10,7 +10,7 @@
{{ item.title }}
-
{{ toJalaliNotime(item.expire_at) }}
+
{{ toJalali(item.expire_at) }}
@@ -69,11 +69,7 @@
تاریخ:
-
{{ toJalaliNotime(item.expire_at) }}
-
-
-
زمان:
-
{{ toTime(item.expire_at) }}
+
{{ toJalali(item.expire_at) }}
بازگشت
@@ -89,7 +85,7 @@ import {computed, ref} from "vue"; import BasicButton from "@/components/BasicPacks/BasicButton.vue"; import type {Transaction} from "@/models/transaction"; import {formatGoldAmount, usePriceWithoutType} from "../../../../../utils/usePrice"; -import {toJalaliNotime, toTime} from "../../../../../utils/useDateTime"; +import {toJalali} from "../../../../../utils/useDateTime"; const infoVisible = ref(false) diff --git a/src/views/base/home/components/AppHeader.vue b/src/views/base/home/components/AppHeader.vue index 185feda..c2d03dc 100644 --- a/src/views/base/home/components/AppHeader.vue +++ b/src/views/base/home/components/AppHeader.vue @@ -10,6 +10,20 @@
+ + +