diff --git a/components.d.ts b/components.d.ts index 8fdaf02..19f5fc4 100644 --- a/components.d.ts +++ b/components.d.ts @@ -15,17 +15,9 @@ declare module 'vue' { AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AModal: typeof import('ant-design-vue/es')['Modal'] AppCreditLink: typeof import('./src/components/AppCreditLink.vue')['default'] - ARadio: typeof import('ant-design-vue/es')['Radio'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] 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'] - 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'] Basic: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/basic.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'] PresettedRanges: typeof import('./src/components/BasicPacks/BaseDatePicker/src/datePick/demo/presetted-ranges.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'] PwaInstallStepIllustration: typeof import('./src/components/pwa/PwaInstallStepIllustration.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'] VanActionSheet: typeof import('vant/es')['ActionSheet'] VanDialog: typeof import('vant/es')['Dialog'] - VanField: typeof import('vant/es')['Field'] } } diff --git a/package-lock.json b/package-lock.json index 4d0558d..3563350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "vue3-apexcharts": "^1.4.4", "vue3-lazyload": "^0.3.8", "vue3-odometer": "^0.1.3", - "vue3-otp-input": "^0.4.1" + "vue3-otp-input": "^0.4.1", + "wavesurfer.js": "^7.12.11" }, "devDependencies": { "@rushstack/eslint-patch": "^1.3.2", @@ -9121,6 +9122,12 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index ab89434..38f5189 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "vue3-apexcharts": "^1.4.4", "vue3-lazyload": "^0.3.8", "vue3-odometer": "^0.1.3", - "vue3-otp-input": "^0.4.1" + "vue3-otp-input": "^0.4.1", + "wavesurfer.js": "^7.12.11" }, "devDependencies": { "@rushstack/eslint-patch": "^1.3.2", diff --git a/src/App.vue b/src/App.vue index 32b26c6..f50fa59 100644 --- a/src/App.vue +++ b/src/App.vue @@ -32,7 +32,7 @@
پشتیبانی + + {{ supportChatUnreadCount > 99 ? '99+' : supportChatUnreadCount }} +
@@ -86,6 +92,13 @@ + + import('@/modules/version/VersionUpdateModal.vue') @@ -130,8 +156,12 @@ const popVisible = ref(false) const { guideOpen, + bannerVisible, platform, + openGuide, + dismissBanner, completeGuide, + showBanner, } = usePwaInstall() const { @@ -139,6 +169,7 @@ const { supportPickerAnchor, supportMessengerEntries, messengerMeta, + isChatEnabled, hasMessengerLink: showSupportButton, openMessengerEntry, openSupportMessenger, @@ -147,6 +178,10 @@ const { const supportButtonEl = ref(null) const pickerAnchorEl = computed(() => supportPickerAnchor.value ?? supportButtonEl.value) +const supportChatUnreadCount = computed(() => + isChatEnabled.value ? Number(unreadSupportCount.value) || 0 : 0, +) + const supportIcon = computed(() => messengerMeta.value.icon) const supportAccentColor = computed(() => { @@ -226,6 +261,7 @@ watch( void import('@/utils/safeAreaInsets').then(({ syncSafeAreaInsets }) => { syncSafeAreaInsets() }) + showBanner() }, { immediate: true }, ) @@ -241,6 +277,64 @@ watch( { 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', + }) +}) + diff --git a/src/composables/useAppNotificationBadge.ts b/src/composables/useAppNotificationBadge.ts index c63352b..9ab036c 100644 --- a/src/composables/useAppNotificationBadge.ts +++ b/src/composables/useAppNotificationBadge.ts @@ -1,35 +1,65 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { notification } from 'ant-design-vue' -import { getAppNotifications, getAppNotificationsUnreadCount } from '@/api/appNotifications' +import { getAppNotificationsUnreadCount } from '@/api/appNotifications' import { useSettingsStore } from '@/store/settingsStore' import { getToken } from '@/utils' import { showBrowserNotification } from '@/utils/browserNotification' +import { priceSocketConnected, priceWssEnabled } from '@/utils/priceSocketState' +import { APP_Z_TOAST } from '@/utils/zIndex' const unreadCount = ref(0) -let pollTimer: ReturnType | null = null let started = false let lastStreamNotifId = 0 let hydratedUnread = false let lastShownNotifId = 0 +let toastConfigured = false +let hydrateFallbackTimer: ReturnType | 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) { + 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() { - const settingsStore = useSettingsStore() - if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) { + if (!notificationsEnabled()) { unreadCount.value = 0 hydratedUnread = false return } try { const data = await getAppNotificationsUnreadCount() - const next = Number(data?.unread_count ?? 0) || 0 - const prev = unreadCount.value - - // اگر SSE نرسید ولی تعداد خوانده‌نشده زیاد شد → نوتیف بساز - if (hydratedUnread && next > prev) { - void notifyFromLatestUnread() - } - - unreadCount.value = next + unreadCount.value = Number(data?.unread_count ?? 0) || 0 hydratedUnread = true } catch { // silent — badge optional @@ -42,12 +72,14 @@ function clearUnreadLocally() { function showInAppToast(title: string, body: string) { try { + ensureNotifToastConfig() notification.open({ message: title || 'اعلان جدید', description: body || undefined, placement: 'topRight', duration: 5, class: 'app-notif-toast', + style: { zIndex: APP_Z_TOAST }, onClick: () => { void import('@/router').then((mod) => { mod.default.push({ name: 'appNotifications' }).catch(() => undefined) @@ -84,25 +116,9 @@ async function presentNotification(payload: { }) } -async function notifyFromLatestUnread() { - 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 */ +/** از SSE رویداد app_notification — بدون polling HTTP */ function bumpUnreadFromStream(payload: Record) { - const settingsStore = useSettingsStore() - if (!getToken() || !settingsStore.isSettingTruthy('has_page_notification')) { + if (!notificationsEnabled()) { return } @@ -114,13 +130,50 @@ function bumpUnreadFromStream(payload: Record) { 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({ id, title: String(payload.title ?? ''), 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() { @@ -128,25 +181,27 @@ export function useAppNotificationBadge() { const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification')) onMounted(() => { - if (started) { - void refreshUnreadCount() - return - } started = true - void refreshUnreadCount() - // هر ۲۰ثانیه — اگر SSE قطع بود هم نوتیف دیرتر می‌آید - pollTimer = setInterval(() => { - void refreshUnreadCount() - }, 20000) + scheduleHydrate() }) onUnmounted(() => { - // keep poll for other mounted headers + // module-level badge — keep state for other headers }) watch(enabled, (on) => { - if (!on) unreadCount.value = 0 - else void refreshUnreadCount() + if (!on) { + unreadCount.value = 0 + hydratedUnread = false + clearHydrateFallback() + } else { + scheduleHydrate() + } + }) + + watch([priceSocketConnected, priceWssEnabled], () => { + if (!started) return + scheduleHydrate() }) return { @@ -157,4 +212,9 @@ export function useAppNotificationBadge() { } } -export { refreshUnreadCount, clearUnreadLocally, bumpUnreadFromStream } +export { + refreshUnreadCount, + clearUnreadLocally, + bumpUnreadFromStream, + applyUnreadCountFromStream, +} diff --git a/src/composables/useSupportMessenger.ts b/src/composables/useSupportMessenger.ts index 1506d5d..a89b5be 100644 --- a/src/composables/useSupportMessenger.ts +++ b/src/composables/useSupportMessenger.ts @@ -1,5 +1,6 @@ import { computed, ref } from 'vue' import { message } from 'ant-design-vue' +import { useRouter } from 'vue-router' import { useConfigStore } from '@/store/configStore' import { getProjectBaleContactLink, @@ -13,7 +14,6 @@ import { loadSupportMessengerFromSettings, messengerNeedsClipboardCopy, openSupportMessengerEntry, - openSupportMessengerWithMessage, resolveDepositGuideText, resolveDepositSupportMessage, resolveSupportMessengerEntries, @@ -33,16 +33,29 @@ import { hasDepositFieldValue, } from '@/utils/depositGuideText' import { useSettingsStore } from '@/store/settingsStore' +import { isUserSupportChatEnabled } from '@/utils/chat' /** plain: منوی شناور / درباره ما — بدون پیش‌متن | deposit: مودال واریز — با deposit_support_message_text */ 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() { const config = useConfigStore() const settingsStore = useSettingsStore() + const router = useRouter() const supportPickerOpen = supportMessengerPickerOpen const supportPickerIntent = ref('plain') + const isChatEnabled = computed(() => isUserSupportChatEnabled()) + const legacySupportLink = computed(() => { if (config.config?.name === 'garoosi' || isBazretalaProject()) { return getProjectBaleContactLink() @@ -52,26 +65,40 @@ export function useSupportMessenger() { const depositSupportMessage = computed(() => resolveDepositSupportMessage()) - /** لینک‌های تمیز بدون پیش‌متن — متن فقط موقع باز کردن (قصد deposit) اضافه می‌شود */ const supportMessengerEntries = computed(() => 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 messengerType = computed( () => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(), ) - const messengerMeta = computed(() => - primaryMessengerEntry.value + const messengerMeta = computed(() => { + 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, icon: primaryMessengerEntry.value.icon, accent: primaryMessengerEntry.value.accent, } - : resolveSupportMessengerMeta(messengerType.value), - ) + : resolveSupportMessengerMeta(messengerType.value) + }) const messengerLink = computed(() => 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) { - // فقط وقتی از مودال واریز آمده‌ایم پیش‌نویس می‌گذاریم؛ منوی شناور همیشه خالی + if (entry.messenger === 'chat') { + return goToSupportChat() + } + const messageText = text !== undefined ? text.trim() @@ -112,21 +147,25 @@ export function useSupportMessenger() { const draft = intent === 'deposit' ? depositSupportMessage.value.trim() : '' - const entries = supportMessengerEntries.value + const messengerEntries = supportMessengerEntries.value + const entries = pickerEntries.value + // لیست خالی + چت خاموش → هیچ‌کار if (entries.length === 0) { - return openSupportMessengerWithMessage( - supportMessengerSettings.value.messenger, - supportMessengerSettings.value.messengerId, - draft, - legacySupportLink.value, - ) + return false } + // فقط چت (لیست پیام‌رسان خالی) → مستقیم چت + if (isChatEnabled.value && messengerEntries.length === 0) { + return goToSupportChat() + } + + // فقط یک گزینه (چت یا یک پیام‌رسان) → مستقیم همان if (entries.length === 1) { return openMessengerEntry(entries[0], draft) } + // چند گزینه (چت + لینک‌ها یا چند پیام‌رسان) → منوی انتخاب const now = Date.now() if (supportPickerOpen.value) { if (now - supportPickerOpenedAt < 450) return true @@ -139,12 +178,10 @@ export function useSupportMessenger() { return true } - /** منوی شناور / صفحات درباره ما — بدون پیش‌نویس پیام */ async function openSupportMessenger(anchorEl?: HTMLElement | null) { return openSupportPicker('plain', anchorEl) } - /** مودال راهنمای واریز — با متن deposit_support_message_text */ async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) { return openSupportPicker('deposit', anchorEl) } @@ -225,8 +262,10 @@ export function useSupportMessenger() { ) } - const hasMessengerLink = computed(() => supportMessengerEntries.value.length > 0 || !!messengerLink.value) - const hasMultipleMessengers = computed(() => supportMessengerEntries.value.length > 1) + const hasMessengerLink = computed( + () => isChatEnabled.value || supportMessengerEntries.value.length > 0, + ) + const hasMultipleMessengers = computed(() => pickerEntries.value.length > 1) const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value) const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value) const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value)) @@ -234,7 +273,8 @@ export function useSupportMessenger() { return { supportPickerOpen, supportPickerAnchor: supportMessengerPickerAnchor, - supportMessengerEntries, + supportMessengerEntries: pickerEntries, + isChatEnabled, messengerType, messengerMeta, messengerLink, @@ -256,6 +296,7 @@ export function useSupportMessenger() { openDepositSupportMessenger, openMessengerEntry, handleSupportMessengerClick, + goToSupportChat, hasMessengerLink, hasMultipleMessengers, hasSupportPhoneLink, diff --git a/src/layouts/composables/useAppNavigation.ts b/src/layouts/composables/useAppNavigation.ts index cc8a6cb..30137c9 100644 --- a/src/layouts/composables/useAppNavigation.ts +++ b/src/layouts/composables/useAppNavigation.ts @@ -8,6 +8,10 @@ export function useAppNavigation() { const popVisible = ref(false) const currentRoute = computed(() => route.name) const hideMenu = computed(() => !!route.meta.hideMenu) + /** فقط ناو پایین موبایل — سایدبار دسکتاپ را مخفی نمی‌کند */ + const hideBottomNav = computed( + () => !!route.meta.hideBottomNav || !!route.meta.hideMenu, + ) function isNavActive(routeName: string) { if (currentRoute.value === routeName) return true @@ -20,6 +24,11 @@ export function useAppNavigation() { 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 } @@ -44,6 +53,7 @@ export function useAppNavigation() { popVisible, currentRoute, hideMenu, + hideBottomNav, isNavActive, navItemClass, navigateTo, diff --git a/src/layouts/mobile/MobileAppLayout.vue b/src/layouts/mobile/MobileAppLayout.vue index cb691d3..fa4ff6a 100644 --- a/src/layouts/mobile/MobileAppLayout.vue +++ b/src/layouts/mobile/MobileAppLayout.vue @@ -1,6 +1,6 @@ diff --git a/src/views/base/piggy/index.vue b/src/views/base/piggy/index.vue new file mode 100644 index 0000000..b6be005 --- /dev/null +++ b/src/views/base/piggy/index.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/src/views/base/piggy/info.vue b/src/views/base/piggy/info.vue new file mode 100644 index 0000000..acc8b00 --- /dev/null +++ b/src/views/base/piggy/info.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/src/views/base/piggy/new.vue b/src/views/base/piggy/new.vue new file mode 100644 index 0000000..3a914c0 --- /dev/null +++ b/src/views/base/piggy/new.vue @@ -0,0 +1,356 @@ + + + + + diff --git a/src/views/base/piggy/piggyIcons.ts b/src/views/base/piggy/piggyIcons.ts new file mode 100644 index 0000000..2534130 --- /dev/null +++ b/src/views/base/piggy/piggyIcons.ts @@ -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() + 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')) +} diff --git a/src/views/base/products/components/DealProduct.vue b/src/views/base/products/components/DealProduct.vue index 9c42af0..563dd0d 100644 --- a/src/views/base/products/components/DealProduct.vue +++ b/src/views/base/products/components/DealProduct.vue @@ -223,7 +223,7 @@
قیمت:
- {{ usePriceWithoutType(props?.ProductItem?.price) }} + {{ usePriceWithoutType(isBuy ? buyTotalPrice : sellTotalPrice) }}
ریال
diff --git a/src/views/base/user/components/UserProfileForm.vue b/src/views/base/user/components/UserProfileForm.vue index 932de64..72de29f 100644 --- a/src/views/base/user/components/UserProfileForm.vue +++ b/src/views/base/user/components/UserProfileForm.vue @@ -2,11 +2,11 @@ -
+
@@ -28,10 +28,10 @@
-
@@ -103,6 +123,7 @@ const { menuActions, copyCode, shareCode, + requestLogout, } = useUserPageContext() @@ -126,10 +147,33 @@ const { overscroll-behavior-y: contain; } +.user-menu-icon-wrap { + position: absolute; + right: 2rem; + display: inline-flex; +} + .user-menu-icon-image { width: 18px; height: 18px; object-fit: contain; 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; +} diff --git a/src/views/login/components/Login.vue b/src/views/login/components/Login.vue index f2d1474..d15b852 100644 --- a/src/views/login/components/Login.vue +++ b/src/views/login/components/Login.vue @@ -293,6 +293,10 @@ import { } from "@/utils/config"; import { normalizeAuthFormFields } from '@/utils/persianDigits' import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp' +import { + canRequestBrowserNotification, + requestBrowserNotificationPermission, +} from '@/utils/browserNotification' import {getConfig} from "@/api/config"; import { usePwaInstall } from '@/composables/usePwaInstall' import {getProducts} from "@/api/products"; @@ -426,11 +430,18 @@ function phoneVerification() { stepTwoVerify.value = true reset() } else { + // نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود + if (canRequestBrowserNotification()) { + void requestBrowserNotificationPermission() + } const { access_token } = await verifyToken(userFormState) await login(access_token) } } else { userFormState.token = undefined + if (canRequestBrowserNotification()) { + void requestBrowserNotificationPermission() + } const { access_token } = await loginCustomer(userFormState) await login(access_token) } diff --git a/src/views/login/composables/useLoginForm.ts b/src/views/login/composables/useLoginForm.ts index ffc863e..89f5b96 100644 --- a/src/views/login/composables/useLoginForm.ts +++ b/src/views/login/composables/useLoginForm.ts @@ -19,6 +19,10 @@ import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollRe import { normalizeAuthFormFields } from '@/utils/persianDigits' import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp' import { usePwaInstall } from '@/composables/usePwaInstall' +import { + canRequestBrowserNotification, + requestBrowserNotificationPermission, +} from '@/utils/browserNotification' export function useLoginForm() { const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss') @@ -109,11 +113,18 @@ export function useLoginForm() { stepTwoVerify.value = true reset() } else { + // نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود + if (canRequestBrowserNotification()) { + void requestBrowserNotificationPermission() + } const { access_token } = await verifyToken(userFormState) await login(access_token) } } else { userFormState.token = undefined + if (canRequestBrowserNotification()) { + void requestBrowserNotificationPermission() + } const { access_token } = await loginCustomer(userFormState) await login(access_token) } diff --git a/src/views/login/desktop/components/DesktopAuthShell.vue b/src/views/login/desktop/components/DesktopAuthShell.vue index 736fa58..20da72e 100644 --- a/src/views/login/desktop/components/DesktopAuthShell.vue +++ b/src/views/login/desktop/components/DesktopAuthShell.vue @@ -1,6 +1,10 @@ diff --git a/src/views/login/desktop/styles/desktop-auth.less b/src/views/login/desktop/styles/desktop-auth.less index d2b7b17..85a281d 100644 --- a/src/views/login/desktop/styles/desktop-auth.less +++ b/src/views/login/desktop/styles/desktop-auth.less @@ -10,7 +10,8 @@ .dauth__layout { display: grid; - grid-template-columns: minmax(380px, 1fr) minmax(420px, 520px); + /* فرم | برند — در RTL: برند چپ، فرم راست؛ نسبت نزدیک به توازن */ + grid-template-columns: minmax(480px, 1fr) minmax(420px, 1.05fr); width: 100%; min-height: 100vh; } @@ -136,12 +137,12 @@ display: flex; align-items: center; justify-content: center; - padding: 48px 56px; + padding: 48px 48px; } .dauth-card { width: 100%; - max-width: 420px; + max-width: 520px; background: #ffffff; border-radius: 16px; padding: 36px 40px; @@ -471,7 +472,7 @@ @media (max-width: 900px) { .dauth__layout { - grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr); + grid-template-columns: minmax(340px, 1fr) minmax(300px, 1fr); } .dauth-brand { diff --git a/src/views/support/components/SupportChatComposer.vue b/src/views/support/components/SupportChatComposer.vue new file mode 100644 index 0000000..eb54086 --- /dev/null +++ b/src/views/support/components/SupportChatComposer.vue @@ -0,0 +1,826 @@ +