From a49b20dfd4ea9b66ad3f73b3714827376487cc84 Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 28 Jul 2026 21:02:44 +0330 Subject: [PATCH] fix some bugs from accounting 7 --- src/App.vue | 14 + src/components/InstallButton.vue | 42 ++ src/components/pwa/PwaInstallGuideModal.vue | 375 ++++++++++++++++ .../pwa/PwaInstallStepIllustration.vue | 413 ++++++++++++++++++ src/composables/usePwaInstall.ts | 132 ++++++ src/modules/pwa/pwaInstall.ts | 129 ++++++ src/modules/pwa/pwaInstallGuideSteps.ts | 71 +++ src/utils/browserNotification.ts | 165 +++++++ src/utils/config.ts | 15 + src/views/base/home/components/AppHeader.vue | 253 +++++++++-- .../home/components/DesktopHomeTopbar.vue | 32 +- .../home/components/LogoutConfirmDialog.vue | 41 ++ .../desktop/components/DesktopHomeTopbar.vue | 20 +- .../home/desktop/styles/desktop-home.less | 9 + .../base/user/composables/useUserPage.ts | 24 +- .../base/user/desktop/DesktopUserView.vue | 13 + .../user/desktop/styles/desktop-user.less | 19 + src/views/base/user/index.vue | 5 + src/views/base/user/mobile/MobileUserView.vue | 53 +-- src/views/login/components/Login.vue | 2 + 20 files changed, 1734 insertions(+), 93 deletions(-) create mode 100644 src/components/InstallButton.vue create mode 100644 src/components/pwa/PwaInstallGuideModal.vue create mode 100644 src/components/pwa/PwaInstallStepIllustration.vue create mode 100644 src/composables/usePwaInstall.ts create mode 100644 src/modules/pwa/pwaInstall.ts create mode 100644 src/modules/pwa/pwaInstallGuideSteps.ts create mode 100644 src/utils/browserNotification.ts create mode 100644 src/views/base/home/components/LogoutConfirmDialog.vue diff --git a/src/App.vue b/src/App.vue index 101c762..4fecfe4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -72,6 +72,12 @@ /> + + @@ -89,6 +95,8 @@ import { loadSupportMessengerFromSettings } from '@/utils/supportMessenger' import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue' import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton' import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout' +import { usePwaInstall } from '@/composables/usePwaInstall' +import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue' import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg' import logoFull from '@/assets/img/logo_full_.png' @@ -108,6 +116,12 @@ const showSplash = ref(true) useApplyDesktopLayoutClass() +const { + guideOpen, + platform, + completeGuide, +} = usePwaInstall() + const { supportPickerOpen, supportPickerAnchor, diff --git a/src/components/InstallButton.vue b/src/components/InstallButton.vue new file mode 100644 index 0000000..cabd133 --- /dev/null +++ b/src/components/InstallButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/pwa/PwaInstallGuideModal.vue b/src/components/pwa/PwaInstallGuideModal.vue new file mode 100644 index 0000000..620c714 --- /dev/null +++ b/src/components/pwa/PwaInstallGuideModal.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/src/components/pwa/PwaInstallStepIllustration.vue b/src/components/pwa/PwaInstallStepIllustration.vue new file mode 100644 index 0000000..1d5be83 --- /dev/null +++ b/src/components/pwa/PwaInstallStepIllustration.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/src/composables/usePwaInstall.ts b/src/composables/usePwaInstall.ts new file mode 100644 index 0000000..f288306 --- /dev/null +++ b/src/composables/usePwaInstall.ts @@ -0,0 +1,132 @@ +import { computed, onMounted, ref } from 'vue' +import type { PwaPlatform } from '@/modules/pwa/pwaInstallGuideSteps' +import { + getDefaultPwaPlatform, + initPwaInstallPromptListener, + isPwaInstallBannerDismissed, + markPwaInstallBannerAsShown, + shouldOfferPwaInstall, + shouldShowPwaInstallPrompt, + triggerNativePwaInstall, +} from '@/modules/pwa/pwaInstall' + +const guideOpen = ref(false) +const bannerVisible = ref(false) +const platform = ref(getDefaultPwaPlatform()) +const installBannerConsumed = ref(isPwaInstallBannerDismissed()) +/** false وقتی اپ به‌صورت PWA/TWA باز شده یا همین الان نصب شده */ +const offerInstall = ref(shouldOfferPwaInstall()) +let listenerReady = false +let offerListenersBound = false + +function refreshOfferInstall() { + offerInstall.value = shouldOfferPwaInstall() +} + +function bindOfferInstallListeners() { + if (offerListenersBound || typeof window === 'undefined') return + offerListenersBound = true + + refreshOfferInstall() + + const onInstalled = () => { + offerInstall.value = false + } + window.addEventListener('appinstalled', onInstalled) + + const modes = ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay'] as const + const mediaLists = modes.map((mode) => window.matchMedia(`(display-mode: ${mode})`)) + const onDisplayModeChange = () => refreshOfferInstall() + for (const mql of mediaLists) { + mql.addEventListener('change', onDisplayModeChange) + } +} + +function ensureListener() { + if (listenerReady) return + listenerReady = true + initPwaInstallPromptListener() + bindOfferInstallListeners() +} + +export function usePwaInstall() { + ensureListener() + + onMounted(() => { + refreshOfferInstall() + }) + + const canPrompt = computed(() => { + if (installBannerConsumed.value) return false + return shouldShowPwaInstallPrompt() + }) + + const showInstallEntry = computed(() => offerInstall.value) + + function consumeInstallBanner() { + if (installBannerConsumed.value) return + markPwaInstallBannerAsShown() + installBannerConsumed.value = true + bannerVisible.value = false + } + + function openGuide(nextPlatform?: PwaPlatform) { + if (!offerInstall.value) return + consumeInstallBanner() + platform.value = nextPlatform ?? getDefaultPwaPlatform() + guideOpen.value = true + } + + function closeGuide() { + guideOpen.value = false + } + + function showBanner() { + if (!canPrompt.value) return + bannerVisible.value = true + markPwaInstallBannerAsShown() + installBannerConsumed.value = true + } + + function hideBanner() { + bannerVisible.value = false + } + + function dismissBanner() { + consumeInstallBanner() + } + + async function completeGuide() { + await triggerNativePwaInstall() + consumeInstallBanner() + closeGuide() + } + + /** نصب مستقیم مرورگر؛ اگر ممکن نبود مدال آموزش. */ + async function tryInstallOrOpenGuide(nextPlatform?: PwaPlatform) { + if (!offerInstall.value) return false + const installed = await triggerNativePwaInstall() + if (installed) { + offerInstall.value = false + consumeInstallBanner() + return true + } + openGuide(nextPlatform) + return false + } + + return { + guideOpen, + bannerVisible, + platform, + canPrompt, + showInstallEntry, + openGuide, + closeGuide, + showBanner, + hideBanner, + dismissBanner, + completeGuide, + tryInstallOrOpenGuide, + } +} diff --git a/src/modules/pwa/pwaInstall.ts b/src/modules/pwa/pwaInstall.ts new file mode 100644 index 0000000..f31bd7b --- /dev/null +++ b/src/modules/pwa/pwaInstall.ts @@ -0,0 +1,129 @@ +import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest' +import { isTWA } from '@/utils/config' + +export const PWA_INSTALL_BANNER_DISMISSED_KEY = 'pwa-install-banner-dismissed' + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +let deferredInstallPrompt: BeforeInstallPromptEvent | null = null +let installListenerInitialized = false + +export function isStandalonePwa() { + if (typeof window === 'undefined') return false + + const nav = window.navigator as Navigator & { standalone?: boolean } + if (nav.standalone) return true + + // installed / launched as app (not a normal browser tab) + const modes = ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay'] as const + return modes.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches) +} + +/** دکمه/منوی نصب فقط وقتی هنوز داخل مرورگر عادی هستیم. */ +export function shouldOfferPwaInstall() { + if (typeof window === 'undefined') return false + if (isStandalonePwa() || isTWA()) return false + return true +} + +export 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 isAndroidDevice() { + if (typeof navigator === 'undefined') return false + return /Android/i.test(navigator.userAgent || '') +} + +export function isMobileBrowser() { + if (typeof window === 'undefined') return false + + const coarsePointer = window.matchMedia('(pointer: coarse)').matches + const narrowViewport = window.matchMedia('(max-width: 767.98px)').matches + + return coarsePointer || narrowViewport || isAppleMobileDevice() || isAndroidDevice() +} + +export function getDefaultPwaPlatform(): 'android' | 'ios' { + if (isAppleMobileDevice()) return 'ios' + return 'android' +} + +export function hasNativeInstallPrompt() { + return Boolean(deferredInstallPrompt) +} + +export function isPwaInstallBannerDismissed() { + if (typeof localStorage === 'undefined') return false + return localStorage.getItem(PWA_INSTALL_BANNER_DISMISSED_KEY) === '1' +} + +export function dismissPwaInstallBanner() { + localStorage.setItem(PWA_INSTALL_BANNER_DISMISSED_KEY, '1') +} + +/** بعد از اولین نمایش بنر / راهنما دیگر نشان داده نشود. */ +export function markPwaInstallBannerAsShown() { + dismissPwaInstallBanner() +} + +export function shouldShowPwaInstallPrompt() { + if (typeof window === 'undefined') return false + if (!window.isSecureContext) return false + if (!shouldOfferPwaInstall()) return false + if (isPwaInstallBannerDismissed()) return false + + return true +} + +export function initPwaInstallPromptListener() { + if (installListenerInitialized || typeof window === 'undefined') return + installListenerInitialized = true + + window.addEventListener(PWA_MANIFEST_READY_EVENT, (event) => { + const installEvent = (event as CustomEvent<{ installEvent?: Event | null }>).detail?.installEvent + + if (installEvent) { + deferredInstallPrompt = installEvent as BeforeInstallPromptEvent + } + }) +} + +export async function triggerNativePwaInstall() { + const promptEvent = deferredInstallPrompt + + if (!promptEvent) { + return false + } + + try { + await promptEvent.prompt() + const choice = await promptEvent.userChoice + + if (choice.outcome === 'accepted') { + deferredInstallPrompt = null + dismissPwaInstallBanner() + return true + } + } catch (error) { + console.warn('Native PWA install prompt failed', error) + } + + return false +} diff --git a/src/modules/pwa/pwaInstallGuideSteps.ts b/src/modules/pwa/pwaInstallGuideSteps.ts new file mode 100644 index 0000000..b7a86fa --- /dev/null +++ b/src/modules/pwa/pwaInstallGuideSteps.ts @@ -0,0 +1,71 @@ +export type PwaPlatform = 'android' | 'ios' + +export type PwaGuideIllustration = + | 'android-chrome' + | 'android-menu' + | 'android-install' + | 'android-confirm' + | 'ios-safari' + | 'ios-share' + | 'ios-add-home' + | 'ios-confirm' + +export type PwaGuideStep = { + illustration: PwaGuideIllustration + text: string + hint?: string +} + +export type PwaGuidePlatformConfig = { + title: string + steps: PwaGuideStep[] +} + +export const PWA_INSTALL_GUIDE: Record = { + android: { + title: 'نصب روی Android', + steps: [ + { + illustration: 'android-chrome', + text: 'سایت را در مرورگر Chrome باز کنید.', + }, + { + illustration: 'android-menu', + text: 'روی منوی سه‌نقطه ⋮ در گوشه بالا بزنید.', + }, + { + illustration: 'android-install', + text: 'گزینه «Install app» یا «Add to Home screen» / «افزودن به صفحه اصلی» را انتخاب کنید.', + }, + { + illustration: 'android-confirm', + text: 'نصب را تأیید کنید تا میانبر روی صفحه اصلی ساخته شود.', + hint: 'اگر دکمه نصب را نمی‌بینید، یک‌بار صفحه را رفرش کنید و دوباره منو را باز کنید.', + }, + ], + }, + ios: { + title: 'نصب روی iPhone / iPad', + steps: [ + { + illustration: 'ios-safari', + text: 'سایت را در مرورگر Safari باز کنید (از Chrome روی iOS نصب نمی‌شود).', + }, + { + illustration: 'ios-share', + text: 'روی دکمه Share در پایین صفحه بزنید (مربع با فلش رو به بالا).', + }, + { + illustration: 'ios-add-home', + text: 'به پایین اسکرول کنید و «Add to Home Screen» یا «افزودن به صفحه اصلی» را انتخاب کنید.', + }, + { + illustration: 'ios-confirm', + text: 'نام را تأیید کنید و روی Add / افزودن بزنید.', + hint: 'بعد از نصب، آیکون اپ مثل بقیه برنامه‌ها روی صفحه اصلی ظاهر می‌شود.', + }, + ], + }, +} + +export const PWA_GUIDE_STEP_LABELS = ['۱', '۲', '۳', '۴'] as const diff --git a/src/utils/browserNotification.ts b/src/utils/browserNotification.ts new file mode 100644 index 0000000..a9c6460 --- /dev/null +++ b/src/utils/browserNotification.ts @@ -0,0 +1,165 @@ +/** نوتیف مرورگر بدون 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() + } +} + +/** لوگوی برند از کانفیگ؛ fallback به آیکون‌های PWA */ +function resolveNotifIcon() { + try { + const avatar = String( + (window as Window & { __APP_CONFIG__?: { user?: { avatar?: string } } }).__APP_CONFIG__?.user + ?.avatar ?? '', + ).trim() + if (avatar) { + return avatar + } + } catch { + // ignore + } + + try { + return new URL('/192.png', window.location.origin).href + } catch { + try { + return new URL('/favicon.png', 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() + + 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) + } + + 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/config.ts b/src/utils/config.ts index becbf76..e0c7834 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -6,3 +6,18 @@ export function getConfigApp(key: string) { } return null } + +export function isBoursecuProject() { + const name = String(getConfigApp('name') || '').toLowerCase() + return name === 'boursecu' || name === 'boursemes' +} + +export function isTWA() { + if (typeof sessionStorage === 'undefined') return false + if (sessionStorage.getItem('appMode') === 'twa') return true + if (typeof document !== 'undefined' && document.referrer.startsWith('android-app://')) { + sessionStorage.setItem('appMode', 'twa') + return true + } + return false +} diff --git a/src/views/base/home/components/AppHeader.vue b/src/views/base/home/components/AppHeader.vue index 4cc338c..4404284 100644 --- a/src/views/base/home/components/AppHeader.vue +++ b/src/views/base/home/components/AppHeader.vue @@ -4,39 +4,86 @@ class="app-header px-4 pb-2 top-0 wrap-fix background-color-transition" :class="{ 'app-header--scrolled': isHeaderScrolled }" > -
-
-
+
+
+
-
+
{{ userStore.userInfo?.name }}
-
+
+ +
+ - + + + +
@@ -47,11 +94,28 @@ import { nextTick, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { useUserStore } from '@/store/userStore' import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge' +import { usePwaInstall } from '@/composables/usePwaInstall' +import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest' +import { + hasNativeInstallPrompt, + initPwaInstallPromptListener, +} from '@/modules/pwa/pwaInstall' +import { + canRequestBrowserNotification, + requestBrowserNotificationPermission, +} from '@/utils/browserNotification' import router from '@/router' import Icon from '@/components/BasicPacks/BasicIcon.vue' +const emit = defineEmits<{ + logout: [] +}>() + const headerRef = ref(null) +const menuRoot = ref(null) +const menuOpen = ref(false) const isHeaderScrolled = ref(false) +const canNativeInstall = ref(false) const route = useRoute() const userStore = useUserStore() @@ -60,12 +124,48 @@ const { unreadCount, clearUnreadLocally, } = useAppNotificationBadge() +const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall() -function goNotifications() { +function refreshCanInstall() { + canNativeInstall.value = hasNativeInstallPrompt() +} + +function closeMenu() { + menuOpen.value = false +} + +async function goNotifications() { + closeMenu() clearUnreadLocally() + if (canRequestBrowserNotification()) { + void requestBrowserNotificationPermission() + } void router.push({ name: 'appNotifications' }) } +async function onInstall() { + closeMenu() + await tryInstallOrOpenGuide() + refreshCanInstall() +} + +function onLogout() { + closeMenu() + emit('logout') +} + +function reloadApp() { + window.location.reload() +} + +function onDocPointerDown(event: PointerEvent) { + if (!menuOpen.value) return + const root = menuRoot.value + if (root && !root.contains(event.target as Node)) { + closeMenu() + } +} + let intersectionObserver: IntersectionObserver | null = null let scrollTarget: HTMLElement | Window | null = null @@ -166,6 +266,12 @@ function setupScrollTracking() { } onMounted(async () => { + initPwaInstallPromptListener() + refreshCanInstall() + window.addEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall) + window.addEventListener('appinstalled', refreshCanInstall) + document.addEventListener('pointerdown', onDocPointerDown) + setupScrollTracking() await nextTick() setupScrollTracking() @@ -189,11 +295,10 @@ watch( onUnmounted(() => { teardownScrollTracking() + window.removeEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall) + window.removeEventListener('appinstalled', refreshCanInstall) + document.removeEventListener('pointerdown', onDocPointerDown) }) - -defineEmits<{ - logout: [] -}>() diff --git a/src/views/base/home/components/DesktopHomeTopbar.vue b/src/views/base/home/components/DesktopHomeTopbar.vue index 97e07ce..01bfbb8 100644 --- a/src/views/base/home/components/DesktopHomeTopbar.vue +++ b/src/views/base/home/components/DesktopHomeTopbar.vue @@ -13,7 +13,7 @@ - +
@@ -37,8 +46,16 @@ 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( + defineProps<{ + showLogout?: boolean + }>(), + { showLogout: true }, +) + defineEmits<{ logout: [] }>() @@ -50,10 +67,17 @@ const { clearUnreadLocally, } = useAppNotificationBadge() -function goNotifications() { +async function goNotifications() { clearUnreadLocally() + if (canRequestBrowserNotification()) { + await requestBrowserNotificationPermission() + } void router.push({ name: 'appNotifications' }) } + +function reloadApp() { + window.location.reload() +}