diff --git a/components.d.ts b/components.d.ts index da14038..8fdaf02 100644 --- a/components.d.ts +++ b/components.d.ts @@ -52,10 +52,13 @@ declare module 'vue' { ExtraFooter: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/extra-footer.vue')['default'] Format: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/format.vue')['default'] GoldLoading: typeof import('./src/components/GoldLoading.vue')['default'] + InstallButton: typeof import('./src/components/InstallButton.vue')['default'] ListAnim: typeof import('./src/components/ListAnim.vue')['default'] Mode: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/mode.vue')['default'] PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.vue')['default'] ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default'] + PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default'] + PwaInstallStepIllustration: typeof import('./src/components/pwa/PwaInstallStepIllustration.vue')['default'] RangePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/range-picker.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/App.vue b/src/App.vue index b933cf4..32b26c6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -85,6 +85,12 @@ /> + + diff --git a/src/components/pwa/PwaInstallGuideModal.vue b/src/components/pwa/PwaInstallGuideModal.vue new file mode 100644 index 0000000..23cb3d7 --- /dev/null +++ b/src/components/pwa/PwaInstallGuideModal.vue @@ -0,0 +1,376 @@ + + + + + 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..0434c42 --- /dev/null +++ b/src/composables/usePwaInstall.ts @@ -0,0 +1,139 @@ +import { computed, onMounted, ref } from 'vue' +import type { PwaPlatform } from '@/modules/pwa/pwaInstallGuideSteps' +import { + getDefaultPwaPlatform, + initPwaInstallPromptListener, + isPwaInstallBannerDismissed, + isStandalonePwa, + markPwaInstallBannerAsShown, + shouldOfferPwaInstall, + shouldShowPwaInstallPrompt, + triggerNativePwaInstall, +} from '@/modules/pwa/pwaInstall' +import { isTWA } from '@/utils/config' + +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) { + refreshOfferInstall() + // فقط وقتی واقعاً داخل PWA/TWA هستیم راهنما را نشان نده + if (isStandalonePwa() || isTWA()) 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) { + refreshOfferInstall() + if (isStandalonePwa() || isTWA()) 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/main.ts b/src/main.ts index 1d18c5c..4dd5a73 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 { initPwaInstallPromptListener } from '@/modules/pwa/pwaInstall' import { initSafeAreaInsets } from '@/utils/safeAreaInsets' import { bootstrapApp } from '@/app/bootstrap' @@ -15,4 +16,5 @@ export { initSafeAreaInsets() initPwaInstallCapture() +initPwaInstallPromptListener() void bootstrapApp() 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/views/base/home/components/AppHeader.vue b/src/views/base/home/components/AppHeader.vue index c2d03dc..064ef7c 100644 --- a/src/views/base/home/components/AppHeader.vue +++ b/src/views/base/home/components/AppHeader.vue @@ -10,6 +10,15 @@
+ +