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:
Vendored
+1
-9
@@ -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']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
-1
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */
|
/** گرم معادل برای دریافت دقیق مبلغ ریالی در فروش */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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==
|
||||||
|
|||||||
Reference in New Issue
Block a user