Port modular features into boursecu and fix desktop login UX.

Add support chat, piggy funds, PWA banner, notification/SSE sync, and messenger legacy guards while keeping boursecu theme; also balance login panels and unblock login navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 20:14:46 +02:00
co-authored by Cursor
parent be896fe247
commit 42aef88064
55 changed files with 7488 additions and 434 deletions
+1 -9
View File
@@ -15,17 +15,9 @@ declare module 'vue' {
AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default'] AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
BaseDatePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue')['default'] BaseDatePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/BaseDatePicker.vue')['default']
Basic: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/basic.vue')['default'] Basic: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/basic.vue')['default']
BasicActionSheet: typeof import('./src/components/BasicPacks/BasicActionSheet.vue')['default'] BasicActionSheet: typeof import('./src/components/BasicPacks/BasicActionSheet.vue')['default']
@@ -57,6 +49,7 @@ declare module 'vue' {
Mode: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/mode.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'] PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.vue')['default']
ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default'] ProfileAvatarUpload: typeof import('./src/components/ProfileAvatarUpload.vue')['default']
PwaInstallBanner: typeof import('./src/components/pwa/PwaInstallBanner.vue')['default']
PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default'] PwaInstallGuideModal: typeof import('./src/components/pwa/PwaInstallGuideModal.vue')['default']
PwaInstallStepIllustration: typeof import('./src/components/pwa/PwaInstallStepIllustration.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'] RangePicker: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/range-picker.vue')['default']
@@ -76,6 +69,5 @@ declare module 'vue' {
TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default'] TransactionSkeleton: typeof import('./src/components/TransactionSkeleton.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet'] VanActionSheet: typeof import('vant/es')['ActionSheet']
VanDialog: typeof import('vant/es')['Dialog'] VanDialog: typeof import('vant/es')['Dialog']
VanField: typeof import('vant/es')['Field']
} }
} }
+8 -1
View File
@@ -35,7 +35,8 @@
"vue3-apexcharts": "^1.4.4", "vue3-apexcharts": "^1.4.4",
"vue3-lazyload": "^0.3.8", "vue3-lazyload": "^0.3.8",
"vue3-odometer": "^0.1.3", "vue3-odometer": "^0.1.3",
"vue3-otp-input": "^0.4.1" "vue3-otp-input": "^0.4.1",
"wavesurfer.js": "^7.12.11"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
@@ -9121,6 +9122,12 @@
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
}, },
"node_modules/wavesurfer.js": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.11.tgz",
"integrity": "sha512-Sx0yZIz7jRJ9J9p7UL+pl9y0UQBB6UN/XNo+R7Gy4tCR5xZI9jMyA0dnt1R8TMVSQ4LZ10SeB2HJYQ+dV2dPSA==",
"license": "BSD-3-Clause"
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+2 -1
View File
@@ -39,7 +39,8 @@
"vue3-apexcharts": "^1.4.4", "vue3-apexcharts": "^1.4.4",
"vue3-lazyload": "^0.3.8", "vue3-lazyload": "^0.3.8",
"vue3-odometer": "^0.1.3", "vue3-odometer": "^0.1.3",
"vue3-otp-input": "^0.4.1" "vue3-otp-input": "^0.4.1",
"wavesurfer.js": "^7.12.11"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
+120 -2
View File
@@ -32,7 +32,7 @@
</div> </div>
</AConfigProvider> </AConfigProvider>
<div <div
v-if="showSupportButton && !showSplash" v-if="showSupportButton && !showSplash && route.name !== 'support'"
id="support-button" id="support-button"
ref="supportButtonEl" ref="supportButtonEl"
class="fixed z-[5100] cursor-grab select-none rounded-full" class="fixed z-[5100] cursor-grab select-none rounded-full"
@@ -48,7 +48,7 @@
@pointercancel="onSupportPointerCancel" @pointercancel="onSupportPointerCancel"
> >
<div <div
class="inline-flex items-center gap-2 rounded-full bg-white border border-solid border-gray-200 shadow-[0_6px_20px_rgba(15,23,42,0.14)] px-3.5 py-[9px] text-slate-800 text-[13px] font-600 leading-none transition-all duration-200" class="relative inline-flex items-center gap-2 rounded-full bg-white border border-solid border-gray-200 shadow-[0_6px_20px_rgba(15,23,42,0.14)] px-3.5 py-[9px] text-slate-800 text-[13px] font-600 leading-none transition-all duration-200"
:class="{ :class="{
'shadow-[0_10px_28px_rgba(15,23,42,0.22)]': supportDragging, 'shadow-[0_10px_28px_rgba(15,23,42,0.22)]': supportDragging,
'scale-[0.94] shadow-[0_4px_14px_rgba(15,23,42,0.12)]': supportPickerOpen && !supportDragging, 'scale-[0.94] shadow-[0_4px_14px_rgba(15,23,42,0.12)]': supportPickerOpen && !supportDragging,
@@ -61,6 +61,12 @@
class="shrink-0" class="shrink-0"
/> />
<span class="whitespace-nowrap">پشتیبانی</span> <span class="whitespace-nowrap">پشتیبانی</span>
<span
v-if="supportChatUnreadCount > 0"
class="support-fab-badge"
>
{{ supportChatUnreadCount > 99 ? '99+' : supportChatUnreadCount }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -86,6 +92,13 @@
<VersionUpdateModal /> <VersionUpdateModal />
<PwaInstallBanner
v-if="!showSplash"
:visible="bannerVisible && !guideOpen"
@install="openGuide()"
@dismiss="dismissBanner()"
/>
<PwaInstallGuideModal <PwaInstallGuideModal
v-model:open="guideOpen" v-model:open="guideOpen"
:platform="platform" :platform="platform"
@@ -109,7 +122,20 @@ import SupportMessengerPicker from '@/components/SupportMessengerPicker.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset' import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
import { usePwaInstall } from '@/composables/usePwaInstall' import { usePwaInstall } from '@/composables/usePwaInstall'
import PwaInstallBanner from '@/components/pwa/PwaInstallBanner.vue'
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue' import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
import { useUserStore } from '@/store/userStore'
import { useSettingsStore } from '@/store/settingsStore'
import {
connectChatIfExists,
disconnectChat,
isSettingFlagOn,
unreadSupportCount,
useChatService,
} from '@/utils/chat'
import { getToken } from '@/utils'
import { message } from 'ant-design-vue'
import { showBrowserNotification } from '@/utils/browserNotification'
const VersionUpdateModal = defineAsyncComponent( const VersionUpdateModal = defineAsyncComponent(
() => import('@/modules/version/VersionUpdateModal.vue') () => import('@/modules/version/VersionUpdateModal.vue')
@@ -130,8 +156,12 @@ const popVisible = ref(false)
const { const {
guideOpen, guideOpen,
bannerVisible,
platform, platform,
openGuide,
dismissBanner,
completeGuide, completeGuide,
showBanner,
} = usePwaInstall() } = usePwaInstall()
const { const {
@@ -139,6 +169,7 @@ const {
supportPickerAnchor, supportPickerAnchor,
supportMessengerEntries, supportMessengerEntries,
messengerMeta, messengerMeta,
isChatEnabled,
hasMessengerLink: showSupportButton, hasMessengerLink: showSupportButton,
openMessengerEntry, openMessengerEntry,
openSupportMessenger, openSupportMessenger,
@@ -147,6 +178,10 @@ const {
const supportButtonEl = ref<HTMLElement | null>(null) const supportButtonEl = ref<HTMLElement | null>(null)
const pickerAnchorEl = computed(() => supportPickerAnchor.value ?? supportButtonEl.value) const pickerAnchorEl = computed(() => supportPickerAnchor.value ?? supportButtonEl.value)
const supportChatUnreadCount = computed(() =>
isChatEnabled.value ? Number(unreadSupportCount.value) || 0 : 0,
)
const supportIcon = computed(() => messengerMeta.value.icon) const supportIcon = computed(() => messengerMeta.value.icon)
const supportAccentColor = computed(() => { const supportAccentColor = computed(() => {
@@ -226,6 +261,7 @@ watch(
void import('@/utils/safeAreaInsets').then(({ syncSafeAreaInsets }) => { void import('@/utils/safeAreaInsets').then(({ syncSafeAreaInsets }) => {
syncSafeAreaInsets() syncSafeAreaInsets()
}) })
showBanner()
}, },
{ immediate: true }, { immediate: true },
) )
@@ -241,6 +277,64 @@ watch(
{ flush: 'post' }, { flush: 'post' },
) )
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { latestIncomingNotification } = useChatService()
let lastConnectedChatSessionKey = ''
let lastSupportToastAt = 0
watch(
() => [
userStore.userInfo?.chat_room_id,
userStore.userInfo?.chat_user_id,
settingsStore.getSettingByKey('chat_api_key'),
settingsStore.getSettingByKey('show_to_users_chat'),
settingsStore.getSettingByKey('has_chat'),
getToken(),
],
async ([roomId, chatUserId, chatApiKey, chatAdminFlag, chatCustomerFlag, token]) => {
const chatOn =
isSettingFlagOn(chatAdminFlag) && isSettingFlagOn(chatCustomerFlag)
if (!token || !chatOn || !chatApiKey || !roomId || !chatUserId) {
lastConnectedChatSessionKey = ''
disconnectChat()
return
}
const sessionKey = `${roomId}:${chatUserId}:${chatApiKey}`
if (lastConnectedChatSessionKey === sessionKey) return
try {
await connectChatIfExists()
lastConnectedChatSessionKey = sessionKey
} catch (error) {
lastConnectedChatSessionKey = ''
console.error('connectChatIfExists failed:', error)
}
},
{ immediate: true },
)
watch(latestIncomingNotification, (payload) => {
if (!payload) return
if (payload.at === lastSupportToastAt) return
lastSupportToastAt = payload.at
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
message.info({
content: `پیام جدید پشتیبانی: ${payload.body}`,
duration: 3,
})
}
void showBrowserNotification({
title: payload.title || 'پشتیبانی',
body: payload.body || 'پیام جدید',
tag: 'support-chat-message',
url: '/support',
})
})
</script> </script>
<style lang="less"> <style lang="less">
@@ -263,6 +357,7 @@ watch(
/* بخشی از کارت کیف‌پول زیر نوار «افزودن کارت جدید» دیده می‌شود */ /* بخشی از کارت کیف‌پول زیر نوار «افزودن کارت جدید» دیده می‌شود */
--wallet-card-dock-overlap: 52px; --wallet-card-dock-overlap: 52px;
--app-z-nav: 5000; --app-z-nav: 5000;
--app-z-toast: 5050;
--app-z-wallet-dock: 4490; --app-z-wallet-dock: 4490;
--app-z-wallet-add-bar: 4510; --app-z-wallet-add-bar: 4510;
--app-z-action-sheet: 5200; --app-z-action-sheet: 5200;
@@ -273,6 +368,11 @@ watch(
--app-z-support-fab-open: 5615; --app-z-support-fab-open: 5615;
} }
/* نوار اعلان Ant — پیش‌فرض z-index:1010 زیر هدر fixed می‌رود */
.ant-notification {
z-index: var(--app-z-toast, 5050) !important;
}
.app-main { .app-main {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
@@ -425,6 +525,24 @@ html.project-boursecu {
/* جای draggable از fabStyle می‌آید */ /* جای draggable از fabStyle می‌آید */
} }
.support-fab-badge {
position: absolute;
top: -6px;
left: -4px;
z-index: 2;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px #fff;
}
@media screen and (min-width: 426px) and (max-width: 767.98px) { @media screen and (min-width: 426px) and (max-width: 767.98px) {
.desktopConfig { .desktopConfig {
width: 426px; width: 426px;
+6
View File
@@ -0,0 +1,6 @@
import useAxios from '@/utils/useAxios'
import type { User } from '@/models/login'
export const startChat = async (): Promise<{ message: string; user: User }> => {
return await useAxios.get<{ message: string; user: User }>(`/customer/start-chat`)
}
+5 -1
View File
@@ -6,6 +6,7 @@ import { useFundUserStore } from '@/store/fundStore'
import type { NavigationGuardNext } from 'vue-router' import type { NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '@/store/useAuthStore' import { useAuthStore } from '@/store/useAuthStore'
import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset' import { recoverMobileViewportAfterAuth } from '@/layouts/composables/useMobileScrollReset'
import { getFingerPosition, setFingerPosition } from '@/app/authBoot'
const PUBLIC_ROUTE_NAMES = ['login', '404', 'educate', 'aboutUs'] as const const PUBLIC_ROUTE_NAMES = ['login', '404', 'educate', 'aboutUs'] as const
@@ -108,7 +109,10 @@ export function setupRouterGuards() {
if (!isApple && userStore.userInfo?.finger_print) { if (!isApple && userStore.userInfo?.finger_print) {
if (!fingerprintChecked) { if (!fingerprintChecked) {
fingerprintChecked = true fingerprintChecked = true
setFingerPosition(await authenticateWithFingerprint()) // اگر همین الان با پسورد/OTP وارد شده (setTruth)، دوباره WebAuthn نخواه
if (!getFingerPosition()) {
setFingerPosition(await authenticateWithFingerprint())
}
} }
if (!getFingerPosition()) { if (!getFingerPosition()) {
+10 -3
View File
@@ -32,9 +32,15 @@
:aria-label="entry.label" :aria-label="entry.label"
@click.stop="select(entry)" @click.stop="select(entry)"
> >
<span class="inline-flex items-center gap-2 h-11 rounded-full bg-white shadow-md py-1 pl-3 pr-1 w-full"> <span class="support-picker-item-inner inline-flex items-center gap-2 min-h-[38px] rounded-full bg-white shadow-md py-1 pl-3 pr-1 w-full">
<span class="block w-[30px] h-[30px] shrink-0"> <span class="block w-[30px] h-[30px] shrink-0 flex items-center justify-center">
<SupportMessengerBrandIcon :messenger="entry.messenger" /> <BasicIcon
v-if="entry.messenger === 'chat'"
:icon="entry.icon || 'vuesax-linear:messages-2'"
size="22"
:color="entry.accent || '#16a34a'"
/>
<SupportMessengerBrandIcon v-else :messenger="entry.messenger" />
</span> </span>
<span class="text-sm font-600 text-slate-800 whitespace-nowrap leading-none"> <span class="text-sm font-600 text-slate-800 whitespace-nowrap leading-none">
{{ entry.label }} {{ entry.label }}
@@ -49,6 +55,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue' import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger' import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger'
import { APP_Z_SUPPORT_PICKER } from '@/utils/zIndex' import { APP_Z_SUPPORT_PICKER } from '@/utils/zIndex'
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
install: []
dismiss: []
}>()
</script>
<template>
<Transition name="pwa-install-banner">
<button
v-if="visible"
type="button"
class="pwa-install-banner"
@click="emit('install')"
>
نصب وب اپلیکیشن
</button>
</Transition>
</template>
<style scoped lang="less">
.pwa-install-banner {
position: fixed;
left: 50%;
z-index: 5095;
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
max-width: calc(100% - 24px);
padding: 10px 18px;
border-radius: 999px;
background: #fff;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
font-size: 13px;
font-weight: 700;
color: @App-primary-color;
white-space: nowrap;
cursor: pointer;
transform: translateX(-50%);
bottom: calc(var(--app-bottom-chrome-height, 108px) + 8px);
}
@media screen and (min-width: 768px) {
.pwa-install-banner {
/* بالاتر از متن نسخه در پنل برند لاگین */
bottom: 64px;
}
html.app-layout--desktop:not(.app-layout--desktop-no-sidebar) .pwa-install-banner {
left: calc(50% - var(--app-desktop-sidebar-width, 260px) / 2);
}
}
.pwa-install-banner-enter-active,
.pwa-install-banner-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.pwa-install-banner-enter-from,
.pwa-install-banner-leave-to {
opacity: 0;
transform: translate(-50%, 12px);
}
</style>
+106 -46
View File
@@ -1,35 +1,65 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { notification } from 'ant-design-vue' import { notification } from 'ant-design-vue'
import { getAppNotifications, getAppNotificationsUnreadCount } from '@/api/appNotifications' import { getAppNotificationsUnreadCount } from '@/api/appNotifications'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { getToken } from '@/utils' import { getToken } from '@/utils'
import { showBrowserNotification } from '@/utils/browserNotification' import { showBrowserNotification } from '@/utils/browserNotification'
import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState'
import { APP_Z_TOAST } from '@/utils/zIndex'
const unreadCount = ref(0) const unreadCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
let started = false let started = false
let lastStreamNotifId = 0 let lastStreamNotifId = 0
let hydratedUnread = false let hydratedUnread = false
let lastShownNotifId = 0 let lastShownNotifId = 0
let toastConfigured = false
let hydrateFallbackTimer: ReturnType<typeof setTimeout> | null = null
function ensureNotifToastConfig() {
if (toastConfigured) return
toastConfigured = true
notification.config({
placement: 'topRight',
top: 'calc(var(--app-home-header-offset, 4.25rem) + 0.5rem)',
duration: 5,
})
}
function notificationsEnabled(): boolean {
return Boolean(getToken()) && useSettingsStore().isSettingTruthy('has_page_notification')
}
function clearHydrateFallback() {
if (hydrateFallbackTimer) {
clearTimeout(hydrateFallbackTimer)
hydrateFallbackTimer = null
}
}
/** از SSE رویداد unread-count — بدون HTTP */
function applyUnreadCountFromStream(payload: Record<string, unknown>) {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
return
}
const next = Number(payload.unread_count ?? 0) || 0
unreadCount.value = Math.max(0, next)
hydratedUnread = true
clearHydrateFallback()
}
/** فقط وقتی SSE در دسترس نیست یا برای ریفرش دستی صفحه اعلان‌ها */
async function refreshUnreadCount() { async function refreshUnreadCount() {
const settingsStore = useSettingsStore() if (!notificationsEnabled()) {
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
unreadCount.value = 0 unreadCount.value = 0
hydratedUnread = false hydratedUnread = false
return return
} }
try { try {
const data = await getAppNotificationsUnreadCount() const data = await getAppNotificationsUnreadCount()
const next = Number(data?.unread_count ?? 0) || 0 unreadCount.value = Number(data?.unread_count ?? 0) || 0
const prev = unreadCount.value
// اگر SSE نرسید ولی تعداد خوانده‌نشده زیاد شد → نوتیف بساز
if (hydratedUnread && next > prev) {
void notifyFromLatestUnread()
}
unreadCount.value = next
hydratedUnread = true hydratedUnread = true
} catch { } catch {
// silent — badge optional // silent — badge optional
@@ -42,12 +72,14 @@ function clearUnreadLocally() {
function showInAppToast(title: string, body: string) { function showInAppToast(title: string, body: string) {
try { try {
ensureNotifToastConfig()
notification.open({ notification.open({
message: title || 'اعلان جدید', message: title || 'اعلان جدید',
description: body || undefined, description: body || undefined,
placement: 'topRight', placement: 'topRight',
duration: 5, duration: 5,
class: 'app-notif-toast', class: 'app-notif-toast',
style: { zIndex: APP_Z_TOAST },
onClick: () => { onClick: () => {
void import('@/router').then((mod) => { void import('@/router').then((mod) => {
mod.default.push({ name: 'appNotifications' }).catch(() => undefined) mod.default.push({ name: 'appNotifications' }).catch(() => undefined)
@@ -84,25 +116,9 @@ async function presentNotification(payload: {
}) })
} }
async function notifyFromLatestUnread() { /** از SSE رویداد app_notification — بدون polling HTTP */
try {
const rows = await getAppNotifications()
const latest = (Array.isArray(rows) ? rows : []).find((row) => !row.is_read)
if (!latest) return
await presentNotification({
id: latest.id,
title: latest.title,
body: latest.body,
})
} catch {
// ignore
}
}
/** از SSE زنده — بدون Firebase / subscription */
function bumpUnreadFromStream(payload: Record<string, unknown>) { function bumpUnreadFromStream(payload: Record<string, unknown>) {
const settingsStore = useSettingsStore() if (!notificationsEnabled()) {
if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) {
return return
} }
@@ -114,13 +130,50 @@ function bumpUnreadFromStream(payload: Record<string, unknown>) {
lastStreamNotifId = id lastStreamNotifId = id
} }
unreadCount.value = Math.max(0, unreadCount.value) + 1 if (payload.unread_count !== undefined && payload.unread_count !== null) {
applyUnreadCountFromStream(payload)
} else {
unreadCount.value = Math.max(0, unreadCount.value) + 1
hydratedUnread = true
clearHydrateFallback()
}
void presentNotification({ void presentNotification({
id, id,
title: String(payload.title ?? ''), title: String(payload.title ?? ''),
body: String(payload.body ?? ''), body: String(payload.body ?? ''),
}) })
void refreshUnreadCount() }
/**
* بدون interval: اگر استریم قیمت روشن است منتظر رویداد SSE می‌مانیم؛
* فقط وقتی wss خاموش است یا SSE تا ۱۲ثانیه نیامد، یک‌بار HTTP می‌زنیم.
*/
function scheduleHydrate() {
if (!notificationsEnabled()) {
unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
return
}
if (!priceWssEnabled.value) {
void refreshUnreadCount()
return
}
if (priceSocketConnected.value || hydratedUnread) {
clearHydrateFallback()
return
}
clearHydrateFallback()
hydrateFallbackTimer = setTimeout(() => {
hydrateFallbackTimer = null
if (!hydratedUnread && notificationsEnabled() && !priceSocketConnected.value) {
void refreshUnreadCount()
}
}, 12000)
} }
export function useAppNotificationBadge() { export function useAppNotificationBadge() {
@@ -128,25 +181,27 @@ export function useAppNotificationBadge() {
const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification')) const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification'))
onMounted(() => { onMounted(() => {
if (started) {
void refreshUnreadCount()
return
}
started = true started = true
void refreshUnreadCount() scheduleHydrate()
// هر ۲۰ثانیه — اگر SSE قطع بود هم نوتیف دیرتر می‌آید
pollTimer = setInterval(() => {
void refreshUnreadCount()
}, 20000)
}) })
onUnmounted(() => { onUnmounted(() => {
// keep poll for other mounted headers // module-level badge — keep state for other headers
}) })
watch(enabled, (on) => { watch(enabled, (on) => {
if (!on) unreadCount.value = 0 if (!on) {
else void refreshUnreadCount() unreadCount.value = 0
hydratedUnread = false
clearHydrateFallback()
} else {
scheduleHydrate()
}
})
watch([priceSocketConnected, priceWssEnabled], () => {
if (!started) return
scheduleHydrate()
}) })
return { return {
@@ -157,4 +212,9 @@ export function useAppNotificationBadge() {
} }
} }
export { refreshUnreadCount, clearUnreadLocally, bumpUnreadFromStream } export {
refreshUnreadCount,
clearUnreadLocally,
bumpUnreadFromStream,
applyUnreadCountFromStream,
}
+60 -19
View File
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useConfigStore } from '@/store/configStore' import { useConfigStore } from '@/store/configStore'
import { import {
getProjectBaleContactLink, getProjectBaleContactLink,
@@ -13,7 +14,6 @@ import {
loadSupportMessengerFromSettings, loadSupportMessengerFromSettings,
messengerNeedsClipboardCopy, messengerNeedsClipboardCopy,
openSupportMessengerEntry, openSupportMessengerEntry,
openSupportMessengerWithMessage,
resolveDepositGuideText, resolveDepositGuideText,
resolveDepositSupportMessage, resolveDepositSupportMessage,
resolveSupportMessengerEntries, resolveSupportMessengerEntries,
@@ -33,16 +33,29 @@ import {
hasDepositFieldValue, hasDepositFieldValue,
} from '@/utils/depositGuideText' } from '@/utils/depositGuideText'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { isUserSupportChatEnabled } from '@/utils/chat'
/** plain: منوی شناور / درباره ما — بدون پیش‌متن | deposit: مودال واریز — با deposit_support_message_text */ /** plain: منوی شناور / درباره ما — بدون پیش‌متن | deposit: مودال واریز — با deposit_support_message_text */
type SupportPickerIntent = 'plain' | 'deposit' type SupportPickerIntent = 'plain' | 'deposit'
const CHAT_PICKER_ENTRY: ResolvedSupportMessengerEntry = {
messenger: 'chat',
id: 'support-chat',
label: 'چت پشتیبانی',
icon: 'vuesax-linear:messages-2',
accent: '#16a34a',
url: '',
}
export function useSupportMessenger() { export function useSupportMessenger() {
const config = useConfigStore() const config = useConfigStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const router = useRouter()
const supportPickerOpen = supportMessengerPickerOpen const supportPickerOpen = supportMessengerPickerOpen
const supportPickerIntent = ref<SupportPickerIntent>('plain') const supportPickerIntent = ref<SupportPickerIntent>('plain')
const isChatEnabled = computed(() => isUserSupportChatEnabled())
const legacySupportLink = computed(() => { const legacySupportLink = computed(() => {
if (config.config?.name === 'garoosi' || isBazretalaProject()) { if (config.config?.name === 'garoosi' || isBazretalaProject()) {
return getProjectBaleContactLink() return getProjectBaleContactLink()
@@ -52,26 +65,40 @@ export function useSupportMessenger() {
const depositSupportMessage = computed(() => resolveDepositSupportMessage()) const depositSupportMessage = computed(() => resolveDepositSupportMessage())
/** لینک‌های تمیز بدون پیش‌متن — متن فقط موقع باز کردن (قصد deposit) اضافه می‌شود */
const supportMessengerEntries = computed(() => const supportMessengerEntries = computed(() =>
resolveSupportMessengerEntries(supportMessengerSettings.value), resolveSupportMessengerEntries(supportMessengerSettings.value),
) )
const pickerEntries = computed(() => {
const list = [...supportMessengerEntries.value]
if (isChatEnabled.value) {
list.unshift(CHAT_PICKER_ENTRY)
}
return list
})
const primaryMessengerEntry = computed(() => supportMessengerEntries.value[0] ?? null) const primaryMessengerEntry = computed(() => supportMessengerEntries.value[0] ?? null)
const messengerType = computed( const messengerType = computed(
() => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(), () => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(),
) )
const messengerMeta = computed(() => const messengerMeta = computed(() => {
primaryMessengerEntry.value if (isChatEnabled.value && supportMessengerEntries.value.length === 0) {
return {
label: CHAT_PICKER_ENTRY.label,
icon: CHAT_PICKER_ENTRY.icon,
accent: CHAT_PICKER_ENTRY.accent,
}
}
return primaryMessengerEntry.value
? { ? {
label: primaryMessengerEntry.value.label, label: primaryMessengerEntry.value.label,
icon: primaryMessengerEntry.value.icon, icon: primaryMessengerEntry.value.icon,
accent: primaryMessengerEntry.value.accent, accent: primaryMessengerEntry.value.accent,
} }
: resolveSupportMessengerMeta(messengerType.value), : resolveSupportMessengerMeta(messengerType.value)
) })
const messengerLink = computed(() => const messengerLink = computed(() =>
primaryMessengerEntry.value?.url primaryMessengerEntry.value?.url
@@ -82,8 +109,16 @@ export function useSupportMessenger() {
), ),
) )
function goToSupportChat() {
void router.push({ name: 'support' })
return true
}
async function openMessengerEntry(entry: ResolvedSupportMessengerEntry, text?: string) { async function openMessengerEntry(entry: ResolvedSupportMessengerEntry, text?: string) {
// فقط وقتی از مودال واریز آمده‌ایم پیش‌نویس می‌گذاریم؛ منوی شناور همیشه خالی if (entry.messenger === 'chat') {
return goToSupportChat()
}
const messageText = const messageText =
text !== undefined text !== undefined
? text.trim() ? text.trim()
@@ -112,21 +147,25 @@ export function useSupportMessenger() {
const draft = const draft =
intent === 'deposit' ? depositSupportMessage.value.trim() : '' intent === 'deposit' ? depositSupportMessage.value.trim() : ''
const entries = supportMessengerEntries.value const messengerEntries = supportMessengerEntries.value
const entries = pickerEntries.value
// لیست خالی + چت خاموش → هیچ‌کار
if (entries.length === 0) { if (entries.length === 0) {
return openSupportMessengerWithMessage( return false
supportMessengerSettings.value.messenger,
supportMessengerSettings.value.messengerId,
draft,
legacySupportLink.value,
)
} }
// فقط چت (لیست پیام‌رسان خالی) → مستقیم چت
if (isChatEnabled.value && messengerEntries.length === 0) {
return goToSupportChat()
}
// فقط یک گزینه (چت یا یک پیام‌رسان) → مستقیم همان
if (entries.length === 1) { if (entries.length === 1) {
return openMessengerEntry(entries[0], draft) return openMessengerEntry(entries[0], draft)
} }
// چند گزینه (چت + لینک‌ها یا چند پیام‌رسان) → منوی انتخاب
const now = Date.now() const now = Date.now()
if (supportPickerOpen.value) { if (supportPickerOpen.value) {
if (now - supportPickerOpenedAt < 450) return true if (now - supportPickerOpenedAt < 450) return true
@@ -139,12 +178,10 @@ export function useSupportMessenger() {
return true return true
} }
/** منوی شناور / صفحات درباره ما — بدون پیش‌نویس پیام */
async function openSupportMessenger(anchorEl?: HTMLElement | null) { async function openSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('plain', anchorEl) return openSupportPicker('plain', anchorEl)
} }
/** مودال راهنمای واریز — با متن deposit_support_message_text */
async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) { async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) {
return openSupportPicker('deposit', anchorEl) return openSupportPicker('deposit', anchorEl)
} }
@@ -225,8 +262,10 @@ export function useSupportMessenger() {
) )
} }
const hasMessengerLink = computed(() => supportMessengerEntries.value.length > 0 || !!messengerLink.value) const hasMessengerLink = computed(
const hasMultipleMessengers = computed(() => supportMessengerEntries.value.length > 1) () => isChatEnabled.value || supportMessengerEntries.value.length > 0,
)
const hasMultipleMessengers = computed(() => pickerEntries.value.length > 1)
const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value) const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value)
const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value) const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value)
const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value)) const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value))
@@ -234,7 +273,8 @@ export function useSupportMessenger() {
return { return {
supportPickerOpen, supportPickerOpen,
supportPickerAnchor: supportMessengerPickerAnchor, supportPickerAnchor: supportMessengerPickerAnchor,
supportMessengerEntries, supportMessengerEntries: pickerEntries,
isChatEnabled,
messengerType, messengerType,
messengerMeta, messengerMeta,
messengerLink, messengerLink,
@@ -256,6 +296,7 @@ export function useSupportMessenger() {
openDepositSupportMessenger, openDepositSupportMessenger,
openMessengerEntry, openMessengerEntry,
handleSupportMessengerClick, handleSupportMessengerClick,
goToSupportChat,
hasMessengerLink, hasMessengerLink,
hasMultipleMessengers, hasMultipleMessengers,
hasSupportPhoneLink, hasSupportPhoneLink,
@@ -8,6 +8,10 @@ export function useAppNavigation() {
const popVisible = ref(false) const popVisible = ref(false)
const currentRoute = computed(() => route.name) const currentRoute = computed(() => route.name)
const hideMenu = computed(() => !!route.meta.hideMenu) const hideMenu = computed(() => !!route.meta.hideMenu)
/** فقط ناو پایین موبایل — سایدبار دسکتاپ را مخفی نمی‌کند */
const hideBottomNav = computed(
() => !!route.meta.hideBottomNav || !!route.meta.hideMenu,
)
function isNavActive(routeName: string) { function isNavActive(routeName: string) {
if (currentRoute.value === routeName) return true if (currentRoute.value === routeName) return true
@@ -20,6 +24,11 @@ export function useAppNavigation() {
return currentRoute.value === 'fund_info' || currentRoute.value === 'fund_add' return currentRoute.value === 'fund_info' || currentRoute.value === 'fund_add'
} }
// piggy detail/add are sibling routes of piggy
if (routeName === 'piggy') {
return currentRoute.value === 'piggy_info' || currentRoute.value === 'piggy_add'
}
return false return false
} }
@@ -44,6 +53,7 @@ export function useAppNavigation() {
popVisible, popVisible,
currentRoute, currentRoute,
hideMenu, hideMenu,
hideBottomNav,
isNavActive, isNavActive,
navItemClass, navItemClass,
navigateTo, navigateTo,
+3 -3
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="mobile-app-layout"> <div class="mobile-app-layout">
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]"> <div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideBottomNav }]">
<router-view v-slot="{ Component, route: activeRoute }"> <router-view v-slot="{ Component, route: activeRoute }">
<transition :name="activeRoute.meta.transition || 'fade'"> <transition :name="activeRoute.meta.transition || 'fade'">
<component :is="Component" :key="activeRoute.fullPath" class="main-with-bottom-chrome-inner" /> <component :is="Component" :key="activeRoute.fullPath" class="main-with-bottom-chrome-inner" />
@@ -9,7 +9,7 @@
</div> </div>
<MobileBottomNav <MobileBottomNav
v-if="!hideMenu" v-if="!hideBottomNav"
:nav-item-class="navItemClass" :nav-item-class="navItemClass"
@navigate="navigateTo" @navigate="navigateTo"
/> />
@@ -26,7 +26,7 @@ import MobileBottomNav from './MobileBottomNav.vue'
const route = useRoute() const route = useRoute()
const { const {
hideMenu, hideBottomNav,
navItemClass, navItemClass,
navigateTo, navigateTo,
} = useAppNavigation() } = useAppNavigation()
+5 -2
View File
@@ -1,16 +1,19 @@
import type {Wallet} from '@/models/wallet'; import type {Wallet} from '@/models/wallet';
import type {Product} from '@/models/products';
export interface Fund { export interface Fund {
id?: number, id?: number,
title?: string, title?: string,
file_id?: number, file_id?: number,
customer_id?: number, customer_id?: number,
product_id?: number,
created_at?: string, created_at?: string,
updated_at?: string, updated_at?: string,
deleted_at?: string, deleted_at?: string,
wallet?: Wallet[] | Wallet wallet?: Wallet[] | Wallet,
product?: Product,
} }
export interface FundTransaction { export interface FundTransaction {
fund_id?: number fund_id?: number
value?: number value?: number
} }
+3
View File
@@ -52,6 +52,9 @@ export interface User {
finger_print: boolean finger_print: boolean
gold: number; gold: number;
chat_room_id?: string | null
chat_user_id?: string | null
settings?: Array<{ key?: string; value?: string | number }>
} }
export interface Login { export interface Login {
+77
View File
@@ -0,0 +1,77 @@
export type ChatAttachmentType = 'image' | 'audio'
export type ChatMessageSender = 'user' | 'support' | 'admin'
export type ChatMessageStatus = 'sending' | 'sent' | 'seen' | 'failed'
export type ChatAttachment = {
id: string
type: 'image' | 'audio'
url: string
name?: string
size?: number
mimeType?: string
durationMs?: number
file?: File
uploading?: boolean
uploadProgress?: number
}
export type ChatReplyPreview = {
id: string
sender: string
text?: string
attachment?: {
type: 'image' | 'audio'
url: string
name?: string
} | null
}
export interface ChatMessage {
id: string
text?: string
sender: 'user' | 'support'
createdAt: string
createdAtRaw?: string | number | Date | null
status?: 'sending' | 'sent' | 'seen'
attachments?: ChatAttachment[]
// reply
reply?: string | null
replyTo?: ChatReplyPreview | null
}
export type ComposerSendPayload = {
text: string
attachments: ChatAttachment[]
reply?: string | null
replyTo?: ChatReplyPreview | null
}
export interface SupportMessage {
id: string
sender: 'user' | 'admin'
text?: string
createdAt: string
createdAtRaw?: string | number | Date | null
status?: ChatMessageStatus
attachments?: ChatAttachment[]
// reply
reply?: string | null
replyTo?: ChatReplyPreview | null
}
export interface SupportConversation {
id: string
userName: string
avatar: string
online: boolean
unreadCount: number
lastSeenText: string
lastMessagePreview: string
updatedAtText: string
messages: SupportMessage[]
}
+21
View File
@@ -56,6 +56,12 @@ const router = createRouter({
meta: {hideMenu: true}, meta: {hideMenu: true},
component: () => import('@/views/base/about/index.vue') component: () => import('@/views/base/about/index.vue')
}, },
{
path: 'support',
name: 'support',
meta: { hideBottomNav: true },
component: () => import('@/views/support/index.vue')
},
{ {
path: 'notifyTrigger', path: 'notifyTrigger',
name: 'notifyTrigger', name: 'notifyTrigger',
@@ -127,6 +133,21 @@ const router = createRouter({
name: 'fund_add', name: 'fund_add',
component: () => import('@/views/base/fund/newFund/index.vue') component: () => import('@/views/base/fund/newFund/index.vue')
}, },
{
path: 'piggy',
name: 'piggy',
component: () => import('@/views/base/piggy/index.vue')
},
{
path: 'piggy/add',
name: 'piggy_add',
component: () => import('@/views/base/piggy/new.vue')
},
{
path: 'piggy/:id',
name: 'piggy_info',
component: () => import('@/views/base/piggy/info.vue')
},
] ]
}, },
{ {
+18 -24
View File
@@ -1,16 +1,18 @@
import { type ComputedRef, ref } from 'vue' import { ref } from 'vue'
// import { get } from 'lodash' import { defineStore } from 'pinia'
import {defineStore} from "pinia"; import { getFundTable } from '@/api/fund'
import {getMe} from "@/api/me"; import type { Fund } from '@/models/fund'
import {getFundTable} from "@/api/fund"; import type { TableOutput } from '@/models/table'
import type {Fund} from "@/models/fund";
import type {TableOutput} from "@/models/table";
import { getProductInventory } from '@/api/products' import { getProductInventory } from '@/api/products'
export const useFundUserStore = defineStore('fund', () => { export const useFundUserStore = defineStore('fund', () => {
/** دارایی‌ها / موجودی محصول (صفحهٔ /fund فعلی) */
const myFunds = ref<TableOutput<Fund> | undefined>(undefined) const myFunds = ref<TableOutput<Fund> | undefined>(undefined)
/** قلک‌های واقعی (مدل Fund) */
const myPiggyFunds = ref<TableOutput<Fund> | undefined>(undefined)
async function getMyFund() { async function getMyFund() {
try { try {
console.log('fff')
myFunds.value = await getProductInventory() myFunds.value = await getProductInventory()
return myFunds.value return myFunds.value
} catch (e) { } catch (e) {
@@ -18,22 +20,14 @@ export const useFundUserStore = defineStore('fund', () => {
} }
} }
/* const permissions: ComputedRef<string[]> = computed(() => { async function getMyPiggyFunds() {
const permissions: string[] = get(userInfo.value, 'permission', []) try {
const excludes: string[] = get(userInfo.value, 'exclude', []) myPiggyFunds.value = await getFundTable()
return permissions.filter((item) => !excludes.includes(item)) return myPiggyFunds.value
})*/ } catch (e) {
console.log(e)
/* }
function canAccess(permissionKey: string) {
if (isAdmin.value) return true
return permissions.value.includes(permissionKey)
} }
*/
/*
const isAdmin = computed(() => {
return userInfo.value?.is_admin
})*/
return { getMyFund, myFunds/* permissions, canAccess, isAdmin*/ } return { getMyFund, getMyPiggyFunds, myFunds, myPiggyFunds }
}) })
+20 -5
View File
@@ -1,4 +1,4 @@
/** نوتیف مرورگر بدون Firebase — فقط با کلیک کاربر (gesture) قابل درخواست است. */ /** نوتیف مرورگر بدون Firebase — ترجیحاً با gesture کاربر؛ بعد از لاگین هم تلاش می‌شود. */
export type BrowserNotifState = export type BrowserNotifState =
| 'unsupported' | 'unsupported'
@@ -77,11 +77,28 @@ export async function requestBrowserNotificationPermission(): Promise<BrowserNot
} }
} }
/** لوگوی برند از کانفیگ؛ fallback به آیکون‌های PWA */
function resolveNotifIcon() { function resolveNotifIcon() {
try { try {
return new URL('/favicon.ico', window.location.origin).href const avatar = String(
(window as Window & { __APP_CONFIG__?: { user?: { avatar?: string } } }).__APP_CONFIG__?.user
?.avatar ?? '',
).trim()
if (avatar) {
return avatar
}
} catch { } catch {
return undefined // 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
}
} }
} }
@@ -104,7 +121,6 @@ export async function showBrowserNotification(options: ShowBrowserNotificationOp
const url = options.url || '/appNotifications' const url = options.url || '/appNotifications'
const icon = resolveNotifIcon() const icon = resolveNotifIcon()
// ۱) Service Worker — روی Chrome/Android پایدارتر است
try { try {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.ready const reg = await navigator.serviceWorker.ready
@@ -124,7 +140,6 @@ export async function showBrowserNotification(options: ShowBrowserNotificationOp
console.warn('SW showNotification failed, falling back', error) console.warn('SW showNotification failed, falling back', error)
} }
// ۲) Notification constructor
try { try {
const n = new Notification(title, { const n = new Notification(title, {
body, body,
+1576
View File
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -122,6 +122,27 @@ function isIphone() {
return /iPhone/.test(userAgent) return /iPhone/.test(userAgent)
} }
function createUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-')
}
return `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
}
export { export {
getToken, getToken,
setToken, setToken,
@@ -131,5 +152,6 @@ export {
isIphone, isIphone,
removeFingerPrint, removeFingerPrint,
getIsActiveFingerprint, getIsActiveFingerprint,
setFingerPrint setFingerPrint,
createUuid
} }
+26
View File
@@ -384,6 +384,15 @@ function handleAppNotificationPayload(payload: unknown) {
void applyAppNotificationFromStream(payload as Record<string, unknown>) void applyAppNotificationFromStream(payload as Record<string, unknown>)
} }
function handleUnreadCountPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
touchStreamActivity()
void applyUnreadCountFromStreamPayload(payload as Record<string, unknown>)
}
async function applyAppNotificationFromStream(payload: Record<string, unknown>) { async function applyAppNotificationFromStream(payload: Record<string, unknown>) {
try { try {
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge') const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
@@ -393,6 +402,15 @@ async function applyAppNotificationFromStream(payload: Record<string, unknown>)
} }
} }
async function applyUnreadCountFromStreamPayload(payload: Record<string, unknown>) {
try {
const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge')
applyUnreadCountFromStream(payload)
} catch (error) {
console.warn('Unread-count SSE apply failed', error)
}
}
function bindStreamEvents(source: EventSource) { function bindStreamEvents(source: EventSource) {
source.addEventListener('last', (event) => { source.addEventListener('last', (event) => {
try { try {
@@ -426,6 +444,14 @@ function bindStreamEvents(source: EventSource) {
} }
}) })
source.addEventListener('unread-count', (event) => {
try {
handleUnreadCountPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Unread-count SSE parse failed', error)
}
})
source.addEventListener('ping', () => { source.addEventListener('ping', () => {
touchStreamActivity() touchStreamActivity()
}) })
+14 -3
View File
@@ -28,7 +28,18 @@ export function formatTradeGramDisplay(grams?: number | null): number {
} }
function feeRate() { function feeRate() {
return Number(serviceFeePercentage.value) return normalizeServiceFeeRatio(Number(serviceFeePercentage.value))
}
/** نسبت کارمزد (۰٫۰۱ = ۱٪). مقدار ≥۱ یعنی درصد انسانی اشتباه وارد شده. */
export function normalizeServiceFeeRatio(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0
}
if (value >= 1) {
return value / 100
}
return value
} }
export async function loadServiceFeeFromSettings() { export async function loadServiceFeeFromSettings() {
@@ -39,7 +50,7 @@ export async function loadServiceFeeFromSettings() {
(s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true (s) => s?.key === 'fee_service_charge_percentage' && s?.is_active === true
) )
if (feeSetting?.value !== undefined && feeSetting?.value !== null) { if (feeSetting?.value !== undefined && feeSetting?.value !== null) {
serviceFeePercentage.value = Number(feeSetting.value) serviceFeePercentage.value = normalizeServiceFeeRatio(Number(feeSetting.value))
} }
const feeModeSetting = settings.find( const feeModeSetting = settings.find(
@@ -58,7 +69,7 @@ export async function loadServiceFeeFromSettings() {
} }
export function calcFeeInGrams(goldValue?: number | null) { export function calcFeeInGrams(goldValue?: number | null) {
return parseFloat(((goldValue ?? 0) * Number(serviceFeePercentage.value)).toFixed(4)) return parseFloat(((goldValue ?? 0) * feeRate()).toFixed(4))
} }
/** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */ /** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */
+7 -13
View File
@@ -88,8 +88,11 @@ export function parseSupportMessengersJson(raw?: string | null): SupportMessenge
export function resolveSupportMessengersFromSettings( export function resolveSupportMessengersFromSettings(
settings: Array<{ key?: string; value?: unknown }>, settings: Array<{ key?: string; value?: unknown }>,
): SupportMessengerEntry[] { ): SupportMessengerEntry[] {
const fromJson = parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers')) // اگر کلید جدید در تنظیمات هست (حتی خالی/null)، فقط همان لیست معتبر است — legacy بله را برنگردان
if (fromJson.length > 0) return fromJson const hasMessengersKey = settings.some((row) => row?.key === 'support_messengers')
if (hasMessengersKey) {
return parseSupportMessengersJson(getSupportSettingValue(settings, 'support_messengers'))
}
const legacyMessenger = getSupportSettingValue(settings, 'support_messenger').toLowerCase() const legacyMessenger = getSupportSettingValue(settings, 'support_messenger').toLowerCase()
const legacyId = getSupportSettingValue(settings, 'support_messenger_id') const legacyId = getSupportSettingValue(settings, 'support_messenger_id')
@@ -108,9 +111,8 @@ export function resolveSupportMessengerEntries(
): ResolvedSupportMessengerEntry[] { ): ResolvedSupportMessengerEntry[] {
const resolved = settings ?? supportMessengerSettings.value const resolved = settings ?? supportMessengerSettings.value
const text = (messageText ?? '').trim() const text = (messageText ?? '').trim()
const source = resolved.messengers?.length // فقط لیست resolve‌شده؛ قبل از load خالی است تا legacy دوباره دکمه نسازد
? resolved.messengers const source = Array.isArray(resolved.messengers) ? resolved.messengers : []
: resolveLegacySingleMessenger(resolved)
return source return source
.map((entry) => { .map((entry) => {
@@ -126,14 +128,6 @@ export function resolveSupportMessengerEntries(
.filter((entry): entry is ResolvedSupportMessengerEntry => entry !== null) .filter((entry): entry is ResolvedSupportMessengerEntry => entry !== null)
} }
function resolveLegacySingleMessenger(settings: SupportMessengerSettings): SupportMessengerEntry[] {
const messenger = (settings.messenger ?? '').trim().toLowerCase()
const id = (settings.messengerId ?? '').trim()
if (!messenger || !id) return []
if (!VALID_SUPPORT_MESSENGERS.has(messenger)) return []
return [{ messenger, id }]
}
/** @deprecated از depositGuideText استفاده کنید */ /** @deprecated از depositGuideText استفاده کنید */
export const DEFAULT_SUPPORT_MESSENGER_TEXT = DEFAULT_DEPOSIT_GUIDE_TEXT export const DEFAULT_SUPPORT_MESSENGER_TEXT = DEFAULT_DEPOSIT_GUIDE_TEXT
+3
View File
@@ -1,3 +1,6 @@
/** toast اعلان درون‌برنامه — بالای هدر (1999) و ناوبری (5000)، زیر اکشن‌شیت */
export const APP_Z_TOAST = 5_050
/** /**
* z-index مدال/اکشن‌شیت/دیالوگ. * z-index مدال/اکشن‌شیت/دیالوگ.
* بالای ناوبری (5000)، هدر (1999) و دکمه پشتیبانی (5100) می‌نشیند تا این کروم‌ها * بالای ناوبری (5000)، هدر (1999) و دکمه پشتیبانی (5100) می‌نشیند تا این کروم‌ها
@@ -33,6 +33,20 @@
</span> </span>
</span> </span>
</button> </button>
<button
v-if="chatEnabled"
type="button"
class="mhome-header-btn mhome-header-btn--notify"
aria-label="چت پشتیبانی"
@click.stop="goSupportChat"
>
<span class="mhome-header-btn__icon-wrap">
<Icon icon="vuesax-linear:messages-2" size="18" />
<span v-if="chatUnreadCount > 0" class="mhome-header-btn__badge">
{{ chatUnreadCount > 99 ? '99+' : chatUnreadCount }}
</span>
</span>
</button>
<button <button
type="button" type="button"
class="mhome-header-btn" class="mhome-header-btn"
@@ -67,6 +81,7 @@ import {
hasNativeInstallPrompt, hasNativeInstallPrompt,
initPwaInstallPromptListener, initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall' } from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
const headerRef = ref<HTMLElement | null>(null) const headerRef = ref<HTMLElement | null>(null)
const isHeaderScrolled = ref(false) const isHeaderScrolled = ref(false)
@@ -74,6 +89,8 @@ const canNativeInstall = ref(false)
const route = useRoute() const route = useRoute()
const projectLogo = computed(() => getProjectMobileNavLogo()) const projectLogo = computed(() => getProjectMobileNavLogo())
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const { const {
enabled: notificationEnabled, enabled: notificationEnabled,
unreadCount, unreadCount,
@@ -98,6 +115,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' }) void router.push({ name: 'appNotifications' })
} }
function goSupportChat() {
void router.push({ name: 'support' })
}
let intersectionObserver: IntersectionObserver | null = null let intersectionObserver: IntersectionObserver | null = null
let scrollTarget: HTMLElement | Window | null = null let scrollTarget: HTMLElement | Window | null = null
+31 -8
View File
@@ -39,10 +39,10 @@
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button> <a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button> <a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group> </a-radio-group>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass"> <div id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart <apexchart
v-if="canRenderChart" v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`" :key="chartRenderKey"
type="area" type="area"
width="100%" width="100%"
:height="chartHeight" :height="chartHeight"
@@ -66,7 +66,6 @@ import {
shouldUseKatodVisualChartTransform, shouldUseKatodVisualChartTransform,
} from '@/utils/chartPriceAxis' } from '@/utils/chartPriceAxis'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { useElementSize } from '@vueuse/core'
import { price } from '@/utils/price' import { price } from '@/utils/price'
import type { ChartMetalType } from '../composables/usePriceChart' import type { ChartMetalType } from '../composables/usePriceChart'
@@ -117,12 +116,10 @@ const emit = defineEmits<{
const config = useConfigStore() const config = useConfigStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const { selectedMetalChartRatio } = usePriceChart() const { selectedMetalChartRatio } = usePriceChart()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false) const chartMounted = ref(false)
const renderNonce = ref(0)
const selectedValue = ref(props.chartSelection) const selectedValue = ref(props.chartSelection)
const { width: chartWidth } = useElementSize(chartContainerRef)
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0) const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const amplifyKatodChart = computed(() => const amplifyKatodChart = computed(() =>
shouldAmplifyKatodMesChart({ shouldAmplifyKatodMesChart({
@@ -175,20 +172,46 @@ const chartYBounds = computed(() => {
return computeKatodMesVisualYBounds(prices) return computeKatodMesVisualYBounds(prices)
}) })
const canRenderChart = computed( /** Do not gate on measured width — useElementSize can stay 0 until a reflow (e.g. tab click). */
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0 const canRenderChart = computed(() => chartMounted.value && seriesLength.value > 0)
const chartRenderKey = computed(
() =>
`${props.layout}-${props.variant}-${props.selectedMetal}-${props.chartSelection}-${seriesLength.value}-${renderNonce.value}`
) )
function scheduleChartRemount() {
void nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
renderNonce.value += 1
})
})
})
}
watch( watch(
() => props.chartSelection, () => props.chartSelection,
(timeline) => { (timeline) => {
selectedValue.value = timeline selectedValue.value = timeline
if (seriesLength.value > 0) {
scheduleChartRemount()
}
} }
) )
// Data often arrives before/while first layout; Apex can mount blank until remount.
watch(seriesLength, (len, prev) => {
if (len > 0 && (prev ?? 0) === 0) {
scheduleChartRemount()
}
})
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
chartMounted.value = true chartMounted.value = true
if (seriesLength.value > 0) {
scheduleChartRemount()
}
}) })
const chartOptions = computed(() => { const chartOptions = computed(() => {
+40 -27
View File
@@ -81,34 +81,47 @@ export function useHomePage() {
const showClosedMessage = computed(() => !!marketClosedText.value) const showClosedMessage = computed(() => !!marketClosedText.value)
const goldVal = computed(() => lastPrice.value?.price_last ?? 1) const goldVal = computed(() => lastPrice.value?.price_last ?? 1)
const mainActions = computed<HomeActionItem[]>(() => [ const mainActions = computed<HomeActionItem[]>(() => {
{ const actions: HomeActionItem[] = [
name: 'buySell', {
icon: 'vuesax-linear:money-receive', name: 'buySell',
label: 'خرید و فروش', icon: 'vuesax-linear:money-receive',
handler: () => router.push({ name: 'buyProduct' }), label: 'خرید و فروش',
}, handler: () => router.push({ name: 'buyProduct' }),
{
name: 'deposit',
icon: 'vuesax-linear:card-add',
label: 'واریز نقدی',
handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
},
{
name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
}, },
}, {
{ name: 'deposit',
name: 'assets', icon: 'vuesax-linear:card-add',
icon: 'vuesax-linear:cup', label: 'واریز نقدی',
label: 'دارایی ها', handler: () => router.push({ name: 'wallet', query: { state: 'deposit' } }),
handler: () => router.push({ name: 'fund' }), },
}, {
]) name: 'balance',
icon: 'vuesax-linear:receipt-item',
label: 'موجودی',
handler: () => {
wholeBalanceVisible.value = true
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: 'دارایی ها',
handler: () => router.push({ name: 'fund' }),
},
]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
actions.push({
name: 'piggy',
icon: 'vuesax-linear:safe-home',
label: 'قلک',
handler: () => router.push({ name: 'piggy' }),
})
}
return actions
})
function copyCode() { function copyCode() {
const code = userStore.userInfo?.referral_id const code = userStore.userInfo?.referral_id
@@ -4,8 +4,10 @@ import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProd
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price' import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh' export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
export type ChartTimeline = 'DAILY' | 'WEEKLY' | 'MONTHLY'
type ChartPoint = [number, number] type ChartPoint = [number, number]
type PricesMap = Record<string, Array<{ timestamp: number; value: number }>>
/** /**
* مارک‌آپ ثابت روی قیمت بورس کالا برای نمودار: * مارک‌آپ ثابت روی قیمت بورس کالا برای نمودار:
@@ -22,7 +24,8 @@ const priceChartSeries = ref<{ name: string; data: ChartPoint[] }[]>([
{ name: 'قیمت', data: [] }, { name: 'قیمت', data: [] },
]) ])
const rawChartPoints = ref<ChartPoint[]>([]) const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY') const cachedPrices = ref<PricesMap | null>(null)
const chartSelection = ref<ChartTimeline>('DAILY')
const chartLoading = ref(false) const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod') const selectedChartMetal = ref<ChartMetalType>('katod')
const catalogProducts = getProductsCatalogRef() const catalogProducts = getProductsCatalogRef()
@@ -67,7 +70,7 @@ function normalizeChartTimestamp(timestamp: number) {
return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp
} }
function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp: number; value: number }>> | null { function resolvePricesMap(response: unknown): PricesMap | null {
if (!response || typeof response !== 'object') { if (!response || typeof response !== 'object') {
return null return null
} }
@@ -76,11 +79,11 @@ function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp:
const nested = body.data const nested = body.data
if (nested && typeof nested === 'object' && 'prices' in nested) { if (nested && typeof nested === 'object' && 'prices' in nested) {
return (nested as { prices: Record<string, Array<{ timestamp: number; value: number }>> }).prices return (nested as { prices: PricesMap }).prices
} }
if ('prices' in body) { if ('prices' in body) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>> return body.prices as PricesMap
} }
return null return null
@@ -95,9 +98,21 @@ function buildChartPoints(items: Array<{ timestamp: number; value: number }>): C
function syncChartSeries() { function syncChartSeries() {
const ratio = selectedMetalChartRatio.value const ratio = selectedMetalChartRatio.value
priceChartSeries.value[0].data = rawChartPoints.value.map( // New array so Vue invalidates parent computeds (nested .data mutation did not).
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint, priceChartSeries.value = [
) {
name: 'قیمت',
data: rawChartPoints.value.map(
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
),
},
]
}
function applyTimelineFromCache(timeline: ChartTimeline = chartSelection.value) {
const selectedData = cachedPrices.value?.[timeline] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
} }
watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => { watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
@@ -105,8 +120,8 @@ watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
}) })
async function fetchPrices(force = false) { async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) { if (!force && chartInitialized && cachedPrices.value) {
syncChartSeries() applyTimelineFromCache()
return return
} }
@@ -119,10 +134,9 @@ async function fetchPrices(force = false) {
chartLoading.value = true chartLoading.value = true
const response = await getAllPrices() const response = await getAllPrices()
const data = resolvePricesMap(response) const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? [] cachedPrices.value = data
rawChartPoints.value = buildChartPoints(selectedData) applyTimelineFromCache()
syncChartSeries() chartInitialized = Boolean(data)
chartInitialized = true
} catch (error) { } catch (error) {
console.error('Error fetching prices:', error) console.error('Error fetching prices:', error)
} finally { } finally {
@@ -160,7 +174,11 @@ async function loadMetalChartOffsets(force = false) {
} }
function updateChartData(timeline: string) { function updateChartData(timeline: string) {
chartSelection.value = timeline chartSelection.value = timeline as ChartTimeline
if (cachedPrices.value) {
applyTimelineFromCache(chartSelection.value)
return
}
void fetchPrices(true) void fetchPrices(true)
} }
@@ -197,6 +215,7 @@ export function loadHomeMaftolOffset(force = false) {
export function resetHomePriceChart() { export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }] priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = [] rawChartPoints.value = []
cachedPrices.value = null
chartSelection.value = 'DAILY' chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod' selectedChartMetal.value = 'katod'
chartInitialized = false chartInitialized = false
@@ -7,7 +7,11 @@ import DesktopHomeCard from '../components/DesktopHomeCard.vue'
const ctx = useHomePageContext() const ctx = useHomePageContext()
const chartLayout = 'boursecu' as const const chartLayout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries)) const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection)) const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal)) const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script> </script>
@@ -16,7 +16,11 @@ const props = withDefaults(
const ctx = useHomePageContext() const ctx = useHomePageContext()
const chartLayout = 'boursecu' as const const chartLayout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries)) const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection)) const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal)) const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script> </script>
@@ -18,7 +18,7 @@
@click="onInstall" @click="onInstall"
> >
<BasicIcon icon="vuesax-linear:mobile" size="16" /> <BasicIcon icon="vuesax-linear:mobile" size="16" />
<span>{{ canNativeInstall ? 'نصب وب اپ' : 'آموزش نصب' }}</span> <span>{{ canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب' }}</span>
</button> </button>
<button <button
v-if="notificationEnabled" v-if="notificationEnabled"
@@ -35,6 +35,21 @@
</span> </span>
<span>اعلان</span> <span>اعلان</span>
</button> </button>
<button
v-if="chatEnabled"
type="button"
class="dhome-btn-ghost dhome-btn-ghost--notify"
aria-label="چت پشتیبانی"
@click="goSupportChat"
>
<span class="dhome-notify-wrap">
<BasicIcon icon="vuesax-linear:messages-2" size="16" />
<span v-if="chatUnreadCount > 0" class="dhome-notify-badge">
{{ chatUnreadCount > 99 ? '99+' : chatUnreadCount }}
</span>
</span>
<span>چت</span>
</button>
<button type="button" class="dhome-btn-ghost" aria-label="بروزرسانی" @click="reloadApp"> <button type="button" class="dhome-btn-ghost" aria-label="بروزرسانی" @click="reloadApp">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" /> <BasicIcon icon="vuesax-linear:refresh-2" size="16" />
</button> </button>
@@ -52,7 +67,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge' import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
@@ -63,6 +78,7 @@ import {
hasNativeInstallPrompt, hasNativeInstallPrompt,
initPwaInstallPromptListener, initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall' } from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
import router from '@/router' import router from '@/router'
withDefaults( withDefaults(
@@ -78,6 +94,8 @@ defineEmits<{
const userStore = useUserStore() const userStore = useUserStore()
const canNativeInstall = ref(false) const canNativeInstall = ref(false)
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const { const {
enabled: notificationEnabled, enabled: notificationEnabled,
unreadCount, unreadCount,
@@ -102,6 +120,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' }) void router.push({ name: 'appNotifications' })
} }
function goSupportChat() {
void router.push({ name: 'support' })
}
function reloadApp() { function reloadApp() {
window.location.reload() window.location.reload()
} }
@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue' import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue' import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions' import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
@@ -10,10 +13,28 @@ const props = withDefaults(
actions?: HomeQuickActionItem[] actions?: HomeQuickActionItem[]
}>(), }>(),
{ {
actions: () => boursecuQuickActions, actions: undefined,
}, },
) )
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) { function handleClick(action: HomeQuickActionItem) {
router.push(action.route) router.push(action.route)
} }
@@ -23,7 +44,7 @@ function handleClick(action: HomeQuickActionItem) {
<DesktopHomeCard title="دسترسی سریع"> <DesktopHomeCard title="دسترسی سریع">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<HomeQuickActionBox <HomeQuickActionBox
v-for="action in props.actions" v-for="action in resolvedActions"
:key="action.id" :key="action.id"
:action="action" :action="action"
class="home-desktop-quick-action" class="home-desktop-quick-action"
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue' import DesktopHomeSection from './DesktopHomeSection.vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions' import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types' import type { HomeQuickActionItem } from '../../layouts/types'
@@ -11,11 +14,29 @@ const props = withDefaults(
title?: string title?: string
}>(), }>(),
{ {
actions: () => boursecuQuickActions, actions: undefined,
title: 'میانبرها', title: 'میانبرها',
}, },
) )
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function navigate(action: HomeQuickActionItem) { function navigate(action: HomeQuickActionItem) {
router.push(action.route) router.push(action.route)
} }
@@ -25,7 +46,7 @@ function navigate(action: HomeQuickActionItem) {
<DesktopHomeSection :title="title"> <DesktopHomeSection :title="title">
<nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی"> <nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی">
<button <button
v-for="action in props.actions" v-for="action in resolvedActions"
:key="action.id" :key="action.id"
type="button" type="button"
class="dhome-action" class="dhome-action"
@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import router from '@/router' import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue' import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import { boursecuQuickActions } from '../configs/boursecuQuickActions' import { boursecuQuickActions } from '../configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../types' import type { HomeQuickActionItem } from '../types'
@@ -9,10 +12,28 @@ const props = withDefaults(
actions?: HomeQuickActionItem[] actions?: HomeQuickActionItem[]
}>(), }>(),
{ {
actions: () => boursecuQuickActions, actions: undefined,
} }
) )
const settingsStore = useSettingsStore()
const resolvedActions = computed(() => {
if (props.actions) return props.actions
const list = [...boursecuQuickActions]
if (settingsStore.isSettingTruthy('enable_product_funds')) {
list.push({
id: 'piggy',
label: 'قلک',
icon: 'vuesax-linear:safe-home',
iconSrc: getBoursecuActionIconSrc('piggy'),
route: { name: 'piggy' },
})
}
return list
})
function handleClick(action: HomeQuickActionItem) { function handleClick(action: HomeQuickActionItem) {
router.push(action.route) router.push(action.route)
} }
@@ -21,7 +42,7 @@ function handleClick(action: HomeQuickActionItem) {
<template> <template>
<div class="grid grid-cols-3 gap-3 mt-4"> <div class="grid grid-cols-3 gap-3 mt-4">
<HomeQuickActionBox <HomeQuickActionBox
v-for="action in props.actions" v-for="action in resolvedActions"
:key="action.id" :key="action.id"
:action="action" :action="action"
@click="handleClick(action)" @click="handleClick(action)"
@@ -5,7 +5,12 @@ import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)! const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
const layout = 'boursecu' as const const layout = 'boursecu' as const
const priceChartSeries = computed(() => toValue(ctx.priceChartSeries)) const priceChartSeries = computed(() => {
const series = toValue(ctx.priceChartSeries)
// Track length so chart updates even if the array reference is reused.
void series?.[0]?.data?.length
return series
})
const chartSelection = computed(() => toValue(ctx.chartSelection)) const chartSelection = computed(() => toValue(ctx.chartSelection))
const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal)) const selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script> </script>
+280
View File
@@ -0,0 +1,280 @@
<template>
<div class="piggy-page">
<div class="piggy-page__inner">
<header class="piggy-bar">
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-bar__title">قلک‌ها</h1>
<button type="button" class="piggy-bar__btn piggy-bar__btn--primary" aria-label="قلک جدید" @click="goAdd">
<BasicIcon icon="vuesax-linear:add" size="20" />
</button>
</header>
<a-spin :spinning="busy">
<section class="piggy-summary">
<div class="piggy-summary__card">
<div class="piggy-summary__row">
<div class="min-w-0">
<p class="piggy-summary__label">جمع موجودی داخل قلک‌ها</p>
<p class="piggy-summary__value" dir="ltr">
{{ formatPiggyAmount(totalGold) }}
<span class="piggy-summary__unit">گرم</span>
</p>
</div>
<div class="piggy-summary__badge">
<BasicIcon icon="vuesax-linear:safe-home" size="22" />
</div>
</div>
<div class="piggy-summary__meta">
<span>{{ rows.length }} قلک</span>
<span class="piggy-summary__dot" />
<span>{{ groups.length }} محصول</span>
</div>
</div>
<BasicButton class="!w-full mt-3" type="primary" size="middle" block @click="goAdd">
<div class="flex items-center justify-center gap-2">
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
<span>{{ rows.length ? 'افزودن قلک' : 'ساخت اولین قلک' }}</span>
</div>
</BasicButton>
</section>
<section class="piggy-body">
<div v-if="!rows.length" class="piggy-empty">
<div class="piggy-empty__icon">
<BasicIcon icon="vuesax-linear:safe-home" size="36" />
</div>
<p class="piggy-empty__title">قلکی ندارید</p>
<p class="piggy-empty__desc">
برای جدا کردن بخشی از موجودی هر محصول (مثلاً آبشده یا سکه) یک قلک بسازید.
</p>
</div>
<div v-else class="piggy-groups">
<div v-for="group in groups" :key="group.productId" class="piggy-group">
<div class="piggy-group__head">
<div class="min-w-0">
<p class="piggy-group__name">{{ group.productTitle }}</p>
<p class="piggy-group__count">{{ group.funds.length }} قلک</p>
</div>
<p class="piggy-group__total" dir="ltr">
{{ formatPiggyAmount(group.totalGold) }}
<span>گرم</span>
</p>
</div>
<div class="piggy-group__list">
<button
v-for="item in group.funds"
:key="item.id"
type="button"
class="piggy-row"
@click="openInfo(item.id!)"
>
<div class="piggy-row__icon">
<BasicIcon :icon="piggyIconName(item.title)" size="20" />
</div>
<div class="piggy-row__main">
<div class="piggy-row__title">{{ item.title || 'بدون عنوان' }}</div>
<div class="piggy-row__sub">کد {{ item.id }}</div>
</div>
<div class="piggy-row__amount" dir="ltr">
<strong>{{ formatPiggyAmount(fundWalletGold(item)) }}</strong>
<span>گرم</span>
</div>
<BasicIcon class="piggy-row__chevron" icon="vuesax-linear:arrow-left-1" size="16" />
</button>
</div>
</div>
</div>
</section>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import {
formatPiggyAmount,
fundWalletGold,
groupFundsByProduct,
piggyIconName,
} from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
const groups = computed(() => groupFundsByProduct(rows.value))
const totalGold = computed(() => rows.value.reduce((sum, item) => sum + fundWalletGold(item), 0))
function goBack() {
router.back()
}
function goAdd() {
router.push({ name: 'piggy_add' })
}
function openInfo(id: number) {
router.push({ name: 'piggy_info', params: { id: String(id) } })
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await fundStore.getMyPiggyFunds()
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-page {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-page__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-bar {
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
}
.piggy-bar__title {
@apply m-0 truncate px-2 text-base font-bold text-gray-900;
}
.piggy-bar__btn {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-gray-100 text-gray-800;
}
.piggy-bar__btn--primary {
@apply bg-primary text-white;
}
.piggy-summary {
@apply px-3 pt-4 sm:px-4;
}
.piggy-summary__card {
@apply rounded-2xl bg-primary p-4 text-white;
}
.piggy-summary__row {
@apply flex items-start justify-between gap-3;
}
.piggy-summary__label {
@apply m-0 text-sm text-white/80;
}
.piggy-summary__value {
@apply m-0 mt-1 flex flex-wrap items-baseline gap-2 text-2xl font-bold sm:text-3xl;
}
.piggy-summary__unit {
@apply text-sm font-medium text-white/75;
}
.piggy-summary__badge {
@apply flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/15;
}
.piggy-summary__meta {
@apply mt-4 flex flex-wrap items-center gap-2 text-xs text-white/75;
}
.piggy-summary__dot {
@apply h-1 w-1 rounded-full bg-white/50;
}
.piggy-body {
@apply px-3 pt-4 sm:px-4;
}
.piggy-groups {
@apply flex flex-col gap-4;
}
.piggy-group {
@apply overflow-hidden rounded-2xl bg-white;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-group__head {
@apply flex items-center justify-between gap-3 border-0 border-b border-solid border-gray-100 px-3 py-3 sm:px-4;
}
.piggy-group__name {
@apply m-0 truncate text-sm font-bold text-gray-900;
}
.piggy-group__count {
@apply m-0 mt-0.5 text-[11px] text-gray-500;
}
.piggy-group__total {
@apply m-0 flex shrink-0 items-baseline gap-1 text-sm font-bold text-primary;
}
.piggy-group__total span {
@apply text-[11px] font-medium text-gray-500;
}
.piggy-group__list {
@apply flex flex-col;
}
.piggy-row {
@apply flex w-full min-w-0 items-center gap-2 border-0 border-b border-solid border-gray-50 bg-transparent px-2.5 py-3 text-right last:border-b-0 sm:gap-3 sm:px-3;
}
.piggy-row__icon {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-50 text-primary sm:h-10 sm:w-10;
}
.piggy-row__main {
@apply min-w-0 flex-1;
}
.piggy-row__title {
@apply truncate text-sm font-bold text-gray-900;
}
.piggy-row__sub {
@apply mt-0.5 text-[11px] text-gray-400;
}
.piggy-row__amount {
@apply flex shrink-0 flex-col items-end leading-tight;
}
.piggy-row__amount strong {
@apply text-sm font-bold text-gray-900;
}
.piggy-row__amount span {
@apply text-[11px] text-gray-500;
}
.piggy-row__chevron {
@apply shrink-0 text-gray-300;
}
.piggy-empty {
@apply rounded-2xl bg-white px-5 py-10 text-center;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-empty__icon {
@apply mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-gray-50 text-primary;
}
.piggy-empty__title {
@apply m-0 text-base font-bold text-gray-900;
}
.piggy-empty__desc {
@apply m-0 mt-2 text-sm leading-6 text-gray-500;
}
@media (min-width: 768px) {
.piggy-page {
padding: 16px 16px 2rem;
}
.piggy-page__inner {
max-width: 28rem;
}
.piggy-bar {
border-radius: 12px;
}
}
</style>
+317
View File
@@ -0,0 +1,317 @@
<template>
<div class="piggy-info">
<div class="piggy-info__inner">
<header class="piggy-info__hero">
<div class="piggy-info__top">
<button type="button" class="piggy-info__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-info__title">{{ fund?.title || 'قلک' }}</h1>
<button type="button" class="piggy-info__btn" aria-label="حذف" @click="onDelete">
<BasicIcon icon="vuesax-linear:trash" size="18" />
</button>
</div>
<a-spin :spinning="busy">
<div class="piggy-info__balance-block">
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
<p class="piggy-info__label">موجودی قلک</p>
<p class="piggy-info__value" dir="ltr">
{{ formatPiggyAmount(potGold) }}
<span>گرم</span>
</p>
<p class="piggy-info__free">
آزاد قابل واریز:
<strong dir="ltr">{{ formatPiggyAmount(freeGold) }}</strong>
گرم
</p>
</div>
</a-spin>
</header>
<div class="piggy-info__sheet">
<div class="piggy-info__actions">
<BasicButton class="piggy-info__action" size="middle" @click="openTx(false)">
<div class="piggy-info__action-inner">
<BasicIcon icon="vuesax-linear:export" size="18" />
<span>برداشت به آزاد</span>
</div>
</BasicButton>
<BasicButton class="piggy-info__action" type="primary" size="middle" @click="openTx(true)">
<div class="piggy-info__action-inner">
<BasicIcon icon="vuesax-linear:import" size="18" />
<span>واریز از آزاد</span>
</div>
</BasicButton>
</div>
<section class="piggy-info__tx">
<h2 class="piggy-info__tx-title">تراکنش‌های این قلک</h2>
<div v-if="!transactions.length" class="piggy-info__tx-empty">
<BasicIcon icon="vuesax-linear:receipt-2" size="28" />
<p class="m-0 mt-2 text-sm text-gray-500">هنوز جابه‌جایی ثبت نشده</p>
</div>
<div v-else class="piggy-info__tx-list">
<div v-for="tx in transactions" :key="tx.id" class="piggy-info__tx-row">
<div class="piggy-info__tx-dot" />
<div class="min-w-0 flex-1 text-right">
<div class="truncate text-sm font-bold text-gray-900">
{{ tx.type_str || tx.type || 'تراکنش' }}
</div>
<div class="mt-0.5 text-[11px] text-gray-400">{{ tx.created_at || '' }}</div>
</div>
<div class="shrink-0 font-bold text-primary" dir="ltr">
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
</div>
</div>
</div>
</section>
</div>
</div>
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
<div class="flex flex-col gap-3 p-1">
<p class="m-0 text-xs leading-5 text-gray-500">
{{
isDeposit
? `از موجودی آزاد «${fund?.product?.title || 'محصول'}» — حداکثر ${formatPiggyAmount(freeGold)} گرم`
: `برگرداندن به آزاد — حداکثر ${formatPiggyAmount(potGold)} گرم`
}}
</p>
<BasicInput v-model:value="txValue" type="number" placeholder="مقدار به گرم" />
<BasicButton class="!w-full" type="primary" block :disabled="busy" @click="submitTx">
تأیید
</BasicButton>
</div>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import {
deleteFund,
fundDeposit,
fundWithdraw,
getFundById,
getFundTransactionTable,
} from '@/api/fund'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { message, Modal } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import type { Transaction } from '@/models/transaction'
import { formatPiggyAmount } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const busy = ref(false)
const fund = ref<Fund>()
const transactions = ref<Transaction[]>([])
const txVisible = ref(false)
const isDeposit = ref(true)
const txValue = ref<string | number | undefined>()
const fundId = computed(() => parseInt(String(router.currentRoute.value.params.id), 10))
const potGold = computed(() => {
const wallets = Array.isArray(fund.value?.wallet)
? fund.value!.wallet
: fund.value?.wallet
? [fund.value.wallet]
: []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
})
const freeGold = computed(() => {
const productId = Number(fund.value?.product_id ?? 0)
if (!productId) return 0
const cards = assetsSummary.value?.inventory_cards ?? []
const card = cards.find((c: any) => Number(c.product_id ?? c.id) === productId)
if (card && card.quantity != null) return Number(card.quantity)
return Number(assetsSummary.value?.metal_totals?.[0]?.weight ?? 0)
})
function goBack() {
router.back()
}
function openTx(deposit: boolean) {
isDeposit.value = deposit
txValue.value = undefined
txVisible.value = true
}
async function submitTx() {
const value = Number(txValue.value)
if (!Number.isFinite(value) || value <= 0) {
message.error('مقدار نامعتبر است')
return
}
try {
busy.value = true
const payload = { fund_id: fundId.value, value }
if (isDeposit.value) await fundDeposit(payload)
else await fundWithdraw(payload)
message.success('انجام شد')
txVisible.value = false
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
function onDelete() {
if (potGold.value > 0) {
message.error('ابتدا موجودی قلک را صفر کنید')
return
}
Modal.confirm({
title: 'حذف قلک',
content: 'آیا از حذف این قلک مطمئن هستید؟',
okText: 'حذف',
cancelText: 'انصراف',
async onOk() {
await deleteFund(fundId.value)
await fundStore.getMyPiggyFunds()
message.success('حذف شد')
router.replace({ name: 'piggy' })
},
})
}
async function reload() {
fund.value = await getFundById(fundId.value)
const walletId = Array.isArray(fund.value?.wallet)
? fund.value?.wallet?.[0]?.id
: (fund.value?.wallet as { id?: number } | undefined)?.id
if (walletId) {
const table = await getFundTransactionTable({ wallet_id: walletId } as any)
transactions.value = (table?.rows ?? []) as Transaction[]
} else {
transactions.value = []
}
await loadWalletAssets()
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-info {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-info__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-info__hero {
@apply bg-primary px-3 pb-8 pt-3 text-white;
}
.piggy-info__top {
@apply flex items-center justify-between gap-2;
}
.piggy-info__btn {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-white/15 text-white;
}
.piggy-info__title {
@apply m-0 min-w-0 flex-1 truncate text-center text-base font-bold text-white;
}
.piggy-info__balance-block {
@apply mt-6 px-2 text-center;
}
.piggy-info__product {
@apply m-0 inline-flex max-w-full truncate rounded-full bg-white/15 px-3 py-1 text-xs text-white/90;
}
.piggy-info__label {
@apply m-0 mt-4 text-sm text-white/75;
}
.piggy-info__value {
@apply m-0 mt-1 flex flex-wrap items-baseline justify-center gap-2 text-3xl font-bold sm:text-4xl;
}
.piggy-info__value span {
@apply text-sm font-medium text-white/75;
}
.piggy-info__free {
@apply m-0 mt-3 text-xs leading-5 text-white/70;
}
.piggy-info__free strong {
@apply text-white;
}
.piggy-info__sheet {
@apply relative z-10 -mt-4 rounded-t-3xl bg-gray-50 px-3 pt-5 sm:px-4;
}
.piggy-info__actions {
@apply flex flex-col gap-2 sm:flex-row;
}
.piggy-info__action {
@apply !w-full sm:!flex-1;
}
.piggy-info__action-inner {
@apply flex items-center justify-center gap-2 text-sm;
}
.piggy-info__tx {
@apply mt-5;
}
.piggy-info__tx-title {
@apply m-0 mb-3 text-base font-bold text-gray-900;
}
.piggy-info__tx-empty {
@apply flex flex-col items-center rounded-2xl bg-white px-4 py-10 text-gray-400;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-info__tx-list {
@apply flex flex-col gap-2;
}
.piggy-info__tx-row {
@apply flex min-w-0 items-center gap-3 rounded-2xl bg-white p-3;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-info__tx-dot {
@apply h-2.5 w-2.5 shrink-0 rounded-full bg-primary;
}
@media (min-width: 768px) {
.piggy-info {
padding: 16px 16px 2rem;
}
.piggy-info__inner {
max-width: 26rem;
overflow: hidden;
border-radius: 16px;
}
.piggy-info__hero {
border-radius: 16px 16px 0 0;
}
.piggy-info__actions {
flex-direction: row;
}
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<template>
<div class="piggy-create">
<div class="piggy-create__inner">
<header class="piggy-bar">
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-bar__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
<span class="w-9 shrink-0" />
</header>
<a-spin :spinning="busy">
<div class="piggy-create__body">
<section class="piggy-create__step">
<div class="piggy-create__step-head">
<span class="piggy-create__step-num">۱</span>
<div class="min-w-0">
<h2 class="piggy-create__step-title">محصول</h2>
<p class="piggy-create__step-desc">این قلک فقط برای همین محصول پر می‌شود</p>
</div>
</div>
<div v-if="editId" class="piggy-create__locked">
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
</div>
<div v-else class="piggy-create__products">
<button
v-for="p in products"
:key="p.id"
type="button"
class="piggy-create__product"
:class="{ 'piggy-create__product--on': productId === p.id }"
@click="productId = p.id"
>
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="min-w-0 truncate">{{ p.title || `محصول ${p.id}` }}</span>
</button>
</div>
</section>
<section class="piggy-create__step">
<div class="piggy-create__step-head">
<span class="piggy-create__step-num">۲</span>
<div class="min-w-0">
<h2 class="piggy-create__step-title">نام قلک</h2>
<p class="piggy-create__step-desc">مثلاً پس‌انداز، هدیه یا ازدواج</p>
</div>
</div>
<div class="piggy-create__title-row">
<div class="piggy-create__title-icon">
<BasicIcon :icon="piggyIconName(title)" size="22" />
</div>
<BasicInput v-model:value="title" class="!min-w-0 !flex-1" placeholder="عنوان قلک" maxlength="25" />
</div>
<div class="piggy-create__presets">
<button
v-for="preset in presets"
:key="preset"
type="button"
class="piggy-create__chip"
:class="{ 'piggy-create__chip--on': title === preset }"
@click="title = preset"
>
{{ preset }}
</button>
</div>
</section>
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
{{ editId ? 'ذخیره' : 'ساخت قلک' }}
</BasicButton>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { createFund, editFund, getFundById } from '@/api/fund'
import { getProducts } from '@/api/products'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Product } from '@/models/products'
import { PIGGY_PRESETS, piggyIconName } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const title = ref('پس انداز')
const productId = ref<number | undefined>()
const products = ref<Product[]>([])
const lockedProductTitle = ref('')
const presets = [...PIGGY_PRESETS]
const editId = computed(() => {
const raw = router.currentRoute.value.query.id
const n = parseInt(String(raw ?? ''), 10)
return Number.isFinite(n) && n > 0 ? n : 0
})
function goBack() {
router.back()
}
async function submit() {
if (!title.value.trim()) {
message.error('عنوان قلک را وارد کنید')
return
}
if (!editId.value && !productId.value) {
message.error('محصول قلک را انتخاب کنید')
return
}
try {
busy.value = true
if (editId.value) {
await editFund(editId.value, { title: title.value.trim() })
} else {
await createFund({ title: title.value.trim(), product_id: productId.value })
}
await fundStore.getMyPiggyFunds()
message.success(editId.value ? 'قلک ویرایش شد' : 'قلک ساخته شد')
router.replace({ name: 'piggy' })
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
const list = await getProducts()
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
if (editId.value) {
const fund = await getFundById(editId.value)
title.value = fund?.title ?? ''
productId.value = fund?.product_id
lockedProductTitle.value = fund?.product?.title || ''
} else if (products.value.length === 1) {
productId.value = products.value[0]?.id
}
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-create {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-create__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 12px;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
}
.piggy-bar__title {
margin: 0;
padding: 0 8px;
font-size: 1rem;
font-weight: 700;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.piggy-bar__btn {
display: flex;
width: 36px;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: none;
border-radius: 9999px;
background: #f3f4f6;
color: #1f2937;
}
.piggy-create__body {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
}
.piggy-create__step {
border-radius: 16px;
background: #fff;
padding: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-create__step-head {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.piggy-create__step-num {
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--App-primary, #0a1b37);
color: #fff;
font-size: 0.875rem;
font-weight: 700;
}
.piggy-create__step-title {
margin: 0;
font-size: 0.875rem;
font-weight: 700;
color: #111827;
}
.piggy-create__step-desc {
margin: 4px 0 0;
font-size: 0.75rem;
color: #6b7280;
}
.piggy-create__products {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.piggy-create__product {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 8px;
border: none;
border-radius: 12px;
background: #f9fafb;
padding: 10px 14px;
text-align: right;
font-size: 0.875rem;
color: #374151;
}
.piggy-create__product--on {
background: var(--App-primary, #0a1b37);
color: #fff;
}
.piggy-create__locked {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
border-radius: 12px;
background: #f9fafb;
padding: 12px;
font-size: 0.875rem;
font-weight: 700;
color: #1f2937;
}
.piggy-create__title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.piggy-create__title-icon {
display: flex;
width: 44px;
height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #f9fafb;
color: var(--App-primary, #0a1b37);
}
.piggy-create__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.piggy-create__chip {
border: none;
border-radius: 9999px;
background: #f3f4f6;
padding: 8px 12px;
font-size: 0.75rem;
color: #4b5563;
}
.piggy-create__chip--on {
background: var(--App-primary, #0a1b37);
color: #fff;
}
@media (min-width: 768px) {
.piggy-create {
padding: 16px 16px 2rem;
}
.piggy-create__inner {
max-width: 26rem;
}
.piggy-bar {
border-radius: 12px;
}
}
</style>
+73
View File
@@ -0,0 +1,73 @@
import type { Fund } from '@/models/fund'
export const PIGGY_PRESETS = [
'پس انداز',
'هدیه',
'ازدواج',
'تعمیرات',
'گردش',
'خرید لوازم منزل',
] as const
/** آیکون vuesax برای عنوان قلک — بدون تصویر سه‌بعدی گیج‌کننده */
export function piggyIconName(title?: string | null): string {
switch ((title ?? '').trim()) {
case 'خرید لوازم منزل':
return 'vuesax-linear:house-2'
case 'هدیه':
return 'vuesax-linear:gift'
case 'گردش':
return 'vuesax-linear:airplane'
case 'ازدواج':
return 'vuesax-linear:heart'
case 'تعمیرات':
return 'vuesax-linear:setting-2'
case 'پس انداز':
default:
return 'vuesax-linear:safe-home'
}
}
export function fundWalletGold(item?: Fund | null): number {
if (!item) return 0
const wallets = Array.isArray(item.wallet) ? item.wallet : item.wallet ? [item.wallet] : []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
}
export function fundProductLabel(item?: Fund | null): string {
if (!item) return '—'
return item.product?.title || (item as { product_title?: string }).product_title || '—'
}
export function fundProductId(item?: Fund | null): number {
return Number(item?.product_id ?? item?.product?.id ?? 0)
}
export function formatPiggyAmount(value: number): string {
const n = Number(value) || 0
if (Math.abs(n) < 0.0005) return '۰'
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
}
export type PiggyProductGroup = {
productId: number
productTitle: string
totalGold: number
funds: Fund[]
}
export function groupFundsByProduct(funds: Fund[]): PiggyProductGroup[] {
const map = new Map<number, PiggyProductGroup>()
for (const fund of funds) {
const productId = fundProductId(fund) || -1
const productTitle = fundProductLabel(fund)
let group = map.get(productId)
if (!group) {
group = { productId, productTitle, totalGold: 0, funds: [] }
map.set(productId, group)
}
group.funds.push(fund)
group.totalGold += fundWalletGold(fund)
}
return [...map.values()].sort((a, b) => a.productTitle.localeCompare(b.productTitle, 'fa'))
}
@@ -223,7 +223,7 @@
<div>قیمت:</div> <div>قیمت:</div>
<div class="flex items-center"> <div class="flex items-center">
<div class="font-bold"> <div class="font-bold">
{{ usePriceWithoutType(props?.ProductItem?.price) }} {{ usePriceWithoutType(isBuy ? buyTotalPrice : sellTotalPrice) }}
</div> </div>
<div class="text-xs mr-1">ریال</div> <div class="text-xs mr-1">ریال</div>
</div> </div>
@@ -2,11 +2,11 @@
<a-form <a-form
ref="formRef" ref="formRef"
:model="userFormState" :model="userFormState"
:class="showSubmit ? 'user-profile-form-mobile' : '!mt-5'" :class="formClass"
name="basic" name="basic"
@finish="ok" @finish="ok"
> >
<div :class="showSubmit ? 'user-profile-form-mobile__scroll' : undefined"> <div :class="scrollClass">
<a-form-item :rules="[isValidPersian()]" label="نام" name="fname"> <a-form-item :rules="[isValidPersian()]" label="نام" name="fname">
<BasicInput v-model:value="userFormState.fname" /> <BasicInput v-model:value="userFormState.fname" />
</a-form-item> </a-form-item>
@@ -28,10 +28,10 @@
<BasicInput v-model:value="userFormState.city" /> <BasicInput v-model:value="userFormState.city" />
</a-form-item> </a-form-item>
</div> </div>
<div v-if="showSubmit" class="user-profile-form-mobile__footer"> <div v-if="showSubmit" :class="footerClass">
<BasicButton <BasicButton
:disabled="loading" :disabled="loading"
class="w-full py-2" class="user-profile-form__submit"
html-type="submit" html-type="submit"
type="primary" type="primary"
> >
@@ -43,16 +43,34 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue' import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue' import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker' import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import { useUserPageContext } from '../composables/useUserPage' import { useUserPageContext } from '../composables/useUserPage'
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
showSubmit?: boolean showSubmit?: boolean
layout?: 'mobile' | 'desktop'
}>(), }>(),
{ showSubmit: false }, { showSubmit: false, layout: 'mobile' },
)
const formClass = computed(() => {
if (!props.showSubmit) return '!mt-5'
return props.layout === 'desktop' ? 'user-profile-form-desktop' : 'user-profile-form-mobile'
})
const scrollClass = computed(() => {
if (!props.showSubmit || props.layout === 'desktop') return undefined
return 'user-profile-form-mobile__scroll'
})
const footerClass = computed(() =>
props.layout === 'desktop'
? 'user-profile-form-desktop__footer'
: 'user-profile-form-mobile__footer',
) )
const { const {
@@ -90,4 +108,17 @@ const {
background: #fff; background: #fff;
border-top: 1px solid #f3f4f6; border-top: 1px solid #f3f4f6;
} }
.user-profile-form-desktop__footer {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #f3f4f6;
}
.user-profile-form__submit {
width: 100%;
min-height: 42px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style> </style>
+57 -1
View File
@@ -1,5 +1,5 @@
import { computed, inject, onMounted, provide, ref, watch, type InjectionKey } from 'vue' import { computed, inject, onMounted, provide, ref, watch, type InjectionKey } from 'vue'
import { loading } from '@/utils' import { loading, removeToken } from '@/utils'
import { import {
guideTypePassword, guideTypePassword,
isValidPersian, isValidPersian,
@@ -12,12 +12,21 @@ import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me' import { updateMe, updatePass } from '@/api/me'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { getUserAvatarUrl } from '@/utils/file' import { getUserAvatarUrl } from '@/utils/file'
import { useConfigStore } from '@/store/configStore' import { useConfigStore } from '@/store/configStore'
import { getBoursecuActionIconSrc } from '@/utils/config' import { getBoursecuActionIconSrc } from '@/utils/config'
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare' import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
import { normalizeAuthFormFields } from '@/utils/persianDigits' import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { useSettingsStore } from '@/store/settingsStore' import { useSettingsStore } from '@/store/settingsStore'
import { useSupportMessenger } from '@/composables/useSupportMessenger'
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
import { resetHomePageData } from '../../home/composables/useHomePageData'
import { resetWalletPageData } from '../../wallet/composables/useWalletPageData'
import { resetAddressPageData } from '../../address/composables/useAddressPageData'
import { resetTransactionsPageData } from '../../transactions/composables/useTransactionsPageData'
import { resetProductsPageData } from '../../products/composables/useProductsPageData'
export interface UserMenuAction { export interface UserMenuAction {
id: string id: string
@@ -26,6 +35,7 @@ export interface UserMenuAction {
iconSrc?: string iconSrc?: string
onClick: () => void onClick: () => void
visible: boolean visible: boolean
badge?: number
} }
export interface UserPageContext { export interface UserPageContext {
@@ -39,6 +49,7 @@ export interface UserPageContext {
isLegal: ReturnType<typeof computed<{ title: string; color: string; icon: string }>> isLegal: ReturnType<typeof computed<{ title: string; color: string; icon: string }>>
shareButtonLabel: ReturnType<typeof computed<string>> shareButtonLabel: ReturnType<typeof computed<string>>
menuActions: ReturnType<typeof computed<UserMenuAction[]>> menuActions: ReturnType<typeof computed<UserMenuAction[]>>
showLogoutConfirm: ReturnType<typeof ref<boolean>>
loading: typeof loading loading: typeof loading
guideTypePassword: typeof guideTypePassword guideTypePassword: typeof guideTypePassword
isValidPersian: typeof isValidPersian isValidPersian: typeof isValidPersian
@@ -48,6 +59,8 @@ export interface UserPageContext {
shareCode: () => void shareCode: () => void
ok: () => void ok: () => void
submitPassForm: () => void submitPassForm: () => void
requestLogout: () => void
logout: () => Promise<void>
} }
export const USER_PAGE_CONTEXT_KEY: InjectionKey<UserPageContext> = Symbol('userPageContext') export const USER_PAGE_CONTEXT_KEY: InjectionKey<UserPageContext> = Symbol('userPageContext')
@@ -65,11 +78,15 @@ export function useUserPage() {
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>() const formPassRef = ref<FormInstance>()
const userStore = useUserStore() const userStore = useUserStore()
const walletStore = useWalletStore()
const { clearWalletAssets } = useWalletAssets()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const nameProject = useConfigStore().config.name_fa const nameProject = useConfigStore().config.name_fa
const editPassVisible = ref(false) const editPassVisible = ref(false)
const editPasswordVisible = ref(false) const editPasswordVisible = ref(false)
const showLogoutConfirm = ref(false)
const localAvatarUrl = ref<string | null>(null) const localAvatarUrl = ref<string | null>(null)
const { openSupportMessenger } = useSupportMessenger()
const profileAvatarUrl = computed( const profileAvatarUrl = computed(
() => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo), () => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo),
@@ -121,6 +138,13 @@ export function useUserPage() {
onClick: () => router.push({ name: 'notifyTrigger' }), onClick: () => router.push({ name: 'notifyTrigger' }),
visible: settingsStore.isSettingTruthy('has_page_price_trigger'), visible: settingsStore.isSettingTruthy('has_page_price_trigger'),
}, },
{
id: 'piggy',
label: 'قلک‌ها',
icon: 'vuesax-linear:safe-home',
onClick: () => router.push({ name: 'piggy' }),
visible: settingsStore.isSettingTruthy('enable_product_funds'),
},
{ {
id: 'about', id: 'about',
label: 'درباره ما', label: 'درباره ما',
@@ -128,6 +152,16 @@ export function useUserPage() {
onClick: () => router.push({ name: 'about' }), onClick: () => router.push({ name: 'about' }),
visible: true, visible: true,
}, },
{
id: 'support',
label: 'پشتیبانی',
icon: 'vuesax-linear:messages-2',
onClick: () => {
void openSupportMessenger()
},
visible: isUserSupportChatEnabled(),
badge: unreadSupportCount.value > 0 ? unreadSupportCount.value : undefined,
},
{ {
id: 'physicalReceive', id: 'physicalReceive',
label: 'تحویل فیزیکی', label: 'تحویل فیزیکی',
@@ -224,6 +258,25 @@ export function useUserPage() {
}) })
} }
function requestLogout() {
showLogoutConfirm.value = true
}
async function logout() {
userStore.userInfo = undefined
walletStore.walletInfo = undefined
clearWalletAssets()
resetHomePageData()
resetWalletPageData()
resetAddressPageData()
resetTransactionsPageData()
resetProductsPageData()
removeToken()
showLogoutConfirm.value = false
await router.push({ name: 'login' })
window.location.reload()
}
onMounted(() => { onMounted(() => {
setForm() setForm()
if (userStore.userInfo?.is_legal === 0) { if (userStore.userInfo?.is_legal === 0) {
@@ -251,6 +304,7 @@ export function useUserPage() {
isLegal, isLegal,
shareButtonLabel, shareButtonLabel,
menuActions, menuActions,
showLogoutConfirm,
loading, loading,
guideTypePassword, guideTypePassword,
isValidPersian, isValidPersian,
@@ -260,6 +314,8 @@ export function useUserPage() {
shareCode, shareCode,
ok, ok,
submitPassForm, submitPassForm,
requestLogout,
logout,
} }
provide(USER_PAGE_CONTEXT_KEY, context) provide(USER_PAGE_CONTEXT_KEY, context)
@@ -50,15 +50,17 @@
</button> </button>
</div> </div>
<button <div class="duser-profile__actions">
type="button" <button
class="duser-profile__btn duser-profile__btn--primary" type="button"
@click="editPassVisible = true" class="duser-profile__btn duser-profile__btn--primary"
> @click="editPassVisible = true"
<BasicIcon icon="vuesax-linear:edit-2" size="16" /> >
<span>ویرایش اطلاعات کاربری</span> <BasicIcon icon="vuesax-linear:edit-2" size="16" />
</button> <span>ویرایش اطلاعات کاربری</span>
<InstallButton class="duser-profile__install" /> </button>
<InstallButton class="duser-profile__install" />
</div>
</section> </section>
<section class="duser-surface duser-actions"> <section class="duser-surface duser-actions">
@@ -79,6 +81,12 @@
class="duser-actions__icon-image" class="duser-actions__icon-image"
/> />
<BasicIcon v-else :icon="action.icon" size="18" /> <BasicIcon v-else :icon="action.icon" size="18" />
<span
v-if="action.badge && action.badge > 0"
class="duser-actions__badge"
>
{{ action.badge > 99 ? '99+' : action.badge }}
</span>
</span> </span>
<span class="duser-actions__label">{{ action.label }}</span> <span class="duser-actions__label">{{ action.label }}</span>
<BasicIcon <BasicIcon
@@ -87,6 +95,21 @@
size="16" size="16"
/> />
</button> </button>
<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>
<BasicIcon
class="duser-actions__chevron"
icon="vuesax-linear:arrow-left"
size="16"
/>
</button>
</div> </div>
</section> </section>
</div> </div>
@@ -107,6 +130,7 @@ const {
menuActions, menuActions,
copyCode, copyCode,
shareCode, shareCode,
requestLogout,
} = useUserPageContext() } = useUserPageContext()
</script> </script>
@@ -4,12 +4,16 @@
min-height: 100%; min-height: 100%;
background: #f6f7f9; background: #f6f7f9;
color: #111827; color: #111827;
padding-bottom: 88px;
box-sizing: border-box;
} }
.duser__container { .duser__container {
width: 100%;
max-width: 960px; max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 28px 36px 40px; padding: 28px 36px 40px;
box-sizing: border-box;
} }
.duser-topbar { .duser-topbar {
@@ -33,6 +37,7 @@
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
padding: 8px 0; padding: 8px 0;
flex-shrink: 0;
&:hover { &:hover {
color: #111827; color: #111827;
@@ -63,7 +68,7 @@
.duser-grid { .duser-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); grid-template-columns: minmax(0, 1.15fr) minmax(260px, 320px);
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
@@ -73,9 +78,14 @@
border-radius: 14px; border-radius: 14px;
padding: 24px; padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-width: 0;
} }
.duser-profile { .duser-profile {
display: flex;
flex-direction: column;
min-width: 0;
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -83,6 +93,7 @@
margin-bottom: 24px; margin-bottom: 24px;
padding-bottom: 20px; padding-bottom: 20px;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
min-width: 0;
} }
&__avatar { &__avatar {
@@ -103,6 +114,7 @@
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #111827; color: #111827;
margin: 0 0 4px; margin: 0 0 4px;
word-break: break-word;
} }
&__phone { &__phone {
@@ -111,6 +123,7 @@
margin: 0; margin: 0;
direction: ltr; direction: ltr;
text-align: right; text-align: right;
word-break: break-word;
} }
&__fields { &__fields {
@@ -122,12 +135,13 @@
&__field { &__field {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; align-items: flex-start;
gap: 12px; gap: 6px;
padding: 12px 14px; padding: 12px 14px;
border-radius: 10px; border-radius: 10px;
background: #f9fafb; background: #f9fafb;
min-width: 0;
} }
&__label { &__label {
@@ -137,16 +151,18 @@
} }
&__value { &__value {
width: 100%;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #111827; color: #111827;
direction: ltr; direction: ltr;
text-align: left; text-align: right;
word-break: break-word;
} }
&__referral-actions { &__referral-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -156,6 +172,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
min-width: 0;
width: 100%;
padding: 10px 14px; padding: 10px 14px;
border-radius: 10px; border-radius: 10px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
@@ -167,17 +185,27 @@
cursor: pointer; cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, border-color 0.15s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover { &:hover {
background: #f9fafb; background: #f9fafb;
border-color: #d1d5db; border-color: #d1d5db;
} }
&--primary { &--primary {
width: 100%;
background: @App-primary-color; background: @App-primary-color;
border-color: @App-primary-color; border-color: @App-primary-color;
color: #fff; color: #fff;
span {
white-space: normal;
}
&:hover { &:hover {
opacity: 0.92; opacity: 0.92;
color: #fff; color: #fff;
@@ -185,13 +213,25 @@
} }
} }
&__actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
&__install { &__install {
width: 100%; width: 100%;
margin-top: 10px;
&.mt-2 {
margin-top: 0 !important;
}
} }
} }
.duser-actions { .duser-actions {
min-width: 0;
&__title { &__title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -223,6 +263,16 @@
background: #f9fafb; background: #f9fafb;
border-color: #e5e7eb; 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 { &__icon {
@@ -236,6 +286,29 @@
color: #374151; color: #374151;
flex-shrink: 0; flex-shrink: 0;
overflow: visible; overflow: visible;
position: relative;
&--logout {
background: #fef2f2;
color: #ef4444;
}
}
&__badge {
position: absolute;
top: -6px;
left: -6px;
z-index: 1;
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;
} }
&__icon-image { &__icon-image {
@@ -247,9 +320,16 @@
&__label { &__label {
flex: 1; flex: 1;
min-width: 0;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #111827; color: #111827;
line-height: 1.35;
word-break: break-word;
&--logout {
color: #ef4444;
}
} }
&__chevron { &__chevron {
@@ -258,8 +338,38 @@
} }
} }
@media (max-width: 860px) { @media (max-width: 1080px) {
.duser-grid { .duser-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.duser-profile__referral-actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.duser__container {
padding: 20px 16px 32px;
}
.duser-topbar {
flex-wrap: wrap;
margin-bottom: 20px;
padding-bottom: 16px;
&__title {
font-size: 22px;
}
}
.duser-surface {
padding: 18px 16px;
}
}
@media (min-width: 1081px) and (max-width: 1240px) {
.duser-grid {
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
}
} }
+66 -14
View File
@@ -22,18 +22,17 @@
v-else v-else
v-model:visible="editPassVisible" v-model:visible="editPassVisible"
title="ویرایش اطلاعات" title="ویرایش اطلاعات"
width="520px" width="440px"
destroy-on-close destroy-on-close
custom-footer
wrap-class-name="user-edit-modal"
:loading="loading" :loading="loading"
@ok="ok"
> >
<div class="flex justify-end mb-4"> <div class="user-edit-modal__status" :class="isLegal.color">
<div class="flex flex-col items-center"> <BasicIcon :icon="isLegal.icon" size="18" :class="isLegal.color" />
<BasicIcon :icon="isLegal.icon" size="30" :class="isLegal.color" /> <span>{{ isLegal.title }}</span>
<p class="text-[10px] mt-1" :class="isLegal.color">{{ isLegal.title }}</p>
</div>
</div> </div>
<UserProfileForm /> <UserProfileForm show-submit layout="desktop" />
</BasicModal> </BasicModal>
<!-- ویرایش رمزعبور — موبایل --> <!-- ویرایش رمزعبور — موبایل -->
@@ -51,11 +50,13 @@
title="ویرایش رمزعبور" title="ویرایش رمزعبور"
width="480px" width="480px"
destroy-on-close destroy-on-close
custom-footer
:loading="loading" :loading="loading"
@ok="submitPassForm"
> >
<UserPasswordForm /> <UserPasswordForm show-submit />
</BasicModal> </BasicModal>
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout" />
</div> </div>
</template> </template>
@@ -64,6 +65,7 @@ import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue' import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout' import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import LogoutConfirmDialog from '@/views/base/home/components/LogoutConfirmDialog.vue'
import { useUserPage } from './composables/useUserPage' import { useUserPage } from './composables/useUserPage'
import MobileUserView from './mobile/MobileUserView.vue' import MobileUserView from './mobile/MobileUserView.vue'
import DesktopUserView from './desktop/DesktopUserView.vue' import DesktopUserView from './desktop/DesktopUserView.vue'
@@ -77,8 +79,8 @@ const {
editPasswordVisible, editPasswordVisible,
isLegal, isLegal,
loading, loading,
ok, showLogoutConfirm,
submitPassForm, logout,
} = useUserPage() } = useUserPage()
</script> </script>
@@ -103,10 +105,60 @@ const {
<style lang="less"> <style lang="less">
.reject { .reject {
color: red; color: #dc2626;
} }
.success { .success {
color: green; color: #16a34a;
}
.user-edit-modal {
.ant-modal-content {
border-radius: 16px;
padding-top: 0.75rem;
}
.ant-modal-header {
margin-bottom: 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.ant-modal-title {
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
.ant-modal-body {
padding-top: 1rem;
}
#body {
max-height: none;
overflow: visible;
padding-top: 0;
padding-bottom: 0.25rem;
}
}
.user-edit-modal__status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.4rem 0.75rem;
border-radius: 999px;
background: #f8fafc;
font-size: 0.75rem;
font-weight: 600;
&.success {
background: #f0fdf4;
}
&.reject {
background: #fef2f2;
}
} }
</style> </style>
+51 -7
View File
@@ -72,16 +72,36 @@
@click="action.onClick" @click="action.onClick"
> >
<div class="flex"> <div class="flex">
<img <span class="user-menu-icon-wrap absolute right-8">
v-if="action.iconSrc" <img
:src="action.iconSrc" v-if="action.iconSrc"
alt="" :src="action.iconSrc"
class="user-menu-icon-image absolute right-8" alt=""
/> class="user-menu-icon-image"
<BasicIcon v-else :icon="action.icon" class="absolute right-8" /> />
<BasicIcon v-else :icon="action.icon" />
<span
v-if="action.badge && action.badge > 0"
class="user-menu-badge"
>
{{ action.badge > 99 ? '99+' : action.badge }}
</span>
</span>
<div>{{ action.label }}</div> <div>{{ action.label }}</div>
</div> </div>
</BasicButton> </BasicButton>
<BasicButton
class="mt-2 w-full"
icon=""
iconSize="18"
size="large"
@click="requestLogout"
>
<div class="flex">
<BasicIcon icon="vuesax-linear:logout" class="absolute right-8 text-red" />
<div>خروج</div>
</div>
</BasicButton>
</div> </div>
</div> </div>
</div> </div>
@@ -103,6 +123,7 @@ const {
menuActions, menuActions,
copyCode, copyCode,
shareCode, shareCode,
requestLogout,
} = useUserPageContext() } = useUserPageContext()
</script> </script>
@@ -126,10 +147,33 @@ const {
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
} }
.user-menu-icon-wrap {
position: absolute;
right: 2rem;
display: inline-flex;
}
.user-menu-icon-image { .user-menu-icon-image {
width: 18px; width: 18px;
height: 18px; height: 18px;
object-fit: contain; object-fit: contain;
transform: scale(2.2); transform: scale(2.2);
} }
.user-menu-badge {
position: absolute;
top: -8px;
left: -10px;
z-index: 1;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
</style> </style>
+11
View File
@@ -293,6 +293,10 @@ import {
} from "@/utils/config"; } from "@/utils/config";
import { normalizeAuthFormFields } from '@/utils/persianDigits' import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp' import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
import {getConfig} from "@/api/config"; import {getConfig} from "@/api/config";
import { usePwaInstall } from '@/composables/usePwaInstall' import { usePwaInstall } from '@/composables/usePwaInstall'
import {getProducts} from "@/api/products"; import {getProducts} from "@/api/products";
@@ -426,11 +430,18 @@ function phoneVerification() {
stepTwoVerify.value = true stepTwoVerify.value = true
reset() reset()
} else { } else {
// نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await verifyToken(userFormState) const { access_token } = await verifyToken(userFormState)
await login(access_token) await login(access_token)
} }
} else { } else {
userFormState.token = undefined userFormState.token = undefined
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await loginCustomer(userFormState) const { access_token } = await loginCustomer(userFormState)
await login(access_token) await login(access_token)
} }
@@ -19,6 +19,10 @@ import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollRe
import { normalizeAuthFormFields } from '@/utils/persianDigits' import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp' import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import { usePwaInstall } from '@/composables/usePwaInstall' import { usePwaInstall } from '@/composables/usePwaInstall'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
export function useLoginForm() { export function useLoginForm() {
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss') const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
@@ -109,11 +113,18 @@ export function useLoginForm() {
stepTwoVerify.value = true stepTwoVerify.value = true
reset() reset()
} else { } else {
// نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await verifyToken(userFormState) const { access_token } = await verifyToken(userFormState)
await login(access_token) await login(access_token)
} }
} else { } else {
userFormState.token = undefined userFormState.token = undefined
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await loginCustomer(userFormState) const { access_token } = await loginCustomer(userFormState)
await login(access_token) await login(access_token)
} }
@@ -1,6 +1,10 @@
<template> <template>
<div class="dauth dauth--boursecu"> <div class="dauth dauth--boursecu">
<div class="dauth__layout"> <div class="dauth__layout">
<main class="dauth-form-panel">
<slot />
</main>
<aside class="dauth-brand dauth-brand--boursecu"> <aside class="dauth-brand dauth-brand--boursecu">
<div class="dauth-brand__pattern" /> <div class="dauth-brand__pattern" />
<div class="dauth-brand__content"> <div class="dauth-brand__content">
@@ -22,10 +26,6 @@
</div> </div>
<div class="dauth-brand__footer">نسخه {{ version }}</div> <div class="dauth-brand__footer">نسخه {{ version }}</div>
</aside> </aside>
<main class="dauth-form-panel">
<slot />
</main>
</div> </div>
</div> </div>
</template> </template>
@@ -10,7 +10,8 @@
.dauth__layout { .dauth__layout {
display: grid; display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(420px, 520px); /* فرم | برند — در RTL: برند چپ، فرم راست؛ نسبت نزدیک به توازن */
grid-template-columns: minmax(480px, 1fr) minmax(420px, 1.05fr);
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
} }
@@ -136,12 +137,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 48px 56px; padding: 48px 48px;
} }
.dauth-card { .dauth-card {
width: 100%; width: 100%;
max-width: 420px; max-width: 520px;
background: #ffffff; background: #ffffff;
border-radius: 16px; border-radius: 16px;
padding: 36px 40px; padding: 36px 40px;
@@ -471,7 +472,7 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.dauth__layout { .dauth__layout {
grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr); grid-template-columns: minmax(340px, 1fr) minmax(300px, 1fr);
} }
.dauth-brand { .dauth-brand {
@@ -0,0 +1,826 @@
<template>
<div class="support-composer">
<div class="support-composer__inner mx-auto w-full max-w-6xl">
<div
class="support-composer-panel transition-all"
:class="isDragOver ? 'is-drag-over' : ''"
@dragenter.prevent="isDragOver = true"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="onFileChange"
/>
<Transition name="support-reply-preview">
<div v-if="replyingTo" class="support-composer-reply">
<div class="support-composer-reply__bar" />
<div
v-if="replyingTo.attachment?.type === 'image'"
class="support-composer-reply__thumb"
>
<img
:src="replyingTo.attachment.url"
:alt="replyingTo.attachment.name || 'reply image'"
/>
</div>
<div class="support-composer-reply__body">
<div class="support-composer-reply__title">
پاسخ به {{ getReplySenderLabel(replyingTo.sender) }}
</div>
<div class="support-composer-reply__text">
{{ getReplyPreviewText(replyingTo.text) }}
</div>
</div>
<BasicButton class="support-composer-reply__close" @click="emit('cancel-reply')" danger>
<BasicIcon icon="vuesax-linear:add" class="rotate-45" />
</BasicButton>
</div>
</Transition>
<div v-if="attachments.length" class="mb-3 flex gap-2 overflow-x-auto pb-1">
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
draggable="true"
class="support-attachment-item group relative shrink-0 cursor-move overflow-hidden rounded-2xl border"
:class="
attachment.type === 'image'
? 'h-24 w-24'
: 'flex min-w-[240px] items-center gap-3 p-3'
"
@dragstart="onPreviewDragStart(index)"
@dragover.prevent
@drop.prevent="onPreviewDrop(index)"
>
<template v-if="attachment.type === 'image'">
<img
:src="attachment.url"
:alt="attachment.name"
class="h-full w-full object-cover"
/>
</template>
<template v-else>
<div
class="support-audio-badge flex h-10 w-10 items-center justify-center rounded-2xl"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M12 3a3 3 0 0 1 3 3v6a3 3 0 1 1-6 0V6a3 3 0 0 1 3-3Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M8 22h8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-[var(--colorText)]">
{{ attachment.name }}
</div>
<audio :src="attachment.url" controls class="mt-2 block w-full max-w-[160px]" />
</div>
</template>
<button
type="button"
class="support-remove-btn absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full opacity-100 transition md:opacity-0 md:group-hover:opacity-100"
@click="removeAttachment(attachment.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M18 6 6 18M6 6l12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<div
v-show="showRecorder || isRecording || recordedBlobUrl"
class="support-recorder-panel mb-3 overflow-hidden rounded-2xl border"
>
<div class="support-recorder-head border-b px-4 py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span
v-if="isRecording"
class="h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
/>
<span class="text-sm font-semibold text-[var(--colorText)]">
{{ isRecording ? 'در حال ضبط وویس...' : 'پیش‌نمایش وویس' }}
</span>
</div>
<span class="text-xs text-[var(--colorText)]/65">
{{ formattedRecordTime }}
</span>
</div>
</div>
<div class="px-3 py-3">
<div ref="waveformEl" class="min-h-[64px]" />
</div>
<div
v-if="recordedBlobUrl && !isRecording"
class="support-recorder-actions flex items-center gap-2 border-t px-4 py-3"
>
<button
type="button"
class="support-recorder-secondary rounded-xl border px-3 py-2 text-sm transition"
@click="discardRecordedVoice"
>
حذف وویس
</button>
<button
type="button"
class="support-recorder-primary rounded-xl px-3 py-2 text-sm transition"
@click="appendRecordedVoiceToAttachments"
>
افزودن به پیام
</button>
</div>
</div>
<div class="support-composer__row flex items-end gap-2">
<BasicButton
type="primary"
:disabled="disabled || isRecording || (!modelValue.trim() && attachments.length === 0)"
class="support-send-btn !h-12 !w-12 shrink-0 !p-0"
@mousedown.prevent
@click="submit"
>
<BasicIcon icon="vuesax-linear:direct-right" size="20" />
</BasicButton>
<div
class="support-textarea-shell flex min-h-12 min-w-0 flex-1 items-center rounded-2xl px-1.5 py-1"
>
<textarea
ref="textareaRef"
:value="modelValue"
:disabled="disabled || isRecording"
rows="1"
placeholder="پیامت را بنویس..."
class="composer-textarea w-full resize-none overflow-y-auto bg-transparent px-2 text-sm leading-7 outline-none py-[10px] !text-[16px]"
@input="onInput"
@keydown="onKeydown"
/>
<BasicButton
class="support-tool-btn flex !h-10 !w-10 shrink-0 items-center justify-center rounded-xl"
:disabled="disabled || isRecording"
type="link"
@mousedown.prevent
@click="openFilePicker"
>
<BasicIcon icon="vuesax-linear:gallery" size="20" />
</BasicButton>
</div>
</div>
<div
v-if="isDragOver"
class="support-drop-zone mt-3 rounded-2xl border border-dashed px-4 py-3 text-center text-sm"
>
عکس را اینجا رها کن
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import WaveSurfer from 'wavesurfer.js'
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.esm.js'
import type { ChatAttachment, ChatReplyPreview, ComposerSendPayload } from '@/models/support'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
const props = defineProps<{
modelValue: string
disabled?: boolean
replyingTo?: ChatReplyPreview | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', payload: ComposerSendPayload): void
(e: 'cancel-reply'): void
}>()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const waveformEl = ref<HTMLElement | null>(null)
const attachments = ref<ChatAttachment[]>([])
const isDragOver = ref(false)
const draggedPreviewIndex = ref<number | null>(null)
const shouldRefocusAfterSend = ref<boolean>(false)
const showRecorder = ref(false)
const isRecording = ref(false)
const recordTimeMs = ref(0)
const recordedBlob = ref<Blob | null>(null)
const recordedBlobUrl = ref('')
const recordedMimeType = ref('audio/webm')
let wavesurfer: WaveSurfer | null = null
let recordPlugin: ReturnType<typeof RecordPlugin.create> | null = null
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const getReplySenderLabel = (sender: string) => {
if (sender === 'user') return 'خودتان'
if (sender === 'support') return 'پشتیبانی'
return 'پیام'
}
const getReplyPreviewText = (text: string | undefined) => {
const value = String(text || '').trim()
return value || 'پیام'
}
const focusTextarea = async () => {
await nextTick()
const el = textareaRef.value
if (!el || props.disabled || isRecording.value) return
el.focus()
const length = el.value.length
el.setSelectionRange(length, length)
}
const formattedRecordTime = computed(() => {
const totalSeconds = Math.floor(recordTimeMs.value / 1000)
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0')
const seconds = String(totalSeconds % 60).padStart(2, '0')
return `${minutes}:${seconds}`
})
const resizeTextarea = () => {
if (!textareaRef.value) return
textareaRef.value.style.height = '48px'
textareaRef.value.style.height = `${Math.min(textareaRef.value.scrollHeight, 144)}px`
}
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
resizeTextarea()
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}
const openFilePicker = () => {
fileInputRef.value?.click()
}
const createImageAttachment = (file: File): ChatAttachment => ({
id: createId(),
type: 'image',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
file
})
const createAudioAttachment = (file: File, durationMs?: number): ChatAttachment => ({
id: createId(),
type: 'audio',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
durationMs,
file
})
const addImageFiles = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith('image/'))
if (!imageFiles.length) return
attachments.value.push(...imageFiles.map(createImageAttachment))
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
addImageFiles(files)
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const onDrop = (event: DragEvent) => {
isDragOver.value = false
const files = Array.from(event.dataTransfer?.files ?? [])
addImageFiles(files)
}
const onDragLeave = (event: DragEvent) => {
const target = event.currentTarget as HTMLElement | null
const relatedTarget = event.relatedTarget as Node | null
if (!target || (relatedTarget && target.contains(relatedTarget))) return
isDragOver.value = false
}
const removeAttachment = (id: string) => {
const target = attachments.value.find((item) => item.id === id)
if (target?.url.startsWith('blob:')) {
URL.revokeObjectURL(target.url)
}
attachments.value = attachments.value.filter((item) => item.id !== id)
}
const onPreviewDragStart = (index: number) => {
draggedPreviewIndex.value = index
}
const onPreviewDrop = (dropIndex: number) => {
if (draggedPreviewIndex.value === null) return
if (draggedPreviewIndex.value === dropIndex) return
const items = [...attachments.value]
const movedItem = items[draggedPreviewIndex.value]
items.splice(draggedPreviewIndex.value, 1)
items.splice(dropIndex, 0, movedItem)
attachments.value = items
draggedPreviewIndex.value = null
}
const initWaveSurfer = async () => {
if (!waveformEl.value || wavesurfer) return
recordPlugin = RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: true,
mediaRecorderTimeslice: 250,
mimeType: 'audio/webm'
})
wavesurfer = WaveSurfer.create({
container: waveformEl.value,
height: 64,
cursorWidth: 0,
barWidth: 3,
barGap: 2,
barRadius: 999,
normalize: true,
interact: true,
plugins: [recordPlugin]
})
recordPlugin.on('record-progress', (duration) => {
recordTimeMs.value = duration
})
recordPlugin.on('record-start', () => {
isRecording.value = true
recordTimeMs.value = 0
recordedBlob.value = null
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
})
recordPlugin.on('record-end', (blob) => {
isRecording.value = false
recordedBlob.value = blob
recordedMimeType.value = blob.type || 'audio/webm'
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordedBlobUrl.value = URL.createObjectURL(blob)
})
}
const requestMicrophonePermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
console.error('mic permission failed:', error)
return false
}
}
const toggleRecording = async () => {
if (props.disabled) return
try {
const granted = await requestMicrophonePermission()
if (!granted) return
if (!showRecorder.value) {
showRecorder.value = true
await nextTick()
}
await initWaveSurfer()
if (!recordPlugin) return
if (recordPlugin.isRecording()) {
recordPlugin.stopRecording()
} else {
await recordPlugin.startRecording()
}
} catch (error) {
console.error('Voice recording failed:', error)
}
}
const discardRecordedVoice = () => {
recordedBlob.value = null
recordTimeMs.value = 0
isRecording.value = false
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
showRecorder.value = false
wavesurfer?.empty()
}
const appendRecordedVoiceToAttachments = () => {
if (!recordedBlob.value) return
const extension = recordedMimeType.value.includes('ogg')
? 'ogg'
: recordedMimeType.value.includes('mp4')
? 'm4a'
: 'webm'
const file = new File([recordedBlob.value], `voice-${Date.now()}.${extension}`, {
type: recordedMimeType.value || 'audio/webm'
})
attachments.value.push(createAudioAttachment(file, recordTimeMs.value))
discardRecordedVoice()
}
const clearComposerAttachments = () => {
attachments.value.forEach((item) => {
if (item.url.startsWith('blob:')) {
URL.revokeObjectURL(item.url)
}
})
attachments.value = []
}
const submit = async () => {
const text = props.modelValue.trim()
if (!text && attachments.value.length === 0) return
if (props.disabled || isRecording.value) return
const payloadAttachments = attachments.value.map((item) => ({
...item,
url: item.file ? URL.createObjectURL(item.file) : item.url
}))
shouldRefocusAfterSend.value = true
emit('send', {
text,
attachments: payloadAttachments,
reply: props.replyingTo?.id ?? null,
replyTo: props.replyingTo ?? null
})
emit('update:modelValue', '')
clearComposerAttachments()
await nextTick()
resizeTextarea()
await focusTextarea()
}
watch(
() => props.modelValue,
() => {
resizeTextarea()
},
{ immediate: true }
)
onBeforeUnmount(() => {
clearComposerAttachments()
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordPlugin?.destroy()
wavesurfer?.destroy()
recordPlugin = null
wavesurfer = null
})
watch(
() => props.disabled,
async (disabled) => {
if (disabled) return
if (!shouldRefocusAfterSend.value) return
shouldRefocusAfterSend.value = false
await focusTextarea()
}
)
</script>
<style scoped lang="less">
.support-composer {
position: relative;
z-index: 3;
padding: 10px 12px 12px;
background: color-mix(in srgb, var(--bg, #fff) 82%, transparent);
border-top: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 12%, transparent);
box-shadow: none;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
font-family: var(--font-family, iran-sans), serif;
}
.support-composer__inner {
min-width: 0;
}
.support-composer-reply__thumb {
width: 42px;
height: 42px;
flex: 0 0 42px;
overflow: hidden;
border-radius: 12px;
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.08);
}
.support-composer-reply__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.support-composer-panel {
width: 100%;
min-width: 0;
}
.support-composer-panel.is-drag-over {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.06);
border-radius: 16px;
}
.support-attachment-item {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.18);
background: var(--bg, #fff);
}
.support-audio-badge {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
color: var(--colorPrimary, #2563eb);
}
.support-remove-btn {
background: rgb(0 0 0 / 0.7);
color: #fff;
}
.support-recorder-panel {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.16);
background: var(--bg, #fff);
}
.support-recorder-head,
.support-recorder-actions {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
}
.support-recorder-secondary {
background: transparent;
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.18);
color: var(--colorText, #111827);
}
.support-recorder-primary {
background: var(--colorPrimary, #2563eb);
color: var(--color6, #fff);
border: 1px solid var(--colorPrimary, #2563eb);
}
.support-recorder-secondary:hover {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.05);
}
.support-recorder-primary:hover {
opacity: 0.92;
}
.support-textarea-shell {
background: var(--bg, #fff);
border: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 16%, transparent);
box-shadow: 0 6px 16px color-mix(in srgb, var(--colorPrimary, #2563eb) 8%, transparent);
}
.composer-textarea {
color: var(--colorText, #111827);
font-family: var(--font-family, iran-sans), serif;
min-height: 44px;
max-height: 144px;
scrollbar-width: thin;
scrollbar-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35) transparent;
}
.composer-textarea::placeholder {
color: color-mix(in srgb, var(--colorText, #111827) 45%, transparent);
}
.composer-textarea::-webkit-scrollbar {
width: 6px;
}
.composer-textarea::-webkit-scrollbar-thumb {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35);
border-radius: 999px;
}
.composer-textarea::-webkit-scrollbar-track {
background: transparent;
}
.support-tool-btn {
background: transparent !important;
color: var(--colorPrimary, #2563eb) !important;
box-shadow: none !important;
}
.support-tool-btn.is-recording {
background: rgb(239 68 68 / 0.08) !important;
border-color: rgb(239 68 68 / 0.28) !important;
color: #ef4444 !important;
}
.support-send-btn {
border-radius: 16px !important;
background: var(--colorPrimary, #2563eb) !important;
border-color: var(--colorPrimary, #2563eb) !important;
color: #fff !important;
box-shadow: 0 10px 22px color-mix(in srgb, var(--colorPrimary, #2563eb) 34%, transparent) !important;
}
.support-send-btn.ant-btn-primary:not(:disabled):not(.ant-btn-disabled) {
background: var(--colorPrimary, #2563eb) !important;
border-color: var(--colorPrimary, #2563eb) !important;
color: #fff !important;
}
.support-send-btn:disabled,
.support-send-btn.ant-btn-disabled,
.support-send-btn.ant-btn-primary:disabled {
opacity: 1 !important;
background: color-mix(in srgb, var(--colorPrimary, #2563eb) 38%, #fff) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: none !important;
}
.support-drop-zone {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.28);
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.06);
color: var(--colorText, #111827);
}
.support-composer-reply {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
margin-bottom: 10px;
padding: 9px 10px;
border-radius: 16px;
background: var(--bg2, #f4f6f8);
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
overflow: hidden;
}
.support-composer-reply__bar {
width: 4px;
height: 38px;
border-radius: 999px;
background: var(--colorPrimary, #2563eb);
flex: 0 0 4px;
}
.support-composer-reply__body {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
.support-composer-reply__title {
margin-bottom: 2px;
font-size: 12px;
font-weight: 900;
color: var(--colorPrimary, #2563eb);
}
.support-composer-reply__text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
unicode-bidi: plaintext;
font-size: 12px;
line-height: 1.8;
color: color-mix(in srgb, var(--colorText, #111827) 72%, transparent);
}
.support-composer-reply__close {
width: 30px;
height: 30px;
flex: 0 0 30px;
border: 0;
border-radius: 999px;
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
color: var(--colorText, #111827);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.support-reply-preview-enter-active,
.support-reply-preview-leave-active {
transition: all 0.18s ease;
}
.support-reply-preview-enter-from,
.support-reply-preview-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (min-width: 768px) {
.support-composer {
padding: 12px 16px 14px;
}
}
@media (max-width: 767px) {
.support-composer {
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
}
}
</style>
@@ -0,0 +1,155 @@
<template>
<header class="support-header">
<div class="support-header__inner">
<div class="bg-glass rounded-full p-2" @click="$router.back()">
<BasicIcon icon="vuesax-linear:arrow-right" />
</div>
<div class="support-header__icon">
<img
v-if="logoUrl"
:src="logoUrl"
alt=""
class="support-header__logo"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="support-header__icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M8 10.5h8M8 14h5M7 20l-3 1 1-3V6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9l-2 3Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div class="support-header__content">
<h1 class="support-header__title">{{ title }}</h1>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useConfigStore } from '@/store/configStore'
defineProps<{
title: string
subtitle?: string
}>()
const config = useConfigStore()
const logoUrl = computed(() => String(config.config?.avatar || '').trim())
</script>
<style scoped lang="less">
.support-header {
position: sticky;
top: 0;
z-index: 2;
border-bottom: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg, #fff) 55%, transparent) 0%,
color-mix(in srgb, var(--bg, #fff) 22%, transparent) 70%,
transparent 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.support-header__inner {
max-width: 64rem;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
}
.support-header__icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
flex: 0 0 52px;
overflow: hidden;
border-radius: 18px;
background: var(--bg2, #0a1b37);
color: var(--colorText, #fff);
box-shadow: 0 10px 24px rgb(var(--colorPrimaryRGB, 37 99 235) / 0.16);
}
.support-header__logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
padding: 6px;
background: #fff;
}
.support-header__icon-svg {
width: 22px;
height: 22px;
}
.support-header__content {
min-width: 0;
flex: 1;
}
.support-header__title {
margin: 0;
font-size: 1.125rem;
font-weight: 900;
color: var(--colorText, #111827);
}
.support-header__subtitle-row {
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.support-header__status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--color4, #4caf50);
box-shadow: 0 0 0 3px rgb(76 175 80 / 0.12);
}
.support-header__subtitle {
font-size: 0.95rem;
color: color-mix(in srgb, var(--colorText, #111827) 75%, transparent);
}
:global(body.dynamicMode) .support-header__title,
:global(body.dynamicMode) .support-header__subtitle {
color: var(--colorText, #fff);
}
@media (max-width: 767px) {
.support-header__inner {
padding: 14px 16px;
}
.support-header__icon {
width: 48px;
height: 48px;
flex-basis: 48px;
border-radius: 16px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
<template>
<div class="support-page main-with-bottom-chrome-inner--contained">
<div class="support-shell">
<SupportChatHeader title="پشتیبانی" />
<SupportChatMessages
ref="messagesRef"
:messages="messages"
:support-typing="supportTyping"
:has-more="hasMoreMessages"
:loading-older="isFetchingOlderMessages"
:loading="chatLoading"
:loading-label="loadingLabel"
@load-older="loadOlderMessages"
@reply="handleReply"
@bottom-state-change="setSupportChatAtBottom"
/>
<SupportChatComposer
v-model="draft"
:disabled="isSending"
:replying-to="replyingTo"
@cancel-reply="clearReply"
@send="sendMessage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import SupportChatHeader from './components/SupportChatHeader.vue'
import SupportChatMessages from './components/SupportChatMessages.vue'
import SupportChatComposer from './components/SupportChatComposer.vue'
import type {
ChatAttachment,
ChatMessage,
ChatReplyPreview,
ComposerSendPayload
} from '@/models/support'
import {
connectChatIfExists,
ensureChatAndConnect,
fetchChatMessages,
fetchOlderChatMessages,
sendTextMessage,
sendMediaMessage,
getExistingChatSession,
useChatService,
chatLoading,
setSupportChatPageOpen
} from '@/utils/chat'
import { useSettingsStore } from '@/store/settingsStore'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
const draft = ref('')
const isSending = ref(false)
const supportTyping = ref(false)
const replyingTo = ref<ChatReplyPreview | null>(null)
const uploadingMessagesByRoom = ref<Record<string, ChatMessage[]>>({})
const IMAGE_MAX_WIDTH = 1600
const IMAGE_MAX_HEIGHT = 1600
const IMAGE_WEBP_QUALITY = 0.85
const messagesRef = ref<InstanceType<typeof SupportChatMessages> | null>(null)
const {
messages: rawMessages,
currentRoomId,
currentChatUserId,
status,
hasMoreMessages,
isFetchingOlderMessages,
setSupportChatAtBottom
} = useChatService()
const loadingLabel = computed(() => {
if (!chatLoading.value) return ''
if (status.value === 'CONNECTING') {
return 'در حال دریافت پیام‌ها و اتصال به پشتیبانی...'
}
return 'در حال دریافت پیام‌ها...'
})
const formatTime = (date: Date | string | number | null | undefined) => {
const value = date ? new Date(date) : new Date()
return new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit'
}).format(value)
}
const getSettingValue = (key: string) => {
const setting = useSettingsStore().getSettingByKey(key)
if (setting == null) return ''
if (typeof setting === 'string') return setting.trim()
return String(setting).trim()
}
const getChatUploadUrl = () => getSettingValue('chat_upload_url')
const getChatDownloadUrl = () => getSettingValue('chat_download_url')
const buildChatFileUrl = (value: string) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^(https?:\/\/|blob:|data:)/i.test(raw)) {
return raw
}
const downloadUrl = getChatDownloadUrl()
if (!downloadUrl) {
console.error('chat_download_url is empty')
return raw
}
const cleanRaw = raw.replace(/^\/+/, '')
try {
const baseUrl = new URL(downloadUrl)
const basePath = baseUrl.pathname.replace(/^\/+|\/+$/g, '')
const baseParts = basePath.split('/').filter(Boolean)
const rawParts = cleanRaw.split('/').filter(Boolean)
if (basePath && cleanRaw.startsWith(`${basePath}/`)) {
return `${baseUrl.origin}/${cleanRaw}`
}
const lastBasePart = baseParts[baseParts.length - 1]
if (lastBasePart && rawParts[0] === lastBasePart) {
const parentPath = baseParts.slice(0, -1).join('/')
return `${baseUrl.origin}/${[parentPath, cleanRaw].filter(Boolean).join('/')}`
}
return `${downloadUrl.replace(/\/+$/, '')}/${cleanRaw}`
} catch (error) {
console.error('invalid chat_download_url:', downloadUrl, error)
return raw
}
}
const extractUploadedPath = (result: any) => {
const value =
result?.path ||
result?.url ||
result?.file_url ||
result?.fileUrl ||
result?.data?.path ||
result?.data?.url ||
result?.data?.file_url ||
result?.data?.fileUrl ||
result?.file?.path ||
result?.file?.url
return String(value || '').trim()
}
const getCompressedImageName = (fileName: string) => {
const cleanName = String(fileName || `image-${Date.now()}`)
.replace(/\.[^/.]+$/, '')
.replace(/[^\w\u0600-\u06FF.-]+/g, '-')
return `${cleanName}.webp`
}
const canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality: number) => {
return new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => resolve(blob), type, quality)
})
}
const loadImageFromFile = (file: File) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('image load failed'))
}
img.src = url
})
}
const shouldCompressImage = (file: File) => {
if (!file.type.startsWith('image/')) return false
// gif متحرک خراب میشه، svg هم نیازی به canvas/webp نداره
if (file.type === 'image/gif') return false
if (file.type === 'image/svg+xml') return false
return true
}
const compressImageToWebp = async (file: File) => {
if (!shouldCompressImage(file)) return file
try {
const img = await loadImageFromFile(file)
const ratio = Math.min(
IMAGE_MAX_WIDTH / img.naturalWidth,
IMAGE_MAX_HEIGHT / img.naturalHeight,
1
)
const width = Math.round(img.naturalWidth * ratio)
const height = Math.round(img.naturalHeight * ratio)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return file
ctx.drawImage(img, 0, 0, width, height)
const blob = await canvasToBlob(canvas, 'image/webp', IMAGE_WEBP_QUALITY)
if (!blob) return file
const webpFile = new File([blob], getCompressedImageName(file.name), {
type: 'image/webp',
lastModified: Date.now()
})
// اگر WebP بزرگ‌تر شد، فایل اصلی رو آپلود کن
if (webpFile.size >= file.size) return file
return webpFile
} catch (error) {
console.error('compress image to webp failed:', error)
return file
}
}
const uploadChatFile = (file: File, onProgress?: (percent: number) => void) => {
return new Promise<string>((resolve, reject) => {
const uploadUrl = getChatUploadUrl()
if (!uploadUrl) {
reject(new Error('chat_upload_url is empty'))
return
}
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', uploadUrl)
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return
const percent = Math.round((event.loaded / event.total) * 100)
onProgress?.(Math.min(percent, 99))
}
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`chat file upload failed: ${xhr.status}`))
return
}
try {
const contentType = xhr.getResponseHeader('content-type') || ''
if (contentType.includes('application/json')) {
const result = JSON.parse(xhr.responseText)
const uploadedPath = extractUploadedPath(result)
if (!uploadedPath) {
console.error('upload response has no path/url:', result)
reject(new Error('upload response has no path/url'))
return
}
resolve(uploadedPath)
return
}
const uploadedPath = xhr.responseText.trim()
if (!uploadedPath) {
reject(new Error('upload response is empty'))
return
}
resolve(uploadedPath)
} catch (error) {
reject(error)
}
}
xhr.onerror = () => {
reject(new Error('chat file upload network error'))
}
xhr.send(formData)
})
}
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const mapRawAttachment = (item: any): ChatAttachment | null => {
if (!item) return null
const type = String(item.type || item.file_type || item.kind || '').toLowerCase()
const rawUrl = item.url || item.file_url || item.fileUrl || item.path || item.content
const url = buildChatFileUrl(rawUrl)
const name = item.name || item.file_name || 'file'
if (!url) return null
if (type === 'image') {
return {
id: item.id || createId(),
type: 'image',
url,
name
}
}
if (type === 'audio' || type === 'voice') {
return {
id: item.id || createId(),
type: 'audio',
url,
name
}
}
return null
}
const getMessageType = (normalized: any) => {
return String(normalized.type_content ?? normalized.type ?? 'text').toLowerCase()
}
const getMessagePreviewText = (message: Pick<ChatMessage, 'text' | 'attachments'>) => {
const text = String(message.text || '').trim()
const firstAttachment = message.attachments?.[0]
if (text) return text
if (firstAttachment?.type === 'image') return 'تصویر'
if (firstAttachment?.type === 'audio') return 'ویس'
return 'پیام'
}
const toReplyPreview = (message: ChatMessage): ChatReplyPreview => {
const firstAttachment = message.attachments?.[0] || null
return {
id: message.id,
sender: message.sender,
text: getMessagePreviewText(message),
attachment: firstAttachment
? {
type: firstAttachment.type,
url: firstAttachment.url,
name: firstAttachment.name
}
: null
}
}
const handleReply = async (message: ChatMessage) => {
replyingTo.value = toReplyPreview(message)
console.log('replying to:', replyingTo.value)
await nextTick()
}
const clearReply = () => {
replyingTo.value = null
}
const messages = computed<ChatMessage[]>(() => {
const baseMessages = rawMessages.value
.map((item: any) => {
const normalized = item.message ?? item
const senderId =
normalized.sender_id ??
normalized.user_id ??
normalized.from_user_id ??
normalized.sender?.id
const typeContent = getMessageType(normalized)
const rawContent = normalized.content ?? normalized.text ?? normalized.body ?? ''
const fileContent =
typeContent === 'image' || typeContent === 'voice' || typeContent === 'audio'
? buildChatFileUrl(rawContent)
: ''
let attachments: ChatAttachment[] = []
if (typeContent === 'image' && fileContent) {
attachments = [
{
id: normalized.id || normalized.message_id || createId(),
type: 'image',
url: fileContent,
name: normalized.file_name || normalized.name || 'image'
}
]
} else if ((typeContent === 'voice' || typeContent === 'audio') && fileContent) {
attachments = [
{
id: normalized.id || normalized.message_id || createId(),
type: 'audio',
url: fileContent,
name: normalized.file_name || normalized.name || 'voice'
}
]
} else {
const rawAttachments = normalized.attachments ?? normalized.files ?? normalized.media ?? []
attachments = Array.isArray(rawAttachments)
? (rawAttachments.map(mapRawAttachment).filter(Boolean) as ChatAttachment[])
: []
}
const text = typeContent === 'text' ? String(rawContent || '').trim() : ''
if (!text && attachments.length === 0) return null
const isUserMessage = String(senderId) === String(currentChatUserId.value)
const realMessageId = normalized.id || normalized.message_id
const messageId =
realMessageId ||
normalized._client_key ||
`${normalized.room_id || 'room'}-${senderId || 'sender'}-${
normalized.created_at || normalized.timestamp || ''
}-${rawContent}`
const reply = normalized.reply ?? normalized.reply_id ?? normalized.reply_to_id ?? null
return {
id: String(messageId),
text,
sender: isUserMessage ? 'user' : 'support',
createdAt: formatTime(
normalized.created_at || normalized.createdAt || normalized.timestamp
),
createdAtRaw: normalized.created_at || normalized.createdAt || normalized.timestamp,
status: isUserMessage
? normalized._optimistic
? 'sending'
: normalized.read_at
? 'seen'
: 'sent'
: undefined,
attachments,
reply: reply ? String(reply) : null,
replyTo: null
}
})
.filter(Boolean) as ChatMessage[]
const messageMap = new Map(baseMessages.map((item) => [String(item.id), item]))
const mappedMessages = baseMessages.map((message) => {
const replyId = message.reply ? String(message.reply) : ''
if (!replyId) return message
const repliedMessage = messageMap.get(replyId)
if (!repliedMessage) {
return {
...message,
replyTo: {
id: replyId,
sender: 'support',
text: 'پیام پاسخ داده‌شده',
attachment: null
}
}
}
return {
...message,
replyTo: toReplyPreview(repliedMessage)
}
})
const roomId = currentRoomId.value || ''
const uploadingMessages = uploadingMessagesByRoom.value[roomId] || []
return [...mappedMessages, ...uploadingMessages]
})
onMounted(async () => {
setSupportChatPageOpen(true)
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
try {
const existing = getExistingChatSession()
if (!existing) {
chatLoading.value = false
return
}
await connectChatIfExists()
await fetchChatMessages()
await nextTick()
messagesRef.value?.scrollToBottom(false)
} catch (error) {
chatLoading.value = false
console.error('chat page init failed:', error)
}
})
onBeforeUnmount(() => {
setSupportChatPageOpen(false)
})
const loadOlderMessages = async () => {
try {
messagesRef.value?.prepareForPrepend()
await fetchOlderChatMessages()
await messagesRef.value?.restoreAfterPrepend()
} catch (error) {
console.error('load older messages failed:', error)
}
}
const sendMessage = async (payload: ComposerSendPayload) => {
const text = payload.text.trim()
const attachments = payload.attachments?.map((item) => ({ ...item })) || []
const hasAttachments = attachments.length > 0
const replyId = payload.reply
? String(payload.reply)
: replyingTo.value?.id
? String(replyingTo.value.id)
: null
if (!text && !hasAttachments) return
if (isSending.value) return
try {
isSending.value = true
const existing = getExistingChatSession()
if (!existing) {
await ensureChatAndConnect()
} else if (status.value !== 'OPEN') {
await connectChatIfExists()
}
const roomId = currentRoomId.value || getExistingChatSession()?.roomId || ''
if (text) {
await sendTextMessage(text, replyId)
}
for (const attachment of attachments) {
if (!attachment.file) {
if (attachment.url) {
await sendMediaMessage(
attachment.url,
attachment.type === 'audio' ? 'voice' : 'image',
replyId
)
}
continue
}
const tempMessageId = `uploading-${createId()}`
const previewUrl = attachment.url || URL.createObjectURL(attachment.file)
const uploadingMessage: ChatMessage = {
id: tempMessageId,
sender: 'user',
text: '',
createdAt: formatTime(new Date()),
createdAtRaw: new Date().toISOString(),
status: 'sending',
reply: replyId,
replyTo: replyingTo.value,
attachments: [
{
...attachment,
id: attachment.id || createId(),
url: previewUrl,
uploading: true,
uploadProgress: 0
}
]
}
uploadingMessagesByRoom.value[roomId] = [
...(uploadingMessagesByRoom.value[roomId] || []),
uploadingMessage
]
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
await nextTick()
messagesRef.value?.scrollToBottom(true)
const setUploadProgress = (percent: number) => {
const list = uploadingMessagesByRoom.value[roomId] || []
const target = list.find((message) => message.id === tempMessageId)
const targetAttachment = target?.attachments?.[0]
if (targetAttachment) {
targetAttachment.uploadProgress = percent
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
}
}
try {
setUploadProgress(5)
const fileForUpload =
attachment.type === 'image' ? await compressImageToWebp(attachment.file) : attachment.file
setUploadProgress(12)
const uploadedPath = await uploadChatFile(fileForUpload, setUploadProgress)
setUploadProgress(99)
await sendMediaMessage(
uploadedPath,
attachment.type === 'audio' ? 'voice' : 'image',
replyId
)
} finally {
uploadingMessagesByRoom.value[roomId] = (
uploadingMessagesByRoom.value[roomId] || []
).filter((message) => message.id !== tempMessageId)
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
}
}
clearReply()
await nextTick()
messagesRef.value?.scrollToBottom(true)
} catch (error) {
console.error('send message failed:', error)
} finally {
draft.value = ''
isSending.value = false
}
}
</script>
<style scoped lang="less">
.support-page {
height: 100%;
min-height: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--font-family, iran-sans), serif;
}
.support-shell {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background:
radial-gradient(
ellipse 110% 70% at 100% -5%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 28%, transparent),
transparent 68%
),
radial-gradient(
ellipse 95% 60% at -5% 105%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 18%, transparent),
transparent 62%
),
radial-gradient(
circle at 50% 45%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 8%, transparent),
transparent 55%
),
var(--bg, #f7f8fb);
border: 0;
border-radius: 0;
box-shadow: none;
}
@media (min-width: 768px) {
.support-page {
height: calc(100dvh - 4.5rem);
padding: 12px 16px;
}
.support-shell {
border-radius: 16px;
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
box-shadow: 0 16px 48px rgb(15 23 42 / 0.06);
}
}
</style>
+162 -186
View File
@@ -71,7 +71,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz"
integrity sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ== integrity sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==
"@babel/core@^7.11.1", "@babel/core@^7.22.10": "@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.11.1", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.22.10", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0":
version "7.22.10" version "7.22.10"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz" resolved "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz"
integrity sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw== integrity sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==
@@ -291,7 +291,7 @@
"@babel/template" "^7.27.1" "@babel/template" "^7.27.1"
"@babel/types" "^7.27.1" "@babel/types" "^7.27.1"
"@babel/parser@^7.20.15", "@babel/parser@^7.21.3", "@babel/parser@^7.22.10", "@babel/parser@^7.25.3", "@babel/parser@^7.26.5", "@babel/parser@^7.27.2": "@babel/parser@^7.15.8", "@babel/parser@^7.20.15", "@babel/parser@^7.21.3", "@babel/parser@^7.22.10", "@babel/parser@^7.25.3", "@babel/parser@^7.26.5", "@babel/parser@^7.27.2":
version "7.27.2" version "7.27.2"
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz"
integrity sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw== integrity sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==
@@ -1006,111 +1006,6 @@
resolved "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz" resolved "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA== integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@esbuild/android-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622"
integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==
"@esbuild/android-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682"
integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==
"@esbuild/android-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2"
integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==
"@esbuild/darwin-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
"@esbuild/darwin-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d"
integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==
"@esbuild/freebsd-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54"
integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==
"@esbuild/freebsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e"
integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==
"@esbuild/linux-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0"
integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==
"@esbuild/linux-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0"
integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==
"@esbuild/linux-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7"
integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==
"@esbuild/linux-loong64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d"
integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==
"@esbuild/linux-mips64el@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231"
integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==
"@esbuild/linux-ppc64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb"
integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==
"@esbuild/linux-riscv64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6"
integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==
"@esbuild/linux-s390x@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071"
integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==
"@esbuild/linux-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338"
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
"@esbuild/netbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1"
integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==
"@esbuild/openbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae"
integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==
"@esbuild/sunos-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d"
integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==
"@esbuild/win32-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9"
integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==
"@esbuild/win32-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102"
integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==
"@esbuild/win32-x64@0.18.20": "@esbuild/win32-x64@0.18.20":
version "0.18.20" version "0.18.20"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz" resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz"
@@ -1244,7 +1139,7 @@
"@nodelib/fs.stat" "2.0.5" "@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9" run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": "@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5" version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -1348,16 +1243,16 @@
resolved "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.0.tgz" resolved "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.0.tgz"
integrity sha512-yhxwIlFVSVcMym3O31HoMnRXpoenmpIxcj4Yoes2DUpe+xCJnA7ECQP1Vw889V0jTt/2nzvpLQ/UuMYCd3JPIg== integrity sha512-yhxwIlFVSVcMym3O31HoMnRXpoenmpIxcj4Yoes2DUpe+xCJnA7ECQP1Vw889V0jTt/2nzvpLQ/UuMYCd3JPIg==
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/estree@^1.0.0": "@types/estree@^1.0.0":
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz"
integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz"
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
"@types/json-schema@^7.0.9": "@types/json-schema@^7.0.9":
version "7.0.12" version "7.0.12"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz"
@@ -1368,7 +1263,7 @@
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz"
integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
"@types/node@*", "@types/node@^18.17.0": "@types/node@*", "@types/node@^18.17.0", "@types/node@>= 14":
version "18.17.5" version "18.17.5"
resolved "https://registry.npmjs.org/@types/node/-/node-18.17.5.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-18.17.5.tgz"
integrity sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA== integrity sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==
@@ -1411,7 +1306,7 @@
semver "^7.3.7" semver "^7.3.7"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/parser@^5.59.1": "@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.59.1":
version "5.62.0" version "5.62.0"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz"
integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA== integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==
@@ -1697,7 +1592,7 @@
resolved "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.3.1.tgz" resolved "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.3.1.tgz"
integrity sha512-tUBEtWcF7wFtII7ayNiLNDTCE1X1afySEo+XNVMNkFXaThENyCowIEX095QqbJZGTgoOcSVDJGlnde2NG4jtbQ== integrity sha512-tUBEtWcF7wFtII7ayNiLNDTCE1X1afySEo+XNVMNkFXaThENyCowIEX095QqbJZGTgoOcSVDJGlnde2NG4jtbQ==
"@volar/language-core@2.4.11", "@volar/language-core@~2.4.11": "@volar/language-core@~2.4.11", "@volar/language-core@2.4.11":
version "2.4.11" version "2.4.11"
resolved "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz" resolved "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz"
integrity sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg== integrity sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==
@@ -1759,14 +1654,6 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
source-map-js "^1.2.0" source-map-js "^1.2.0"
"@vue/compiler-dom@3.3.4":
version "3.3.4"
resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz"
integrity sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==
dependencies:
"@vue/compiler-core" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/compiler-dom@^3.5.0": "@vue/compiler-dom@^3.5.0":
version "3.5.13" version "3.5.13"
resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz" resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz"
@@ -1775,6 +1662,14 @@
"@vue/compiler-core" "3.5.13" "@vue/compiler-core" "3.5.13"
"@vue/shared" "3.5.13" "@vue/shared" "3.5.13"
"@vue/compiler-dom@3.3.4":
version "3.3.4"
resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz"
integrity sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==
dependencies:
"@vue/compiler-core" "3.3.4"
"@vue/shared" "3.3.4"
"@vue/compiler-sfc@3.3.4": "@vue/compiler-sfc@3.3.4":
version "3.3.4" version "3.3.4"
resolved "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz" resolved "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz"
@@ -1886,12 +1781,12 @@
"@vue/compiler-ssr" "3.3.4" "@vue/compiler-ssr" "3.3.4"
"@vue/shared" "3.3.4" "@vue/shared" "3.3.4"
"@vue/shared@3.3.4", "@vue/shared@^3.0.0": "@vue/shared@^3.0.0", "@vue/shared@3.3.4":
version "3.3.4" version "3.3.4"
resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz" resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz"
integrity sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ== integrity sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==
"@vue/shared@3.5.13", "@vue/shared@^3.5.0": "@vue/shared@^3.5.0", "@vue/shared@3.5.13":
version "3.5.13" version "3.5.13"
resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz" resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz"
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
@@ -1933,7 +1828,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.8.2, acorn@^8.9.0: "acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.2, acorn@^8.9.0:
version "8.10.0" version "8.10.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz"
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
@@ -1948,7 +1843,7 @@ ajv@^6.12.4:
json-schema-traverse "^0.4.1" json-schema-traverse "^0.4.1"
uri-js "^4.2.2" uri-js "^4.2.2"
ajv@^8.6.0: ajv@^8.6.0, ajv@>=8:
version "8.12.0" version "8.12.0"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz"
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
@@ -2013,7 +1908,7 @@ anymatch@~3.1.2:
normalize-path "^3.0.0" normalize-path "^3.0.0"
picomatch "^2.0.4" picomatch "^2.0.4"
apexcharts@^3.44.0: apexcharts@^3.44.0, "apexcharts@> 3.0.0":
version "3.44.0" version "3.44.0"
resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-3.44.0.tgz" resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-3.44.0.tgz"
integrity sha512-u7Xzrbcxc2yWznN78Jh5NMCYVAsWDfBjRl5ea++rVzFAqjU2hLz4RgKIFwYOBDRQtW1e/Qz8azJTqIJ1+Vu9Qg== integrity sha512-u7Xzrbcxc2yWznN78Jh5NMCYVAsWDfBjRl5ea++rVzFAqjU2hLz4RgKIFwYOBDRQtW1e/Qz8azJTqIJ1+Vu9Qg==
@@ -2168,7 +2063,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browserslist@^4.21.9, browserslist@^4.22.1: browserslist@^4.21.9, browserslist@^4.22.1, "browserslist@>= 4.21.0":
version "4.22.1" version "4.22.1"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz"
integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ== integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==
@@ -2232,7 +2127,7 @@ chalk@^2.4.1:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" supports-color "^5.3.0"
chalk@^4.0.0, chalk@^4.0.2: chalk@^4.0.0:
version "4.1.2" version "4.1.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -2240,7 +2135,15 @@ chalk@^4.0.0, chalk@^4.0.2:
ansi-styles "^4.1.0" ansi-styles "^4.1.0"
supports-color "^7.1.0" supports-color "^7.1.0"
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: chalk@^4.0.2:
version "4.1.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chokidar@^3.5.3, "chokidar@>=3.0.0 <4.0.0":
version "3.5.3" version "3.5.3"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -2269,16 +2172,16 @@ color-convert@^2.0.1:
dependencies: dependencies:
color-name "~1.1.4" color-name "~1.1.4"
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@~1.1.4: color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
colorette@^2.0.20: colorette@^2.0.20:
version "2.0.20" version "2.0.20"
resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz" resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz"
@@ -2666,7 +2569,7 @@ eslint-plugin-prettier@^5.0.0:
prettier-linter-helpers "^1.0.0" prettier-linter-helpers "^1.0.0"
synckit "^0.8.5" synckit "^0.8.5"
eslint-plugin-vue@^9.15.1: eslint-plugin-vue@^9.0.0, eslint-plugin-vue@^9.15.1:
version "9.17.0" version "9.17.0"
resolved "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.17.0.tgz" resolved "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.17.0.tgz"
integrity sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ== integrity sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==
@@ -2687,7 +2590,15 @@ eslint-scope@^5.1.1:
esrecurse "^4.3.0" esrecurse "^4.3.0"
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-scope@^7.1.1, eslint-scope@^7.2.2: eslint-scope@^7.1.1:
version "7.2.2"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz"
integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==
dependencies:
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-scope@^7.2.2:
version "7.2.2" version "7.2.2"
resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz"
integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==
@@ -2700,7 +2611,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8.45.0: eslint@*, "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0", eslint@^8.45.0, "eslint@>= 8.0.0", eslint@>=6.0.0, eslint@>=7.0.0, eslint@>=8.0.0:
version "8.47.0" version "8.47.0"
resolved "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz" resolved "https://registry.npmjs.org/eslint/-/eslint-8.47.0.tgz"
integrity sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q== integrity sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==
@@ -2771,7 +2682,12 @@ estraverse@^4.1.1:
resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.1.0, estraverse@^5.2.0: estraverse@^5.1.0:
version "5.3.0"
resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
estraverse@^5.2.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
@@ -2796,7 +2712,22 @@ eventemitter3@^5.0.1:
resolved "https://mirror-npm.runflare.com/eventemitter3/-/eventemitter3-5.0.4.tgz" resolved "https://mirror-npm.runflare.com/eventemitter3/-/eventemitter3-5.0.4.tgz"
integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==
execa@^5.0.0, execa@^5.1.1: execa@^5.0.0:
version "5.1.1"
resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
dependencies:
cross-spawn "^7.0.3"
get-stream "^6.0.0"
human-signals "^2.1.0"
is-stream "^2.0.0"
merge-stream "^2.0.0"
npm-run-path "^4.0.1"
onetime "^5.1.2"
signal-exit "^3.0.3"
strip-final-newline "^2.0.0"
execa@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz"
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
@@ -2942,11 +2873,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.1: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
@@ -3000,7 +2926,7 @@ get-symbol-description@^1.0.0:
call-bind "^1.0.2" call-bind "^1.0.2"
get-intrinsic "^1.1.1" get-intrinsic "^1.1.1"
glob-parent@^5.1.2, glob-parent@~5.1.2: glob-parent@^5.1.2:
version "5.1.2" version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -3014,6 +2940,13 @@ glob-parent@^6.0.2:
dependencies: dependencies:
is-glob "^4.0.3" is-glob "^4.0.3"
glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob@^7.1.3, glob@^7.1.6: glob@^7.1.3, glob@^7.1.6:
version "7.2.3" version "7.2.3"
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
@@ -3525,7 +3458,7 @@ kolorist@^1.8.0:
resolved "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz" resolved "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
less@^4.2.0: less@*, less@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.npmjs.org/less/-/less-4.2.0.tgz" resolved "https://registry.npmjs.org/less/-/less-4.2.0.tgz"
integrity sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA== integrity sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==
@@ -3633,7 +3566,14 @@ lru-cache@^6.0.0:
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
magic-string@^0.25.0, magic-string@^0.25.7: magic-string@^0.25.0:
version "0.25.9"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz"
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
dependencies:
sourcemap-codec "^1.4.8"
magic-string@^0.25.7:
version "0.25.9" version "0.25.9"
resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz"
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
@@ -3775,7 +3715,7 @@ mrmime@^1.0.0:
resolved "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz" resolved "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz"
integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==
ms@2.1.2, ms@^2.1.1: ms@^2.1.1, ms@2.1.2:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -3900,7 +3840,7 @@ object.assign@^4.1.4:
has-symbols "^1.0.3" has-symbols "^1.0.3"
object-keys "^1.1.1" object-keys "^1.1.1"
odometer@^0.4.8: odometer@^0.4.8, odometer@latest:
version "0.4.8" version "0.4.8"
resolved "https://registry.npmjs.org/odometer/-/odometer-0.4.8.tgz" resolved "https://registry.npmjs.org/odometer/-/odometer-0.4.8.tgz"
integrity sha512-bfKuAhWE/qMCiX9bwX90c5bTpt7MDVeq1e6YsOHQrYokNhv9jhS5JbR9kg6i+FeWmDgalf3VUtwNx1aVaZ8cgg== integrity sha512-bfKuAhWE/qMCiX9bwX90c5bTpt7MDVeq1e6YsOHQrYokNhv9jhS5JbR9kg6i+FeWmDgalf3VUtwNx1aVaZ8cgg==
@@ -4124,7 +4064,7 @@ prettier-linter-helpers@^1.0.0:
dependencies: dependencies:
fast-diff "^1.1.2" fast-diff "^1.1.2"
prettier@^3.0.0: prettier@^3.0.0, "prettier@>= 3.0.0", prettier@>=3.0.0:
version "3.0.2" version "3.0.2"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz" resolved "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz"
integrity sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ== integrity sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==
@@ -4294,14 +4234,14 @@ rollup-plugin-terser@^7.0.0:
serialize-javascript "^4.0.0" serialize-javascript "^4.0.0"
terser "^5.0.0" terser "^5.0.0"
rollup@^2.43.1: "rollup@^1.20.0 || ^2.0.0", rollup@^1.20.0||^2.0.0, rollup@^2.0.0, rollup@^2.43.1:
version "2.79.2" version "2.79.2"
resolved "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz" resolved "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz"
integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ== integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
rollup@^3.27.1: rollup@^1.20.0||^2.0.0||^3.0.0, rollup@^3.27.1:
version "3.29.5" version "3.29.5"
resolved "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz" resolved "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz"
integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==
@@ -4351,7 +4291,7 @@ safe-regex-test@^1.0.0:
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sass@^1.69.4: sass@*, sass@^1.69.4:
version "1.69.5" version "1.69.5"
resolved "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz" resolved "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz"
integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ== integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==
@@ -4372,7 +4312,12 @@ scroll-into-view-if-needed@^2.2.25:
dependencies: dependencies:
compute-scroll-into-view "^1.0.20" compute-scroll-into-view "^1.0.20"
"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0: semver@^5.5.0:
version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
semver@^5.6.0:
version "5.7.2" version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
@@ -4382,13 +4327,32 @@ semver@^6.3.1:
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.6, semver@^7.3.7, semver@^7.5.4: semver@^7.3.6:
version "7.5.4" version "7.5.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz" resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^7.3.7:
version "7.5.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
semver@^7.5.4:
version "7.5.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
dependencies:
lru-cache "^6.0.0"
"semver@2 || 3 || 4 || 5":
version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
serialize-javascript@^4.0.0: serialize-javascript@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz" resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz"
@@ -4467,7 +4431,7 @@ slash@^3.0.0:
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1: source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0":
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
@@ -4622,7 +4586,14 @@ supports-color@^5.3.0:
dependencies: dependencies:
has-flag "^3.0.0" has-flag "^3.0.0"
supports-color@^7.0.0, supports-color@^7.1.0: supports-color@^7.0.0:
version "7.2.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
dependencies:
has-flag "^4.0.0"
supports-color@^7.1.0:
version "7.2.0" version "7.2.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
@@ -4660,7 +4631,7 @@ svg.filter.js@^2.0.2:
dependencies: dependencies:
svg.js "^2.2.5" svg.js "^2.2.5"
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5: svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5, svg.js@>=2.3.x:
version "2.7.1" version "2.7.1"
resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz" resolved "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
@@ -4722,7 +4693,7 @@ tempy@^0.6.0:
type-fest "^0.16.0" type-fest "^0.16.0"
unique-string "^2.0.0" unique-string "^2.0.0"
terser@^5.0.0: terser@^5.0.0, terser@^5.4.0:
version "5.24.0" version "5.24.0"
resolved "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz" resolved "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz"
integrity sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw== integrity sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==
@@ -4761,11 +4732,6 @@ tr46@^1.0.1:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^1.8.1: tslib@^1.8.1:
version "1.14.1" version "1.14.1"
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
@@ -4776,6 +4742,11 @@ tslib@^2.3.0, tslib@^2.5.0, tslib@^2.6.0:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz" resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz"
integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tsutils@^3.21.0: tsutils@^3.21.0:
version "3.21.0" version "3.21.0"
resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz"
@@ -4839,7 +4810,7 @@ typed-array-length@^1.0.4:
for-each "^0.3.3" for-each "^0.3.3"
is-typed-array "^1.1.9" is-typed-array "^1.1.9"
typescript@~5.1.6: typescript@*, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@>=4.4.4, typescript@>=5.0.0, typescript@~5.1.6:
version "5.1.6" version "5.1.6"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz" resolved "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA== integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
@@ -5014,7 +4985,7 @@ vite-plugin-pwa@^0.16.7:
workbox-build "^7.0.0" workbox-build "^7.0.0"
workbox-window "^7.0.0" workbox-window "^7.0.0"
vite@^4.4.6: "vite@^2.9.0 || ^3.0.0-0 || ^4.0.0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.0.0, vite@^4.4.6:
version "4.5.14" version "4.5.14"
resolved "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz" resolved "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz"
integrity sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g== integrity sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==
@@ -5035,6 +5006,11 @@ vue-content-loader@^2.0.1:
resolved "https://registry.npmjs.org/vue-content-loader/-/vue-content-loader-2.0.1.tgz" resolved "https://registry.npmjs.org/vue-content-loader/-/vue-content-loader-2.0.1.tgz"
integrity sha512-pkof4+q2xmzNEdhqelxtJejeP/vQUJtLle4/v2ueG+HURqM9Q/GIGC8GJ2bVVWeLfTDET51jqimwQdmxJTlu0g== integrity sha512-pkof4+q2xmzNEdhqelxtJejeP/vQUJtLle4/v2ueG+HURqM9Q/GIGC8GJ2bVVWeLfTDET51jqimwQdmxJTlu0g==
vue-demi@^0.12.5:
version "0.12.5"
resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz"
integrity sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==
vue-demi@>=0.14.5: vue-demi@>=0.14.5:
version "0.14.5" version "0.14.5"
resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz" resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.5.tgz"
@@ -5045,11 +5021,6 @@ vue-demi@>=0.14.6:
resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz" resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz"
integrity sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w== integrity sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==
vue-demi@^0.12.5:
version "0.12.5"
resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.12.5.tgz"
integrity sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==
vue-eslint-parser@^9.1.1, vue-eslint-parser@^9.3.1: vue-eslint-parser@^9.1.1, vue-eslint-parser@^9.3.1:
version "9.3.1" version "9.3.1"
resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz" resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz"
@@ -5097,6 +5068,17 @@ vue-types@^3.0.0:
dependencies: dependencies:
is-plain-object "3.0.1" is-plain-object "3.0.1"
"vue@^2.0.0 || >=3.0.0", "vue@^2.6.14 || ^3.3.0", vue@^3, vue@^3.0.*, vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.2.0, vue@^3.2.25, vue@^3.3.4, "vue@> 3.0.0", "vue@>= 2.0.0", vue@>=3.0.3, vue@>=3.2.0, "vue@2 || 3", vue@3.3.4, vue@latest:
version "3.3.4"
resolved "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz"
integrity sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==
dependencies:
"@vue/compiler-dom" "3.3.4"
"@vue/compiler-sfc" "3.3.4"
"@vue/runtime-dom" "3.3.4"
"@vue/server-renderer" "3.3.4"
"@vue/shared" "3.3.4"
vue3-apexcharts@^1.4.4: vue3-apexcharts@^1.4.4:
version "1.4.4" version "1.4.4"
resolved "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.4.4.tgz" resolved "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.4.4.tgz"
@@ -5122,17 +5104,6 @@ vue3-otp-input@^0.4.1:
resolved "https://registry.npmjs.org/vue3-otp-input/-/vue3-otp-input-0.4.1.tgz" resolved "https://registry.npmjs.org/vue3-otp-input/-/vue3-otp-input-0.4.1.tgz"
integrity sha512-wVl9i3DcWlO0C7fBI9V+RIP3crm/1tY72fuhvb3YM2JfbLoYofB96aPl5AgFhA0Cse5bQEMYtIvOeiqW3rfbAw== integrity sha512-wVl9i3DcWlO0C7fBI9V+RIP3crm/1tY72fuhvb3YM2JfbLoYofB96aPl5AgFhA0Cse5bQEMYtIvOeiqW3rfbAw==
vue@^3.2.25, vue@^3.3.4:
version "3.3.4"
resolved "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz"
integrity sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==
dependencies:
"@vue/compiler-dom" "3.3.4"
"@vue/compiler-sfc" "3.3.4"
"@vue/runtime-dom" "3.3.4"
"@vue/server-renderer" "3.3.4"
"@vue/shared" "3.3.4"
warning@^4.0.0: warning@^4.0.0:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz" resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
@@ -5140,6 +5111,11 @@ warning@^4.0.0:
dependencies: dependencies:
loose-envify "^1.0.0" loose-envify "^1.0.0"
wavesurfer.js@^7.12.11:
version "7.12.11"
resolved "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.11.tgz"
integrity sha512-Sx0yZIz7jRJ9J9p7UL+pl9y0UQBB6UN/XNo+R7Gy4tCR5xZI9jMyA0dnt1R8TMVSQ4LZ10SeB2HJYQ+dV2dPSA==
webidl-conversions@^4.0.2: webidl-conversions@^4.0.2:
version "4.0.2" version "4.0.2"
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz"
@@ -5350,7 +5326,7 @@ workbox-sw@7.0.0:
resolved "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.0.0.tgz" resolved "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.0.0.tgz"
integrity sha512-SWfEouQfjRiZ7GNABzHUKUyj8pCoe+RwjfOIajcx6J5mtgKkN+t8UToHnpaJL5UVVOf5YhJh+OHhbVNIHe+LVA== integrity sha512-SWfEouQfjRiZ7GNABzHUKUyj8pCoe+RwjfOIajcx6J5mtgKkN+t8UToHnpaJL5UVVOf5YhJh+OHhbVNIHe+LVA==
workbox-window@7.0.0, workbox-window@^7.0.0: workbox-window@^7.0.0, workbox-window@7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/workbox-window/-/workbox-window-7.0.0.tgz" resolved "https://registry.npmjs.org/workbox-window/-/workbox-window-7.0.0.tgz"
integrity sha512-j7P/bsAWE/a7sxqTzXo3P2ALb1reTfZdvVp6OJ/uLr/C2kZAMvjeWGm8V4htQhor7DOvYg0sSbFN2+flT5U0qA== integrity sha512-j7P/bsAWE/a7sxqTzXo3P2ALb1reTfZdvVp6OJ/uLr/C2kZAMvjeWGm8V4htQhor7DOvYg0sSbFN2+flT5U0qA==