fix some bugs from accounting 7

This commit is contained in:
2026-07-28 21:02:44 +03:30
parent 7cd3fac28b
commit a49b20dfd4
20 changed files with 1734 additions and 93 deletions
+14
View File
@@ -72,6 +72,12 @@
/>
<VersionUpdateModal />
<PwaInstallGuideModal
v-model:open="guideOpen"
:platform="platform"
@complete="completeGuide()"
/>
</div>
</template>
@@ -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,
+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>
+375
View File
@@ -0,0 +1,375 @@
<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;
z-index: var(--app-z-modal, 5500);
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>
+132
View File
@@ -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<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) {
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,
}
}
+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
+165
View File
@@ -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<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()
}
}
/** لوگوی برند از کانفیگ؛ 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<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()
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
}
}
+15
View File
@@ -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
}
+216 -37
View File
@@ -4,39 +4,86 @@
class="app-header px-4 pb-2 top-0 wrap-fix background-color-transition"
:class="{ 'app-header--scrolled': isHeaderScrolled }"
>
<div class="flex justify-between my-2 items-center">
<div class="flex items-center gap-2" @click.stop="router.push({ name: 'user' })">
<div class="z-100000 rounded-full border-1 text-primary border-primary">
<div class="flex justify-between my-2 items-center gap-2">
<div
class="app-header__identity flex items-center gap-2 min-w-0"
@click.stop="router.push({ name: 'user' })"
>
<div class="z-100000 shrink-0 rounded-full border-1 text-primary border-primary">
<Icon icon="vuesax-linear:user" size="20" />
</div>
<div class="text-primary color-transition font-bold">
<div class="app-header__name text-primary color-transition font-bold">
{{ userStore.userInfo?.name }}
</div>
</div>
<div class="flex items-center gap-2">
<div class="app-header__actions flex items-center gap-1.5 shrink-0">
<button
v-if="notificationEnabled"
type="button"
class="mhome-header-btn mhome-header-btn--notify"
aria-label="اعلان"
@click.stop="goNotifications"
class="mhome-header-btn mhome-header-btn--icon"
aria-label="بروزرسانی"
@click="reloadApp"
>
<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">
<Icon icon="vuesax-linear:refresh-2" size="18" />
</button>
<div ref="menuRoot" class="mhome-header-menu">
<button
type="button"
class="mhome-header-btn mhome-header-btn--icon mhome-header-btn--menu"
:class="{ 'mhome-header-btn--active': menuOpen }"
aria-label="منو"
:aria-expanded="menuOpen"
@click.stop="menuOpen = !menuOpen"
>
<Icon icon="vuesax-linear:menu" size="18" />
<span
v-if="notificationEnabled && unreadCount > 0"
class="mhome-header-menu__badge"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</span>
</button>
<button
type="button"
class="mhome-header-btn mhome-header-btn--logout"
@click="$emit('logout')"
>
<Icon icon="vuesax-linear:logout" size="18" />
<span>خروج</span>
</button>
</button>
<div v-if="menuOpen" class="mhome-header-menu__panel" role="menu">
<button
v-if="notificationEnabled"
type="button"
class="mhome-header-menu__item"
role="menuitem"
@click="goNotifications"
>
<span class="mhome-header-btn__icon-wrap">
<Icon icon="vuesax-linear:notification-bing" size="16" />
<span v-if="unreadCount > 0" class="mhome-header-btn__badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</span>
<span>اعلان‌ها</span>
</button>
<button
v-if="showInstallEntry"
type="button"
class="mhome-header-menu__item"
role="menuitem"
@click="onInstall"
>
<Icon icon="vuesax-linear:mobile" size="16" />
<span>{{ canNativeInstall ? 'نصب اپلیکیشن' : 'آموزش نصب' }}</span>
</button>
<button
type="button"
class="mhome-header-menu__item mhome-header-menu__item--danger"
role="menuitem"
@click="onLogout"
>
<Icon icon="vuesax-linear:logout" size="16" />
<span>خروج</span>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -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<HTMLElement | null>(null)
const menuRoot = ref<HTMLElement | null>(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: []
}>()
</script>
<style scoped>
@@ -202,6 +307,7 @@ defineEmits<{
box-sizing: border-box;
padding-top: calc(0.5rem + var(--app-safe-top, env(safe-area-inset-top, 0px)));
background-color: transparent;
overflow: visible;
}
.app-header--scrolled {
@@ -211,6 +317,11 @@ defineEmits<{
0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.app-header__name {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
max-width: 42vw;
}
.mhome-header-btn {
display: inline-flex;
align-items: center;
@@ -227,21 +338,23 @@ defineEmits<{
cursor: pointer;
}
.mhome-header-btn--logout {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
.mhome-header-btn--icon {
width: 36px;
height: 36px;
padding: 0;
}
.mhome-header-btn--notify {
color: #0a1b37;
border-color: #e5e7eb;
background: #fff;
.mhome-header-btn--menu {
position: relative;
overflow: visible;
}
.mhome-header-btn--active {
background: #f3f4f6;
}
.mhome-header-btn__icon-wrap {
position: relative;
display: inline-flex;
@apply relative inline-flex;
}
.mhome-header-btn__badge {
@@ -259,4 +372,70 @@ defineEmits<{
line-height: 16px;
text-align: center;
}
.mhome-header-menu {
position: relative;
overflow: visible;
}
.mhome-header-menu__badge {
position: absolute;
top: -6px;
left: -6px;
z-index: 2;
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;
pointer-events: none;
box-shadow: 0 0 0 2px #fff;
}
.mhome-header-menu__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 40;
min-width: 190px;
padding: 6px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
}
.mhome-header-menu__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #374151;
font-size: 13px;
font-weight: 600;
font-family: inherit;
text-align: right;
cursor: pointer;
}
.mhome-header-menu__item:hover {
background: #f3f4f6;
}
.mhome-header-menu__item--danger {
color: #dc2626;
}
.mhome-header-menu__item--danger:hover {
background: #fef2f2;
}
</style>
@@ -13,7 +13,7 @@
<button
v-if="notificationEnabled"
type="button"
class="dhome-btn-ghost"
class="dhome-btn-ghost dhome-btn-ghost--notify"
aria-label="اعلان"
@click="goNotifications"
>
@@ -25,8 +25,17 @@
</span>
<span>اعلان</span>
</button>
<button type="button" class="dhome-btn-ghost" @click="$emit('logout')">
<BasicIcon icon="vuesax-linear:logout" size="16" />
<button type="button" class="dhome-btn-ghost" aria-label="به‌روزرسانی" @click="reloadApp">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
<span>به‌روزرسانی</span>
</button>
<button
v-if="showLogout"
type="button"
class="dhome-btn-ghost dhome-btn-ghost--danger"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="16" color="#dc2626" />
<span>خروج</span>
</button>
</div>
@@ -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()
}
</script>
<style scoped>
@@ -0,0 +1,41 @@
<template>
<DialogVant v-model:show="visible" :closeOnClickOverlay="false" :timeOut="-1">
<template v-slot:title>
<div class="flex flex-col items-center">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
</template>
<template v-slot:button>
<div class="text-center text-red flex items-center justify-center" @click="$emit('confirm')">
<a-spin :spinning="false" size="small">خروج</a-spin>
</div>
<div class="text-center" @click="visible = false">انصراف</div>
</template>
</DialogVant>
</template>
<script setup lang="ts">
import DialogVant from '@/components/DialogVant.vue'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import {computed} from "vue";
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
confirm: []
}>()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
</script>
@@ -13,7 +13,7 @@
<button
v-if="notificationEnabled"
type="button"
class="dhome-btn-ghost"
class="dhome-btn-ghost dhome-btn-ghost--notify"
aria-label="اعلان"
@click="goNotifications"
>
@@ -25,13 +25,17 @@
</span>
<span>اعلان</span>
</button>
<button type="button" class="dhome-btn-ghost" aria-label="به‌روزرسانی" @click="reloadApp">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
<span>به‌روزرسانی</span>
</button>
<button
v-if="showLogout"
type="button"
class="dhome-btn-ghost"
class="dhome-btn-ghost dhome-btn-ghost--danger"
@click="$emit('logout')"
>
<BasicIcon icon="vuesax-linear:logout" size="16" />
<BasicIcon icon="vuesax-linear:logout" size="16" color="#dc2626" />
<span>خروج</span>
</button>
</div>
@@ -42,6 +46,7 @@
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(
@@ -62,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()
}
</script>
<style scoped>
@@ -101,6 +101,15 @@
color: #111827;
background: rgba(15, 23, 42, 0.04);
}
&--danger {
color: #dc2626;
&:hover {
color: #b91c1c;
background: #fef2f2;
}
}
}
/* Alerts */
+23 -1
View File
@@ -1,5 +1,5 @@
import { computed, inject, onMounted, provide, ref, type InjectionKey } from 'vue'
import { loading } from '@/utils'
import { loading, removeToken } from '@/utils'
import {
guideTypePassword,
isValidPersian,
@@ -12,6 +12,7 @@ import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
import { useSettingsStore } from '@/store/settingsStore'
@@ -34,6 +35,7 @@ export interface UserPageContext {
isLegal: ReturnType<typeof computed<{ title: string; color: string; icon: string }>>
shareButtonLabel: ReturnType<typeof computed<string>>
menuActions: ReturnType<typeof computed<UserMenuAction[]>>
showLogoutConfirm: ReturnType<typeof ref<boolean>>
loading: typeof loading
guideTypePassword: typeof guideTypePassword
isValidPersian: typeof isValidPersian
@@ -43,6 +45,8 @@ export interface UserPageContext {
shareCode: () => void
ok: () => void
submitPassForm: () => void
requestLogout: () => void
logout: () => Promise<void>
}
export const USER_PAGE_CONTEXT_KEY: InjectionKey<UserPageContext> = Symbol('userPageContext')
@@ -60,8 +64,10 @@ export function useUserPage() {
const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>()
const userStore = useUserStore()
const walletStore = useWalletStore()
const editPassVisible = ref(false)
const editPasswordVisible = ref(false)
const showLogoutConfirm = ref(false)
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
const settingsStore = useSettingsStore()
@@ -183,6 +189,19 @@ kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
})
}
function requestLogout() {
showLogoutConfirm.value = true
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
removeToken()
showLogoutConfirm.value = false
await router.push({ name: 'login' })
window.location.reload()
}
onMounted(() => {
setForm()
if (userStore.userInfo?.is_legal === 0) {
@@ -201,6 +220,7 @@ kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
isLegal,
shareButtonLabel,
menuActions,
showLogoutConfirm,
loading,
guideTypePassword,
isValidPersian,
@@ -210,6 +230,8 @@ kh.kashkolgold.ir/login?id=${userStore.userInfo?.referral_id}`
shareCode,
ok,
submitPassForm,
requestLogout,
logout,
}
provide(USER_PAGE_CONTEXT_KEY, context)
@@ -58,6 +58,7 @@
<BasicIcon icon="vuesax-linear:edit-2" size="16" />
<span>ویرایش اطلاعات کاربری</span>
</button>
<InstallButton />
</section>
<section class="duser-surface duser-actions">
@@ -77,6 +78,16 @@
<BasicIcon class="duser-actions__chevron" icon="vuesax-linear:arrow-left" size="16" />
</button>
</div>
<button
type="button"
class="duser-actions__item duser-actions__item--logout"
@click="requestLogout"
>
<span class="duser-actions__icon duser-actions__icon--logout">
<BasicIcon icon="vuesax-linear:logout" size="18" />
</span>
<span class="duser-actions__label duser-actions__label--logout">خروج</span>
</button>
</section>
</div>
</div>
@@ -85,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'
@@ -95,6 +107,7 @@ const {
menuActions,
copyCode,
shareCode,
requestLogout,
} = useUserPageContext()
</script>
@@ -218,6 +218,16 @@
background: #f9fafb;
border-color: #e5e7eb;
}
&--logout {
margin-top: 8px;
border-color: rgba(239, 68, 68, 0.25);
&:hover {
background: #fef2f2;
border-color: rgba(239, 68, 68, 0.4);
}
}
}
&__icon {
@@ -231,6 +241,11 @@
color: #374151;
flex-shrink: 0;
overflow: visible;
&--logout {
background: #fef2f2;
color: #ef4444;
}
}
&__icon-image {
@@ -245,6 +260,10 @@
font-size: 13px;
font-weight: 600;
color: #111827;
&--logout {
color: #ef4444;
}
}
&__chevron {
+5
View File
@@ -52,6 +52,8 @@
>
<UserPasswordForm />
</BasicModal>
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout" />
</div>
</template>
@@ -60,6 +62,7 @@ import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import LogoutConfirmDialog from '@/views/base/home/components/LogoutConfirmDialog.vue'
import { useUserPage } from './composables/useUserPage'
import MobileUserView from './mobile/MobileUserView.vue'
import DesktopUserView from './desktop/DesktopUserView.vue'
@@ -73,6 +76,8 @@ const {
editPasswordVisible,
isLegal,
loading,
showLogoutConfirm,
logout,
ok,
submitPassForm,
} = useUserPage()
+6 -47
View File
@@ -63,6 +63,7 @@
<div>ویرایش اطلاعات کاربری</div>
</div>
</BasicButton>
<InstallButton />
<div class="h-40vh overflow-y-auto mt-4">
<BasicButton
class="mt-2 w-full"
@@ -175,14 +176,12 @@
icon=""
iconSize="18"
size="large"
@click="showLogoutConfirm=!showLogoutConfirm"
@click="requestLogout"
>
<div class="flex">
<Icon
:color="y > 50 ? '' : ''"
class="color-transition absolute right-8"
icon="vuesax-linear:logout"
@click="showLogoutConfirm = true"
/>
<div>خروج از حساب کاربری</div>
</div>
@@ -190,44 +189,16 @@
</div>
</div>
</div>
<DialogVant v-model:show="showLogoutConfirm" :closeOnClickOverlay="false" :timeOut="-1">
<!-- <template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80">
</template>-->
<template v-slot:title>
<div class="flex flex-col items-center">
<DotLottieVue
class="w-11rem h-11rem"
autoplay
src="https://lottie.host/129cf3a1-df84-4758-a856-58c133ae6013/R5RqclXA8G.json"
/>
<div class="p-5 text-sm">آیا از خروج خود اطمینان دارید؟</div>
</div>
</template>
<template v-slot:button>
<div class="text-center text-red flex items-center justify-center" @click="logout">
<a-spin :spinning="logoutLoading" size="small"> خروج</a-spin>
</div>
<div class="text-center" @click="showLogoutConfirm = false">انصراف</div>
</template>
</DialogVant>
</div>
</template>
<script lang="ts" setup>
import img from '@/assets/img/user_ly1.svg'
import { loading, removeToken } from '@/utils'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import router from '@/router'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import DialogVant from '@/components/DialogVant.vue'
import { useWalletStore } from '@/store/walletStore'
import InstallButton from '@/components/InstallButton.vue'
import Icon from '@/components/BasicPacks/BasicIcon.vue'
import { useWindowScroll } from '@vueuse/core/index'
import { onMounted, computed, ref } from 'vue'
import { computed } from 'vue'
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
import { useSettingsStore } from '@/store/settingsStore'
import { useUserStore } from '@/store/userStore'
@@ -235,26 +206,14 @@ import { useUserPageContext } from '../composables/useUserPage'
import MobilePageHeader from '@/layouts/components/MobilePageHeader.vue'
const userStore = useUserStore()
const walletStore = useWalletStore()
const showLogoutConfirm = ref<boolean>()
const logoutLoading = ref<boolean>(false)
const { y } = useWindowScroll()
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
const settingsStore = useSettingsStore()
const priceTriggerEnabled = computed(() =>
settingsStore.isSettingTruthy('has_page_price_trigger'),
)
const { editPassVisible, editPasswordVisible, copyCode, shareCode } = useUserPageContext()
async function logout() {
logoutLoading.value = true
userStore.userInfo = undefined
walletStore.walletInfo = undefined
removeToken()
await router.push({ name: 'login' })
window.location.reload()
}
const { editPassVisible, editPasswordVisible, copyCode, shareCode, requestLogout } =
useUserPageContext()
</script>
<style scoped>
+2
View File
@@ -107,6 +107,7 @@
<div v-else>{{ !isSMS ? 'ورود' : stepTwoVerify ? 'ورود' : 'ارسال کد' }}</div>
</div>
</BasicButton>
<InstallButton />
<BasicButton
v-if="getToken() && userStore.userInfo?.finger_print"
:disabled="loading"
@@ -139,6 +140,7 @@ import { mobileValidation, required } from '@/utils/inputRules'
// import type {UserMobile} from "@/models/login";
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import InstallButton from '@/components/InstallButton.vue'
import { authenticateWithFingerprint, getToken, loading, removeToken, setToken, URLUserType } from '@/utils'
import { reactive, ref } from 'vue'
import OtpInput from 'vue3-otp-input'