Add web app install button and step-by-step PWA guide.

This commit is contained in:
2026-07-30 10:20:04 +02:00
parent e6dea15752
commit be896fe247
18 changed files with 1300 additions and 84 deletions
+3
View File
@@ -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']
+14
View File
@@ -85,6 +85,12 @@
/>
<VersionUpdateModal />
<PwaInstallGuideModal
v-model:open="guideOpen"
:platform="platform"
@complete="completeGuide()"
/>
</template>
<script lang="ts" setup>
@@ -102,6 +108,8 @@ import AppCreditLink from '@/components/AppCreditLink.vue'
import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
import { useRoute } from 'vue-router'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
const VersionUpdateModal = defineAsyncComponent(
() => import('@/modules/version/VersionUpdateModal.vue')
@@ -120,6 +128,12 @@ const config = useConfigStore()
const showSplash = ref(true)
const popVisible = ref(false)
const {
guideOpen,
platform,
completeGuide,
} = usePwaInstall()
const {
supportPickerOpen,
supportPickerAnchor,
+42
View File
@@ -0,0 +1,42 @@
<template>
<div v-if="showInstallEntry" class="mt-2 w-full">
<BasicButton class="!w-full" @click="onClick">
{{ canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب' }}
</BasicButton>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import { usePwaInstall } from '@/composables/usePwaInstall'
import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest'
import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall()
const canNativeInstall = ref(false)
function refreshCanInstall() {
canNativeInstall.value = hasNativeInstallPrompt()
}
async function onClick() {
await tryInstallOrOpenGuide()
refreshCanInstall()
}
onMounted(() => {
initPwaInstallPromptListener()
refreshCanInstall()
window.addEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.addEventListener('appinstalled', refreshCanInstall)
})
onUnmounted(() => {
window.removeEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.removeEventListener('appinstalled', refreshCanInstall)
})
</script>
+376
View File
@@ -0,0 +1,376 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import {
PWA_GUIDE_STEP_LABELS,
PWA_INSTALL_GUIDE,
type PwaPlatform,
} from '@/modules/pwa/pwaInstallGuideSteps'
import { getDefaultPwaPlatform } from '@/modules/pwa/pwaInstall'
import PwaInstallStepIllustration from '@/components/pwa/PwaInstallStepIllustration.vue'
const props = defineProps<{
open: boolean
platform?: PwaPlatform
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
complete: []
close: []
}>()
const activePlatform = ref<PwaPlatform>(props.platform ?? getDefaultPwaPlatform())
const stepIndex = ref(0)
const guide = computed(() => PWA_INSTALL_GUIDE[activePlatform.value])
const currentStep = computed(() => guide.value.steps[stepIndex.value])
const totalSteps = computed(() => guide.value.steps.length)
const isFirstStep = computed(() => stepIndex.value === 0)
const isLastStep = computed(() => stepIndex.value === totalSteps.value - 1)
const stepLabel = computed(() => PWA_GUIDE_STEP_LABELS[stepIndex.value] ?? String(stepIndex.value + 1))
watch(
() => props.open,
(visible) => {
if (!visible) return
activePlatform.value = props.platform ?? getDefaultPwaPlatform()
stepIndex.value = 0
},
)
watch(
() => props.platform,
(value) => {
if (value) activePlatform.value = value
},
)
function close() {
emit('update:open', false)
emit('close')
}
function selectPlatform(next: PwaPlatform) {
if (activePlatform.value === next) return
activePlatform.value = next
stepIndex.value = 0
}
function goNext() {
if (isLastStep.value) {
emit('complete')
close()
return
}
stepIndex.value += 1
}
function goPrev() {
if (isFirstStep.value) return
stepIndex.value -= 1
}
function completeGuide() {
emit('complete')
close()
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="pwa-install-guide"
role="dialog"
aria-modal="true"
aria-labelledby="pwa-install-guide-title"
@click.self="close"
>
<div class="pwa-install-guide__card">
<div class="pwa-install-guide__tabs">
<button
type="button"
class="pwa-install-guide__tab"
:class="{ 'pwa-install-guide__tab--active': activePlatform === 'ios' }"
@click="selectPlatform('ios')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="7" y="2" width="10" height="20" rx="2.5" stroke="currentColor" stroke-width="1.5" />
<circle cx="12" cy="18.5" r="1" fill="currentColor" />
</svg>
<span>آیفون</span>
</button>
<button
type="button"
class="pwa-install-guide__tab"
:class="{ 'pwa-install-guide__tab--active': activePlatform === 'android' }"
@click="selectPlatform('android')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4v12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M8.5 8.5 12 5l3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6 16h12v3a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-3Z" stroke="currentColor" stroke-width="1.5" />
</svg>
<span>اندروید</span>
</button>
</div>
<div class="pwa-install-guide__header">
<span class="pwa-install-guide__progress">
مرحله {{ stepLabel }} از {{ PWA_GUIDE_STEP_LABELS.length }}
</span>
<h2 id="pwa-install-guide-title" class="pwa-install-guide__title">
{{ guide.title }}
</h2>
</div>
<div class="pwa-install-guide__content">
<span class="pwa-install-guide__badge">{{ stepLabel }}</span>
<PwaInstallStepIllustration :type="currentStep.illustration" />
<p class="pwa-install-guide__text">{{ currentStep.text }}</p>
<p v-if="currentStep.hint" class="pwa-install-guide__hint">{{ currentStep.hint }}</p>
</div>
<div class="pwa-install-guide__nav">
<template v-if="isLastStep">
<BasicButton class="pwa-install-guide__primary" type="primary" @click="completeGuide">
متوجه شدم
</BasicButton>
</template>
<template v-else>
<button
type="button"
class="pwa-install-guide__nav-btn"
:disabled="isFirstStep"
aria-label="مرحله قبل"
@click="goPrev"
>
‹
</button>
<div class="pwa-install-guide__dots" aria-hidden="true">
<span
v-for="(_, index) in totalSteps"
:key="index"
class="pwa-install-guide__dot"
:class="{ 'pwa-install-guide__dot--active': index === stepIndex }"
/>
</div>
<button
type="button"
class="pwa-install-guide__nav-btn"
aria-label="مرحله بعد"
@click="goNext"
>
›
</button>
</template>
</div>
<template v-if="!isLastStep">
<BasicButton class="pwa-install-guide__primary" type="primary" @click="goNext">
بعدی
<span class="pwa-install-guide__primary-chevron">›</span>
</BasicButton>
<button type="button" class="pwa-install-guide__close" @click="close">
بستن
</button>
</template>
</div>
</div>
</Teleport>
</template>
<style scoped lang="less">
.pwa-install-guide {
position: fixed;
inset: 0;
/* بالاتر از نوار اعتبار (۱۰۰۰۰۰۰) و FAB پشتیبانی */
z-index: 1000002;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.45);
}
.pwa-install-guide__card {
width: min(100%, 360px);
background: #fff;
border-radius: 24px;
padding: 14px 16px 18px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
}
.pwa-install-guide__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: #f1f5f9;
}
.pwa-install-guide__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 10px;
padding: 10px 8px;
font-size: 13px;
font-weight: 600;
color: #64748b;
background: transparent;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
&--active {
color: #0f172a;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
}
.pwa-install-guide__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 2px 10px;
}
.pwa-install-guide__progress {
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
}
.pwa-install-guide__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
text-align: right;
}
.pwa-install-guide__content {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #f8fafc;
padding: 14px 14px 16px;
min-height: 280px;
}
.pwa-install-guide__badge {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 13px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pwa-install-guide__text {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.8;
color: #334155;
text-align: center;
}
.pwa-install-guide__hint {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.7;
color: #64748b;
text-align: center;
}
.pwa-install-guide__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 14px 0 12px;
.pwa-install-guide__primary {
width: 100%;
margin: 0;
}
}
.pwa-install-guide__nav-btn {
width: 34px;
height: 34px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 20px;
line-height: 1;
cursor: pointer;
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.pwa-install-guide__dots {
display: flex;
align-items: center;
gap: 6px;
}
.pwa-install-guide__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #cbd5e1;
transition: width 0.15s ease, background 0.15s ease;
&--active {
width: 22px;
background: #2563eb;
}
}
.pwa-install-guide__primary {
width: 100%;
height: 44px;
border-radius: 14px !important;
font-weight: 700;
}
.pwa-install-guide__primary-chevron {
margin-right: 4px;
font-size: 16px;
}
.pwa-install-guide__close {
display: block;
width: 100%;
margin-top: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 13px;
cursor: pointer;
}
</style>
@@ -0,0 +1,413 @@
<script setup lang="ts">
import type { PwaGuideIllustration } from '@/modules/pwa/pwaInstallGuideSteps'
import shareAppleIcon from '@/assets/img/share-apple.png'
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
defineProps<{
type: PwaGuideIllustration
}>()
</script>
<template>
<div
class="pwa-step-illustration"
:class="{
'pwa-step-illustration--android-menu': type === 'android-menu',
'pwa-step-illustration--ios-safari': type === 'ios-safari',
}"
>
<div
class="pwa-step-illustration__phone"
:class="{
'pwa-step-illustration__phone--android-menu': type === 'android-menu',
'pwa-step-illustration__phone--ios-safari': type === 'ios-safari',
}"
>
<div
class="pwa-step-illustration__screen"
:class="{
'pwa-step-illustration__screen--android-menu': type === 'android-menu',
'pwa-step-illustration__screen--ios-safari': type === 'ios-safari',
}"
>
<template v-if="type === 'android-chrome'">
<div class="pwa-step-illustration__browser-bar">
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--red" />
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--yellow" />
<span class="pwa-step-illustration__dot pwa-step-illustration__dot--green" />
</div>
<div class="pwa-step-illustration__chrome-body" />
<div class="pwa-step-illustration__chrome-label">باز در Chrome</div>
</template>
<template v-else-if="type === 'android-menu'">
<div class="android-menu-scene">
<div class="android-menu-scene__toolbar">
<div class="android-menu-scene__url-bar" />
<div class="android-menu-scene__menu-target" aria-hidden="true">
<span class="android-menu-scene__menu-dots">⋮</span>
</div>
</div>
<div class="android-menu-scene__viewport">
<div class="android-menu-scene__dropdown">
<div class="android-menu-scene__dropdown-line" />
<div class="android-menu-scene__dropdown-line android-menu-scene__dropdown-line--short" />
<div class="android-menu-scene__dropdown-install">
Install app
</div>
</div>
</div>
</div>
</template>
<template v-else-if="type === 'android-install'">
<div class="pwa-step-illustration__install-btn">Install</div>
</template>
<template v-else-if="type === 'android-confirm'">
<div class="pwa-step-illustration__app-icon" />
<div class="pwa-step-illustration__home-label">روی صفحه اصلی</div>
</template>
<template v-else-if="type === 'ios-safari'">
<div class="ios-safari-scene">
<div class="ios-safari-scene__toolbar">
<span class="ios-safari-scene__toolbar-label">Safari</span>
</div>
<div class="ios-safari-scene__viewport">
<div class="ios-safari-scene__placeholder" />
</div>
<div class="ios-safari-scene__caption">باز در Safari</div>
</div>
</template>
<template v-else-if="type === 'ios-share'">
<div class="pwa-step-illustration__share-wrap">
<img :src="shareAppleIcon" alt="" class="pwa-step-illustration__share-icon" />
</div>
</template>
<template v-else-if="type === 'ios-add-home'">
<div class="pwa-step-illustration__sheet">
<div class="pwa-step-illustration__sheet-item">Share</div>
<div class="pwa-step-illustration__sheet-item pwa-step-illustration__sheet-item--active">
<img :src="addToHomeScreenIcon" alt="" class="pwa-step-illustration__sheet-icon" />
Add to Home Screen
</div>
</div>
</template>
<template v-else-if="type === 'ios-confirm'">
<div class="pwa-step-illustration__app-icon" />
<div class="pwa-step-illustration__home-label">روی صفحه اصلی</div>
</template>
</div>
</div>
</div>
</template>
<style scoped lang="less">
.pwa-step-illustration {
display: flex;
justify-content: center;
padding: 8px 0 4px;
}
.pwa-step-illustration__phone {
width: 148px;
height: 220px;
border: 2px solid #cbd5e1;
border-radius: 22px;
padding: 10px 8px;
background: #fff;
}
.pwa-step-illustration__screen {
position: relative;
width: 100%;
height: 100%;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.pwa-step-illustration__browser-bar {
position: absolute;
top: 10px;
left: 12px;
display: flex;
gap: 4px;
}
.pwa-step-illustration__dot {
width: 7px;
height: 7px;
border-radius: 999px;
&--red { background: #ef4444; }
&--yellow { background: #f59e0b; }
&--green { background: #22c55e; }
}
.pwa-step-illustration__chrome-body {
width: 78%;
height: 42%;
border-radius: 8px;
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
margin-top: 18px;
}
.pwa-step-illustration__chrome-label,
.pwa-step-illustration__home-label {
font-size: 10px;
color: #64748b;
}
.pwa-step-illustration--android-menu,
.pwa-step-illustration--ios-safari {
padding: 12px 0 0;
}
.pwa-step-illustration__phone--android-menu,
.pwa-step-illustration__phone--ios-safari {
width: 132px;
height: 208px;
padding: 0;
border-width: 1.5px;
border-color: #cbd5e1;
border-radius: 20px;
box-shadow: none;
}
.pwa-step-illustration__screen--android-menu,
.pwa-step-illustration__screen--ios-safari {
border: none;
border-radius: 18px;
background: #fff;
display: block;
overflow: hidden;
}
.ios-safari-scene {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.ios-safari-scene__toolbar {
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 8px 10px 6px;
border-bottom: 1px solid #eef2f7;
background: #fff;
}
.ios-safari-scene__toolbar-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
background: #eef2f7;
color: #64748b;
font-size: 9px;
font-weight: 600;
}
.ios-safari-scene__viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 14px 12px 8px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
.ios-safari-scene__placeholder {
width: 78%;
height: 58%;
border-radius: 12px;
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.08);
}
.ios-safari-scene__caption {
padding: 0 10px 10px;
text-align: center;
font-size: 9px;
color: #64748b;
background: #fff;
}
.android-menu-scene {
position: relative;
width: 100%;
height: 100%;
}
.android-menu-scene__toolbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 10px 8px 8px;
border-bottom: 1px solid #eef2f7;
}
.android-menu-scene__url-bar {
flex: 1;
height: 8px;
border-radius: 999px;
background: #e8edf5;
}
.android-menu-scene__menu-target {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 1.5px dashed #2563eb;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(37, 99, 235, 0.04);
}
.android-menu-scene__menu-dots {
font-size: 14px;
line-height: 1;
font-weight: 700;
color: #334155;
transform: translateY(-1px);
}
.android-menu-scene__viewport {
position: relative;
height: calc(100% - 41px);
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
.android-menu-scene__dropdown {
position: absolute;
top: 6px;
right: 8px;
width: 78px;
padding: 8px 8px 10px;
border-radius: 10px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.android-menu-scene__dropdown-line {
height: 4px;
border-radius: 999px;
background: #e2e8f0;
margin-bottom: 6px;
&--short {
width: 72%;
margin-bottom: 8px;
}
}
.android-menu-scene__dropdown-install {
display: flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 4px 6px;
border-radius: 6px;
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
font-size: 8px;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
.pwa-step-illustration__install-btn {
font-size: 11px;
font-weight: 600;
color: #2563eb;
border: 1px solid #2563eb;
border-radius: 8px;
padding: 6px 14px;
background: #eff6ff;
}
.pwa-step-illustration__app-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.pwa-step-illustration__share-wrap {
position: absolute;
bottom: 14px;
width: 34px;
height: 34px;
border: 2px dashed #2563eb;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.pwa-step-illustration__share-icon {
width: 18px;
height: 18px;
object-fit: contain;
}
.pwa-step-illustration__sheet {
width: 88%;
border-radius: 10px;
background: #fff;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.pwa-step-illustration__sheet-item {
padding: 8px 10px;
font-size: 9px;
color: #64748b;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
gap: 6px;
&--active {
color: #2563eb;
border: 1px solid #2563eb;
border-radius: 8px;
margin: 6px;
font-weight: 600;
}
}
.pwa-step-illustration__sheet-icon {
width: 14px;
height: 14px;
object-fit: contain;
}
</style>
+139
View File
@@ -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<PwaPlatform>(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,
}
}
+2
View File
@@ -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()
+129
View File
@@ -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<void>
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
}
+71
View File
@@ -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<PwaPlatform, PwaGuidePlatformConfig> = {
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
@@ -10,6 +10,15 @@
</div>
<div class="flex items-center gap-2">
<button
v-if="showInstallEntry"
type="button"
class="mhome-header-btn mhome-header-btn--install"
:aria-label="canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب'"
@click.stop="onInstall"
>
<Icon icon="vuesax-linear:mobile" size="18" />
</button>
<button
v-if="notificationEnabled"
type="button"
@@ -52,9 +61,16 @@ import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { getProjectMobileNavLogo } from '@/utils/config'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
import { usePwaInstall } from '@/composables/usePwaInstall'
import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest'
import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
const headerRef = ref<HTMLElement | null>(null)
const isHeaderScrolled = ref(false)
const canNativeInstall = ref(false)
const route = useRoute()
const projectLogo = computed(() => getProjectMobileNavLogo())
@@ -63,6 +79,16 @@ const {
unreadCount,
clearUnreadLocally,
} = useAppNotificationBadge()
const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall()
function refreshCanInstall() {
canNativeInstall.value = hasNativeInstallPrompt()
}
async function onInstall() {
await tryInstallOrOpenGuide()
refreshCanInstall()
}
async function goNotifications() {
clearUnreadLocally()
@@ -172,6 +198,10 @@ function setupScrollTracking() {
}
onMounted(async () => {
initPwaInstallPromptListener()
refreshCanInstall()
window.addEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.addEventListener('appinstalled', refreshCanInstall)
setupScrollTracking()
await nextTick()
setupScrollTracking()
@@ -195,6 +225,8 @@ watch(
onUnmounted(() => {
teardownScrollTracking()
window.removeEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.removeEventListener('appinstalled', refreshCanInstall)
})
defineEmits<{
@@ -10,6 +10,16 @@
<BasicIcon icon="vuesax-linear:user" size="16" />
<span>حساب کاربری</span>
</button>
<button
v-if="showInstallEntry"
type="button"
class="dhome-btn-ghost"
:aria-label="canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب'"
@click="onInstall"
>
<BasicIcon icon="vuesax-linear:mobile" size="16" />
<span>{{ canNativeInstall ? 'نصب وب اپ' : 'آموزش نصب' }}</span>
</button>
<button
v-if="notificationEnabled"
type="button"
@@ -42,10 +52,17 @@
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
import { canRequestBrowserNotification, requestBrowserNotificationPermission } from '@/utils/browserNotification'
import { usePwaInstall } from '@/composables/usePwaInstall'
import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest'
import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
import router from '@/router'
withDefaults(
@@ -60,11 +77,22 @@ defineEmits<{
}>()
const userStore = useUserStore()
const canNativeInstall = ref(false)
const {
enabled: notificationEnabled,
unreadCount,
clearUnreadLocally,
} = useAppNotificationBadge()
const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall()
function refreshCanInstall() {
canNativeInstall.value = hasNativeInstallPrompt()
}
async function onInstall() {
await tryInstallOrOpenGuide()
refreshCanInstall()
}
async function goNotifications() {
clearUnreadLocally()
@@ -77,6 +105,18 @@ async function goNotifications() {
function reloadApp() {
window.location.reload()
}
onMounted(() => {
initPwaInstallPromptListener()
refreshCanInstall()
window.addEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.addEventListener('appinstalled', refreshCanInstall)
})
onUnmounted(() => {
window.removeEventListener(PWA_MANIFEST_READY_EVENT, refreshCanInstall)
window.removeEventListener('appinstalled', refreshCanInstall)
})
</script>
<style scoped>
@@ -58,6 +58,7 @@
<BasicIcon icon="vuesax-linear:edit-2" size="16" />
<span>ویرایش اطلاعات کاربری</span>
</button>
<InstallButton class="duser-profile__install" />
</section>
<section class="duser-surface duser-actions">
@@ -95,6 +96,7 @@
<script lang="ts" setup>
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import InstallButton from '@/components/InstallButton.vue'
import router from '@/router'
import { useUserPageContext } from '../composables/useUserPage'
@@ -184,6 +184,11 @@
}
}
}
&__install {
width: 100%;
margin-top: 10px;
}
}
.duser-actions {
@@ -60,6 +60,7 @@
<div>ویرایش اطلاعات کاربری</div>
</div>
</BasicButton>
<InstallButton class="!mt-1.5" />
<div class="w-full mt-4">
<BasicButton
v-for="action in menuActions"
@@ -91,6 +92,7 @@
import img from '@/assets/img/user_ly1.svg'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import InstallButton from '@/components/InstallButton.vue'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
import { useUserPageContext } from '../composables/useUserPage'
+16 -71
View File
@@ -181,13 +181,17 @@
ورود با اثر انگشت
</BasicButton>
<div v-if="!isTWA() && !!setting.getSettingByKey('apk_download_link')" class="mt-1">
<div v-if="!isTWA()" class="mt-1 flex flex-col gap-2">
<a
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
</a>
<button type="button" class="dauth-link-btn dauth-link-btn--primary w-full" @click="installWebApp">
نصب نسخه وب اپ
</button>
</div>
</div>
@@ -225,13 +229,17 @@
</div>
</BasicButton>
<div v-if="!isTWA() && !!setting.getSettingByKey('apk_download_link')" class=" mt-1">
<div v-if="!isTWA()" class="mt-1 flex flex-col gap-2">
<a
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
</a>
<BasicButton type="primary" class="w-full py-2" html-type="button" @click.prevent="installWebApp">
<div>نصب نسخه وب اپ</div>
</BasicButton>
</div>
<div
v-if="isRegister"
@@ -250,60 +258,6 @@
href="https://abshode.peransagold.com/">ورود
همکار</a>
</div>-->
<BasicActionSheet
v-model:show="showPWAInstallGuide"
:showButton="false"
@close="showPWAInstallGuide = false"
>
<div class="px-4 py-2">
<h2 class="text-md font-bold text-center mb-6 text-gray-800">
وب اپلیکیشن گروسی را به صفحه اصلی خود اضافه کنید.
</h2>
<div class="space-y-6 mb-6">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center p-2">
<img :src="shareAppleIcon" alt="Share" class="w-full h-full object-contain"/>
</div>
<div class="flex-1">
<p class="text-sm text-gray-700 leading-relaxed">
روی دکمه Share در نوار پایین کلیک کنید.
</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center p-2">
<img :src="addToHomeScreenIcon" alt="Add to Home Screen" class="w-full h-full object-contain"/>
</div>
<div class="flex-1">
<p class="text-sm text-gray-700 leading-relaxed">
در منوی باز شده در قسمت پایین گزینه Add to Home Screen را انتخاب کنید.
</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<span class="text-blue-600 font-bold text-lg">Add</span>
</div>
<div class="flex-1">
<p class="text-sm text-gray-700 leading-relaxed">
در مرحله آخر در قسمت بالا روی Add کلیک کنید.
</p>
</div>
</div>
</div>
<BasicButton
class="w-full buttonLogin mt-4"
type="primary"
@click="proceedWithInstall"
>
متوجه شدم
</BasicButton>
</div>
</BasicActionSheet>
</div>
</template>
@@ -337,12 +291,10 @@ import {
hasCustomLoginLogoLayout,
isTWA
} from "@/utils/config";
import BasicActionSheet from "@/components/BasicPacks/BasicActionSheet.vue";
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import {getConfig} from "@/api/config";
import shareAppleIcon from '@/assets/img/share-apple.png'
import addToHomeScreenIcon from '@/assets/img/Add-to-homeScreen.png'
import { usePwaInstall } from '@/composables/usePwaInstall'
import {getProducts} from "@/api/products";
import {getSettings} from "@/api/configuration";
import { LOGIN_PAGE_CONTEXT_KEY } from '../composables/useLoginPage'
@@ -498,14 +450,7 @@ async function goToAboutUs(){
await router.push('/aboutUs')
}
const showPWAInstallGuide = ref(false)
async function installWebApp() {
showPWAInstallGuide.value = true
}
async function proceedWithInstall() {
showPWAInstallGuide.value = false
}
const { tryInstallOrOpenGuide: installWebApp } = usePwaInstall()
</script>
<style lang="less">
+4 -4
View File
@@ -18,6 +18,7 @@ import { setTruth } from '@/app/authBoot'
import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollReset'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import { usePwaInstall } from '@/composables/usePwaInstall'
export function useLoginForm() {
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
@@ -29,7 +30,7 @@ export function useLoginForm() {
const refOtpInput = ref<InstanceType<typeof SmsOtpField> | null>(null)
const isSMS = ref(true)
const isRegister = ref<boolean | undefined>()
const showPWAInstallGuide = ref(false)
const { tryInstallOrOpenGuide, completeGuide } = usePwaInstall()
const userFormState = reactive<Login>({
provider: 'customer',
@@ -137,11 +138,11 @@ export function useLoginForm() {
}
function installWebApp() {
showPWAInstallGuide.value = true
void tryInstallOrOpenGuide()
}
function proceedWithInstall() {
showPWAInstallGuide.value = false
void completeGuide()
}
const submitLabel = computed(() => {
@@ -165,7 +166,6 @@ export function useLoginForm() {
refOtpInput,
isSMS,
isRegister,
showPWAInstallGuide,
userFormState,
loading,
submitLabel,
@@ -6,7 +6,6 @@ import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import SmsOtpField from '@/components/SmsOtpField.vue'
import { useConfigStore } from '@/store/configStore'
import { useLoginPageContext } from '../../composables/useLoginPage'
import DesktopPwaGuideModal from './DesktopPwaGuideModal.vue'
import router from '@/router'
import {isTWA} from "@/utils/config";
import {useSettingsStore} from "@/store/settingsStore";
@@ -24,7 +23,6 @@ const {
refOtpInput,
isSMS,
isRegister,
showPWAInstallGuide,
userFormState,
loading,
submitLabel,
@@ -37,7 +35,6 @@ const {
phoneVerification,
editPhoneNumber,
installWebApp,
proceedWithInstall,
} = useLoginPageContext().loginForm
function goToEducate() {
@@ -166,13 +163,17 @@ function goToAboutUs() {
ورود با اثر انگشت
</BasicButton>
<div v-if="!isTWA() && !!setting.getSettingByKey('apk_download_link')" class="dauth-actions-row ">
<div v-if="!isTWA()" class="dauth-actions-row flex flex-col gap-2">
<a
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
v-if="!!setting.getSettingByKey('apk_download_link')"
class="dauth-link-btn dauth-link-btn--primary !hover:text-primary "
:href="setting.getSettingByKey('apk_download_link')"
>
دانلود اپ اندروید
</a>
<BasicButton html-type="button" type="primary" class="w-full" @click.prevent="installWebApp">
نصب نسخه وب اپ
</BasicButton>
</div>
</div>
@@ -184,7 +185,5 @@ function goToAboutUs() {
</button>
</div>
</a-form>
<DesktopPwaGuideModal v-model:show="showPWAInstallGuide" @confirm="proceedWithInstall" />
</div>
</template>
@@ -422,8 +422,10 @@
color: #ffffff;
&:hover {
opacity: 0.92;
background: @App-primary-color;
border-color: @App-primary-color;
color: #ffffff;
opacity: 0.92;
}
}
}