Port modular app notifications, pargar date display, and closed-store product UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>در حال بارگذاری...</title>
|
||||
<!---->
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
@@ -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<CustomerAppNotification[]>(`/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`)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
@@ -45,6 +45,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useConfigStore } from '@/store/configStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { getProjectSplashLogo } from '@/utils/config'
|
||||
import { getPrimaryNavItems, SECONDARY_NAV } from '../config/navigation'
|
||||
import { useAppNavigation } from '../composables/useAppNavigation'
|
||||
@@ -52,9 +53,17 @@ import DesktopNavItem from './DesktopNavItem.vue'
|
||||
import AppCreditLink from '@/components/AppCreditLink.vue'
|
||||
|
||||
const config = useConfigStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const navLogo = computed(() => getProjectSplashLogo(config.config?.avatar))
|
||||
const primaryItems = getPrimaryNavItems()
|
||||
const secondaryItems = SECONDARY_NAV
|
||||
const secondaryItems = computed(() =>
|
||||
SECONDARY_NAV.filter((item) => {
|
||||
if (item.id === 'notifyTrigger') {
|
||||
return settingsStore.isSettingTruthy('has_page_price_trigger')
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
const { isNavActive, navigateTo } = useAppNavigation()
|
||||
</script>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<article
|
||||
class="app-notif-card"
|
||||
:class="[
|
||||
`app-notif-card--${item.severity}`,
|
||||
{ 'app-notif-card--unread': !item.is_read },
|
||||
{ 'app-notif-card--compact': compact },
|
||||
]"
|
||||
@click="$emit('open', item.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="app-notif-card__title m-0 font-bold text-gray-900">{{ item.title }}</h3>
|
||||
<span v-if="!item.is_read" class="app-notif-card__new">جدید</span>
|
||||
</div>
|
||||
<span class="app-notif-card__badge">{{ severityLabel(item.severity) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="app-notif-card__body app-notif-card__body--preview m-0 mt-2 text-sm text-gray-600">
|
||||
{{ previewText(item.body) }}
|
||||
</p>
|
||||
|
||||
<div class="app-notif-card__footer">
|
||||
<span class="app-notif-card__more">مشاهده کامل</span>
|
||||
<BasicIcon icon="vuesax-linear:arrow-left-1" size="16" class="text-gray-500" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import type { CustomerAppNotification } from '@/api/appNotifications'
|
||||
import { previewText, severityLabel } from '../composables/notificationDisplay'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: CustomerAppNotification
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{ compact: false },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
open: [id: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-notif-card {
|
||||
@apply rounded-xl bg-white p-3 border border-gray-100 cursor-pointer transition-shadow;
|
||||
}
|
||||
.app-notif-card--compact {
|
||||
@apply p-4;
|
||||
}
|
||||
.app-notif-card--unread {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
.app-notif-card__title {
|
||||
@apply text-sm;
|
||||
}
|
||||
.app-notif-card--compact .app-notif-card__title {
|
||||
@apply text-base;
|
||||
}
|
||||
.app-notif-card__new {
|
||||
@apply shrink-0 rounded-md bg-red-50 px-1.5 py-0.5 text-[10px] font-bold text-red-600;
|
||||
}
|
||||
.app-notif-card__badge {
|
||||
@apply shrink-0 rounded-md px-2 py-0.5 text-xs font-bold;
|
||||
}
|
||||
.app-notif-card__body--preview {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
.app-notif-card__footer {
|
||||
@apply mt-3 flex items-center justify-between gap-2 border-0 border-t border-solid border-gray-100 pt-2;
|
||||
}
|
||||
.app-notif-card__more {
|
||||
@apply text-xs font-bold text-gray-600;
|
||||
}
|
||||
.app-notif-card--info .app-notif-card__badge {
|
||||
@apply bg-blue-50 text-blue-700;
|
||||
}
|
||||
.app-notif-card--success .app-notif-card__badge {
|
||||
@apply bg-green-50 text-green-700;
|
||||
}
|
||||
.app-notif-card--warning .app-notif-card__badge {
|
||||
@apply bg-orange-50 text-orange-700;
|
||||
}
|
||||
.app-notif-card--danger .app-notif-card__badge {
|
||||
@apply bg-red-50 text-red-700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div v-if="visible" class="notif-perm">
|
||||
<div class="notif-perm__text">
|
||||
<p class="notif-perm__title">{{ title }}</p>
|
||||
<p class="notif-perm__desc">{{ description }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="canEnable"
|
||||
type="button"
|
||||
class="notif-perm__btn"
|
||||
:disabled="loading"
|
||||
@click="onEnable"
|
||||
>
|
||||
{{ loading ? '…' : 'فعالسازی' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
getBrowserNotificationState,
|
||||
requestBrowserNotificationPermission,
|
||||
type BrowserNotifState,
|
||||
} from '@/utils/browserNotification'
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [state: BrowserNotifState]
|
||||
}>()
|
||||
|
||||
const state = ref<BrowserNotifState>('unsupported')
|
||||
const loading = ref(false)
|
||||
const dismissed = ref(false)
|
||||
|
||||
const visible = computed(() => {
|
||||
if (dismissed.value) return false
|
||||
return state.value === 'default' || state.value === 'needs-pwa' || state.value === 'denied'
|
||||
})
|
||||
|
||||
const canEnable = computed(() => state.value === 'default')
|
||||
|
||||
const title = computed(() => {
|
||||
if (state.value === 'needs-pwa') return 'اعلان سیستم در دسترس نیست'
|
||||
if (state.value === 'denied') return 'اعلان سیستم غیرفعال است'
|
||||
return 'اعلانهای فوری'
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
if (state.value === 'needs-pwa') {
|
||||
return 'برای دریافت نوتیف روی آیفون، اپ را به صفحه اصلی اضافه کنید و از همانجا باز کنید.'
|
||||
}
|
||||
if (state.value === 'denied') {
|
||||
return 'مجوز اعلان در تنظیمات مرورگر مسدود است. از قفل کنار آدرس → Site settings → Notifications اجازه بدهید.'
|
||||
}
|
||||
return 'روی «فعالسازی» بزنید تا مرورگر اجازه نوتیف را بپرسد.'
|
||||
})
|
||||
|
||||
function refreshState() {
|
||||
state.value = getBrowserNotificationState()
|
||||
emit('changed', state.value)
|
||||
}
|
||||
|
||||
async function onEnable() {
|
||||
loading.value = true
|
||||
try {
|
||||
const next = await requestBrowserNotificationPermission()
|
||||
state.value = next
|
||||
emit('changed', next)
|
||||
if (next === 'granted') {
|
||||
dismissed.value = true
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshState()
|
||||
})
|
||||
|
||||
defineExpose({ refreshState })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notif-perm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 0.85rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.notif-perm__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-perm__title {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.notif-perm__desc {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.notif-perm__btn {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--App-primary, #0b5fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notif-perm__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<typeof ref<boolean>>
|
||||
items: ReturnType<typeof ref<CustomerAppNotification[]>>
|
||||
detailVisible: ReturnType<typeof ref<boolean>>
|
||||
selectedItem: ReturnType<typeof ref<CustomerAppNotification | null>>
|
||||
enabled: ReturnType<typeof computed<boolean>>
|
||||
refresh: () => Promise<void>
|
||||
openItem: (id: number) => Promise<void>
|
||||
closeDetail: () => void
|
||||
markRead: (id: number) => Promise<void>
|
||||
markAllRead: () => Promise<void>
|
||||
}
|
||||
|
||||
export const APP_NOTIFICATIONS_PAGE_KEY: InjectionKey<AppNotificationsPageContext> =
|
||||
Symbol('appNotificationsPage')
|
||||
|
||||
export function useAppNotificationsPage() {
|
||||
const settingsStore = useSettingsStore()
|
||||
const loading = ref(false)
|
||||
const items = ref<CustomerAppNotification[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const selectedItem = ref<CustomerAppNotification | null>(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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="dapp-notif">
|
||||
<header class="dapp-notif__top">
|
||||
<div>
|
||||
<p class="dapp-notif__eyebrow">اعلانها</p>
|
||||
<h1 class="dapp-notif__title">پیامهای مدیریت</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<BrowserNotificationPermissionBanner class="mb-4 max-w-3xl" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!items.length" class="dapp-notif__empty">
|
||||
<BasicIcon icon="vuesax-linear:notification-bing" size="36" />
|
||||
<p>اعلانی برای نمایش نیست.</p>
|
||||
</div>
|
||||
<div v-else class="dapp-notif__list">
|
||||
<AppNotificationCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
compact
|
||||
@open="openItem"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import AppNotificationCard from '../components/AppNotificationCard.vue'
|
||||
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
|
||||
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
|
||||
|
||||
const { loading, items, openItem } = useAppNotificationsPageContext()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dapp-notif {
|
||||
@apply p-6 min-h-full;
|
||||
}
|
||||
.dapp-notif__eyebrow {
|
||||
@apply m-0 text-xs text-gray-500;
|
||||
}
|
||||
.dapp-notif__title {
|
||||
@apply m-0 mt-1 text-xl font-bold;
|
||||
}
|
||||
.dapp-notif__empty {
|
||||
@apply mt-10 flex flex-col items-center gap-2 text-gray-500;
|
||||
}
|
||||
.dapp-notif__list {
|
||||
@apply mt-6 flex flex-col gap-3 max-w-3xl;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="app-notifications-page-root">
|
||||
<DesktopAppNotificationsView v-show="isDesktop" />
|
||||
<MobileAppNotificationsView v-show="!isDesktop" />
|
||||
|
||||
<BasicActionSheet
|
||||
v-model:show="detailVisible"
|
||||
:title="selectedItem?.title || 'جزئیات اعلان'"
|
||||
width="32rem"
|
||||
:show-button="true"
|
||||
:show-cancel="false"
|
||||
:show-ok="true"
|
||||
ok-text="بستن"
|
||||
@ok="closeDetail"
|
||||
@close="closeDetail"
|
||||
@cancel="closeDetail"
|
||||
>
|
||||
<div v-if="selectedItem" class="app-notif-detail">
|
||||
<div class="app-notif-detail__meta">
|
||||
<span
|
||||
class="app-notif-detail__badge"
|
||||
:class="`app-notif-detail__badge--${selectedItem.severity}`"
|
||||
>
|
||||
{{ severityLabel(selectedItem.severity) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="app-notif-detail__body">{{ selectedItem.body }}</p>
|
||||
</div>
|
||||
</BasicActionSheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
|
||||
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
|
||||
import { useAppNotificationsPage } from './composables/useAppNotificationsPage'
|
||||
import { severityLabel } from './composables/notificationDisplay'
|
||||
import DesktopAppNotificationsView from './desktop/DesktopAppNotificationsView.vue'
|
||||
import MobileAppNotificationsView from './mobile/MobileAppNotificationsView.vue'
|
||||
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const { detailVisible, selectedItem, closeDetail } = useAppNotificationsPage()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-notifications-page-root {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.app-notif-detail__meta {
|
||||
@apply mb-3 flex items-center;
|
||||
}
|
||||
.app-notif-detail__badge {
|
||||
@apply rounded-md px-2 py-0.5 text-xs font-bold;
|
||||
}
|
||||
.app-notif-detail__badge--info {
|
||||
@apply bg-blue-50 text-blue-700;
|
||||
}
|
||||
.app-notif-detail__badge--success {
|
||||
@apply bg-green-50 text-green-700;
|
||||
}
|
||||
.app-notif-detail__badge--warning {
|
||||
@apply bg-orange-50 text-orange-700;
|
||||
}
|
||||
.app-notif-detail__badge--danger {
|
||||
@apply bg-red-50 text-red-700;
|
||||
}
|
||||
.app-notif-detail__body {
|
||||
@apply m-0 whitespace-pre-wrap text-sm leading-7 text-gray-700;
|
||||
max-height: min(60vh, 28rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="app-notif-page">
|
||||
<MobilePageHeader title="اعلانها" show-back />
|
||||
<div class="app-notif-page__body mpage-content">
|
||||
<BrowserNotificationPermissionBanner class="mb-3" />
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!items.length" class="app-notif-page__empty">
|
||||
<BasicIcon icon="vuesax-linear:notification-bing" size="40" class="text-gray-400" />
|
||||
<p class="m-0 mt-3 font-bold text-gray-700">اعلانی نیست</p>
|
||||
<p class="m-0 mt-1 text-sm text-gray-500">هنوز اعلانی از طرف مدیریت ارسال نشده است.</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<AppNotificationCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@open="openItem"
|
||||
/>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||
import AppNotificationCard from '../components/AppNotificationCard.vue'
|
||||
import BrowserNotificationPermissionBanner from '../components/BrowserNotificationPermissionBanner.vue'
|
||||
import { useAppNotificationsPageContext } from '../composables/useAppNotificationsPage'
|
||||
|
||||
const { loading, items, openItem } = useAppNotificationsPageContext()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-notif-page {
|
||||
@apply min-h-full flex flex-col bg-gray-50;
|
||||
}
|
||||
.app-notif-page__body {
|
||||
@apply px-4 pb-8;
|
||||
}
|
||||
.app-notif-page__empty {
|
||||
@apply flex flex-col items-center justify-center rounded-xl bg-white p-8 text-center;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="w-full mr-3">
|
||||
<div class="flex justify-between w-full">
|
||||
<div>{{ item.title }}</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
<div>{{ toJalali(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2">
|
||||
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
|
||||
@@ -69,11 +69,7 @@
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-primary">تاریخ:</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-primary">زمان:</div>
|
||||
<div>{{ toTime(item.expire_at) }}</div>
|
||||
<div>{{ toJalali(item.expire_at) }}</div>
|
||||
</div>
|
||||
<BasicButton class="w-full mt-5" @click="infoVisible = false">بازگشت</BasicButton>
|
||||
</div>
|
||||
@@ -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<boolean>(false)
|
||||
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="notificationEnabled"
|
||||
type="button"
|
||||
class="mhome-header-btn mhome-header-btn--notify"
|
||||
aria-label="اعلان"
|
||||
@click.stop="goNotifications"
|
||||
>
|
||||
<span class="mhome-header-btn__icon-wrap">
|
||||
<Icon icon="vuesax-linear:notification-bing" size="18" />
|
||||
<span v-if="unreadCount > 0" class="mhome-header-btn__badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mhome-header-btn"
|
||||
@@ -36,12 +50,27 @@ import { useRoute } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { getProjectMobileNavLogo } from '@/utils/config'
|
||||
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
|
||||
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
|
||||
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
const isHeaderScrolled = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const projectLogo = computed(() => getProjectMobileNavLogo())
|
||||
const {
|
||||
enabled: notificationEnabled,
|
||||
unreadCount,
|
||||
clearUnreadLocally,
|
||||
} = useAppNotificationBadge()
|
||||
|
||||
async function goNotifications() {
|
||||
clearUnreadLocally()
|
||||
if (canRequestBrowserNotification()) {
|
||||
await requestBrowserNotificationPermission()
|
||||
}
|
||||
void router.push({ name: 'appNotifications' })
|
||||
}
|
||||
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let scrollTarget: HTMLElement | Window | null = null
|
||||
@@ -177,7 +206,7 @@ defineEmits<{
|
||||
.app-header {
|
||||
z-index: 1999;
|
||||
box-sizing: border-box;
|
||||
padding-top: calc(0.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
|
||||
padding-top: calc(0.5rem + var(--app-safe-top, 0px));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -209,4 +238,30 @@ defineEmits<{
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.mhome-header-btn--notify {
|
||||
color: #0a1b37;
|
||||
border-color: #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mhome-header-btn__icon-wrap {
|
||||
@apply relative inline-flex;
|
||||
}
|
||||
|
||||
.mhome-header-btn__badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,24 @@
|
||||
<BasicIcon icon="vuesax-linear:user" size="16" />
|
||||
<span>حساب کاربری</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="notificationEnabled"
|
||||
type="button"
|
||||
class="dhome-btn-ghost dhome-btn-ghost--notify"
|
||||
aria-label="اعلان"
|
||||
@click="goNotifications"
|
||||
>
|
||||
<span class="dhome-notify-wrap">
|
||||
<BasicIcon icon="vuesax-linear:notification-bing" size="16" />
|
||||
<span v-if="unreadCount > 0" class="dhome-notify-badge">
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span>اعلان</span>
|
||||
</button>
|
||||
<button type="button" class="dhome-btn-ghost" aria-label="بروزرسانی" @click="reloadApp">
|
||||
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showLogout"
|
||||
type="button"
|
||||
@@ -26,6 +44,8 @@
|
||||
<script lang="ts" setup>
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
|
||||
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
|
||||
import router from '@/router'
|
||||
|
||||
withDefaults(
|
||||
@@ -40,4 +60,43 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const {
|
||||
enabled: notificationEnabled,
|
||||
unreadCount,
|
||||
clearUnreadLocally,
|
||||
} = useAppNotificationBadge()
|
||||
|
||||
async function goNotifications() {
|
||||
clearUnreadLocally()
|
||||
if (canRequestBrowserNotification()) {
|
||||
await requestBrowserNotificationPermission()
|
||||
}
|
||||
void router.push({ name: 'appNotifications' })
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dhome-notify-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.dhome-notify-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div class="flex bg-white border-1 border-gray-200 rounded-lg"
|
||||
:class="isClickable ? 'bg-white opacity-100' : 'bg-gray-200 opacity-50 cursor-not-allowed'">
|
||||
<div
|
||||
class="flex bg-white border-1 border-gray-200 rounded-lg"
|
||||
:class="{
|
||||
'bg-white opacity-100': isClickable,
|
||||
'bg-gray-200 opacity-50 cursor-not-allowed': !isClickable && !locked,
|
||||
'product-item--locked': locked,
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="bg-gray-100 m-1.5 rounded-lg w-24 h-24 flex justify-center p-1.5 shrink-0">
|
||||
<img :src="displayImage" class="object-contain w-full h-full" alt="">
|
||||
</div>
|
||||
@@ -24,20 +31,36 @@ import { getProductDisplayImage } from '@/utils/productAssets'
|
||||
interface Prop {
|
||||
ProductItem: Product
|
||||
inactive?: boolean
|
||||
/** فروشگاه بسته — خاکستری و غیرقابل کلیک (قیمت همچنان نمایش داده میشود) */
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Prop>(), {
|
||||
inactive: false,
|
||||
locked: false,
|
||||
})
|
||||
|
||||
const isClickable = computed(() => props.ProductItem.show && !props.inactive)
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const isClickable = computed(() => props.ProductItem.show && !props.inactive && !props.locked)
|
||||
|
||||
const displayImage = computed(() => getProductDisplayImage(props.ProductItem))
|
||||
|
||||
function handleClick() {
|
||||
if (!isClickable.value) return
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.product-item--locked {
|
||||
filter: grayscale(0.85);
|
||||
opacity: 0.55;
|
||||
background: #f3f4f6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
:key="item.id"
|
||||
:product="item"
|
||||
:inactive="isProductPriceInactive(item)"
|
||||
:locked="storeClosed"
|
||||
@click="clickProduct(item)"
|
||||
/>
|
||||
<div v-if="!loading && !items?.length" class="dproducts-empty">
|
||||
@@ -57,7 +58,7 @@
|
||||
import DesktopPageTopbar from '@/layouts/components/DesktopPageTopbar.vue'
|
||||
import DesktopProductCard from './components/DesktopProductCard.vue'
|
||||
import { useProductsPageContext } from '../composables/useProductsPage'
|
||||
import { isMarketClosedForDisplay, STORE_CLOSED_TITLE } from '@/utils/storeHours'
|
||||
import { isStoreClosed, STORE_CLOSED_TITLE } from '@/utils/storeHours'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const {
|
||||
@@ -71,7 +72,7 @@ const {
|
||||
storeClosedMessage,
|
||||
} = useProductsPageContext()
|
||||
|
||||
const storeClosed = computed(() => isMarketClosedForDisplay())
|
||||
const storeClosed = computed(() => isStoreClosed())
|
||||
</script>
|
||||
|
||||
<style lang="less" src="./styles/desktop-products.less"></style>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="dproducts-card"
|
||||
:class="{ 'dproducts-card--disabled': !isClickable }"
|
||||
:class="{
|
||||
'dproducts-card--disabled': !isClickable,
|
||||
'dproducts-card--locked': locked,
|
||||
}"
|
||||
:disabled="!isClickable"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
@@ -39,15 +42,17 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
product: Product
|
||||
inactive?: boolean
|
||||
/** فروشگاه بسته — خاکستری و غیرقابل کلیک */
|
||||
locked?: boolean
|
||||
}>(),
|
||||
{ inactive: false },
|
||||
{ inactive: false, locked: false },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const isClickable = computed(() => props.product.show && !props.inactive)
|
||||
const isClickable = computed(() => props.product.show && !props.inactive && !props.locked)
|
||||
|
||||
const displayImage = computed(() => getProductDisplayImage(props.product))
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
font-family: inherit;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||
|
||||
&:hover:not(&--disabled) {
|
||||
&:hover:not(&--disabled):not(&--locked) {
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
transform: translateY(-1px);
|
||||
@@ -72,6 +72,12 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&--locked {
|
||||
filter: grayscale(0.85);
|
||||
opacity: 0.55;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&__image-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<ProductItem
|
||||
:ProductItem="item"
|
||||
:inactive="isProductPriceInactive(item)"
|
||||
:locked="storeClosed"
|
||||
@click="clickProduct(item)"
|
||||
/>
|
||||
</div>
|
||||
@@ -59,7 +60,7 @@ import img from '@/assets/img/user_ly1.svg'
|
||||
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
|
||||
import ProductItem from '@/views/base/products/components/ProductItem.vue'
|
||||
import { useProductsPageContext } from '../composables/useProductsPage'
|
||||
import { isMarketClosedForDisplay, STORE_CLOSED_TITLE } from '@/utils/storeHours'
|
||||
import { isStoreClosed, STORE_CLOSED_TITLE } from '@/utils/storeHours'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const {
|
||||
@@ -73,7 +74,7 @@ const {
|
||||
storeClosedMessage,
|
||||
} = useProductsPageContext()
|
||||
|
||||
const storeClosed = computed(() => isMarketClosedForDisplay())
|
||||
const storeClosed = computed(() => isStoreClosed())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -100,11 +100,7 @@
|
||||
</div>
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">تاریخ:</div>
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">زمان:</div>
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toTime(item.expire_at) }}</div>
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__value' : ''">{{ toJalali(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div v-if="item.eaj" :class="variant === 'desktop' ? 'dtxn-detail__row' : 'flex justify-between mb-3'">
|
||||
<div :class="variant === 'desktop' ? 'dtxn-detail__label' : 'text-primary'">مهلت ارسال فیش:</div>
|
||||
@@ -129,7 +125,7 @@ import {
|
||||
resolveTransactionTradeProduct,
|
||||
shouldShowTransactionTotalWeightLine,
|
||||
} from '@/utils/productDealMethod'
|
||||
import { toJalaliNotime, toTime } from '@/utils/useDateTime'
|
||||
import { toJalali } from '@/utils/useDateTime'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useConfigStore } from '@/store/configStore'
|
||||
import { getBoursecuActionIconSrc } from '@/utils/config'
|
||||
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
|
||||
import { normalizeAuthFormFields } from '@/utils/persianDigits'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
|
||||
export interface UserMenuAction {
|
||||
id: string
|
||||
@@ -64,6 +65,7 @@ export function useUserPage() {
|
||||
const formRef = ref<FormInstance>()
|
||||
const formPassRef = ref<FormInstance>()
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const nameProject = useConfigStore().config.name_fa
|
||||
const editPassVisible = ref(false)
|
||||
const editPasswordVisible = ref(false)
|
||||
@@ -117,7 +119,7 @@ export function useUserPage() {
|
||||
label: 'هشدار قیمت',
|
||||
icon: 'vuesax-linear:bill',
|
||||
onClick: () => router.push({ name: 'notifyTrigger' }),
|
||||
visible: true,
|
||||
visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="w-full mr-3">
|
||||
<div class="flex justify-between w-full">
|
||||
<div>{{ item.type_str }}</div>
|
||||
<div>{{ toJalaliNotime(item.created_at) }}</div>
|
||||
<div>{{ toJalali(item.created_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2">
|
||||
<div :class="item.gold >= 0? 'text-green-600':'text-red-600'" class="text-gray-500 text-xs number-ltr">
|
||||
@@ -111,11 +111,7 @@
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">تاریخ:</div>
|
||||
<div>{{ toJalaliNotime(item.expire_at) }}</div>
|
||||
</div>
|
||||
<div class="flex justify-between mb-3">
|
||||
<div class="text-black">زمان:</div>
|
||||
<div>{{ toTime(item.expire_at) }}</div>
|
||||
<div>{{ toJalali(item.expire_at) }}</div>
|
||||
</div>
|
||||
<!-- <div class="flex justify-between mb-3">
|
||||
<div class="text-black">وضعیت معامله:</div>
|
||||
@@ -144,7 +140,7 @@ import {computed, ref} from "vue";
|
||||
import BasicButton from "@/components/BasicPacks/BasicButton.vue";
|
||||
import type {Transaction} from "@/models/transaction";
|
||||
import {getCashWalletLabel, getGoldWalletLabel, getMetalTypeTradeLabel, getTradePriceUnitLabel, getWeightUnit} from '@/utils/productDealMethod'
|
||||
import {toJalaliNotime, toJalaliOutOfUTC, toTime} from "../../../../utils/useDateTime";
|
||||
import {toJalali, toJalaliOutOfUTC} from "../../../../utils/useDateTime";
|
||||
|
||||
const infoVisible = ref<boolean>(false)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user