diff --git a/src/composables/useAppNotificationBadge.ts b/src/composables/useAppNotificationBadge.ts index 3ae90b7..9ab036c 100644 --- a/src/composables/useAppNotificationBadge.ts +++ b/src/composables/useAppNotificationBadge.ts @@ -1,21 +1,66 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { notification } from 'ant-design-vue' import { getAppNotificationsUnreadCount } from '@/api/appNotifications' import { useSettingsStore } from '@/store/settingsStore' -import { isToken } from '@/utils' +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 -async function refreshUnreadCount() { - const settingsStore = useSettingsStore() - if (!isToken() || !settingsStore.isSettingTruthy('has_page_notification')) { +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() { + if (!notificationsEnabled()) { + unreadCount.value = 0 + hydratedUnread = false return } try { const data = await getAppNotificationsUnreadCount() unreadCount.value = Number(data?.unread_count ?? 0) || 0 + hydratedUnread = true } catch { // silent — badge optional } @@ -25,29 +70,138 @@ function clearUnreadLocally() { unreadCount.value = 0 } +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) + }) + }, + }) + } catch { + // ignore + } +} + +async function presentNotification(payload: { + id?: number + title?: string + body?: string +}) { + const id = Number(payload.id ?? 0) + if (id > 0 && id === lastShownNotifId) { + return + } + if (id > 0) { + lastShownNotifId = id + } + + const title = String(payload.title ?? '').trim() || 'اعلان جدید' + const body = String(payload.body ?? '').trim() + + showInAppToast(title, body) + await showBrowserNotification({ + title, + body, + tag: id > 0 ? `app-notification-${id}` : undefined, + url: '/appNotifications', + }) +} + +/** از SSE رویداد app_notification — بدون polling HTTP */ +function bumpUnreadFromStream(payload: Record) { + if (!notificationsEnabled()) { + return + } + + const id = Number(payload.id ?? 0) + if (id > 0 && id === lastStreamNotifId) { + return + } + if (id > 0) { + lastStreamNotifId = id + } + + 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 ?? ''), + }) +} + +/** + * بدون 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() { const settingsStore = useSettingsStore() const enabled = computed(() => settingsStore.isSettingTruthy('has_page_notification')) onMounted(() => { - if (started) { - void refreshUnreadCount() - return - } started = true - void refreshUnreadCount() - pollTimer = setInterval(() => { - void refreshUnreadCount() - }, 60000) + scheduleHydrate() }) onUnmounted(() => { - // keep poll for other mounted headers; only clear if needed later + // 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 { @@ -58,4 +212,9 @@ export function useAppNotificationBadge() { } } -export { refreshUnreadCount, clearUnreadLocally } +export { + refreshUnreadCount, + clearUnreadLocally, + bumpUnreadFromStream, + applyUnreadCountFromStream, +} diff --git a/src/models/fund.ts b/src/models/fund.ts index 3d193c1..af181f4 100644 --- a/src/models/fund.ts +++ b/src/models/fund.ts @@ -1,16 +1,19 @@ import type {Wallet} from '@/models/wallet'; +import type {Product} from '@/models/products'; export interface Fund { id?: number, title?: string, file_id?: number, customer_id?: number, + product_id?: number, created_at?: string, updated_at?: string, deleted_at?: string, - wallet?: Wallet[] | Wallet + wallet?: Wallet[] | Wallet, + product?: Product, } export interface FundTransaction { fund_id?: number value?: number -} \ No newline at end of file +} diff --git a/src/router/index.ts b/src/router/index.ts index 2cb9f3b..f06de93 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -150,6 +150,21 @@ const router = createRouter({ name: 'fund_add', 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') + }, ] }, { diff --git a/src/store/fundStore.ts b/src/store/fundStore.ts index db55e5b..e91ea31 100644 --- a/src/store/fundStore.ts +++ b/src/store/fundStore.ts @@ -1,39 +1,32 @@ -import { type ComputedRef, ref } from 'vue' -// import { get } from 'lodash' -import {defineStore} from "pinia"; -import {getMe} from "@/api/me"; -import {getFundTable} from "@/api/fund"; -import type {Fund} from "@/models/fund"; -import type {TableOutput} from "@/models/table"; +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { getFundTable } from '@/api/fund' +import type { Fund } from '@/models/fund' +import type { TableOutput } from '@/models/table' import { getProductInventory } from '@/api/products' + export const useFundUserStore = defineStore('fund', () => { const myFunds = ref | undefined>(undefined) + const myPiggyFunds = ref | undefined>(undefined) + async function getMyFund() { try { - // console.log('fff') - myFunds.value = await getFundTable() + // دارایی‌ها — inventory؛ سازگار با صفحهٔ /fund + myFunds.value = await getProductInventory() return myFunds.value } catch (e) { console.log(e) } } -/* const permissions: ComputedRef = computed(() => { - const permissions: string[] = get(userInfo.value, 'permission', []) - const excludes: string[] = get(userInfo.value, 'exclude', []) - return permissions.filter((item) => !excludes.includes(item)) - })*/ - -/* - function canAccess(permissionKey: string) { - if (isAdmin.value) return true - return permissions.value.includes(permissionKey) + async function getMyPiggyFunds() { + try { + myPiggyFunds.value = await getFundTable() + return myPiggyFunds.value + } catch (e) { + console.log(e) + } } -*/ -/* - const isAdmin = computed(() => { - return userInfo.value?.is_admin - })*/ - return { getMyFund, myFunds/* permissions, canAccess, isAdmin*/ } + return { getMyFund, getMyPiggyFunds, myFunds, myPiggyFunds } }) diff --git a/src/utils/priceSocket.ts b/src/utils/priceSocket.ts index 0589c98..8c18491 100644 --- a/src/utils/priceSocket.ts +++ b/src/utils/priceSocket.ts @@ -344,6 +344,42 @@ function handleListPayload(payload: unknown) { applyProductsListFromStream(payload as Product[]) } +function handleAppNotificationPayload(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return + } + + touchStreamActivity() + void applyAppNotificationFromStream(payload as Record) +} + +function handleUnreadCountPayload(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return + } + + touchStreamActivity() + void applyUnreadCountFromStreamPayload(payload as Record) +} + +async function applyAppNotificationFromStream(payload: Record) { + try { + const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge') + bumpUnreadFromStream(payload) + } catch (error) { + console.warn('App notification SSE apply failed', error) + } +} + +async function applyUnreadCountFromStreamPayload(payload: Record) { + try { + const { applyUnreadCountFromStream } = await import('@/composables/useAppNotificationBadge') + applyUnreadCountFromStream(payload) + } catch (error) { + console.warn('Unread-count SSE apply failed', error) + } +} + function bindStreamEvents(source: EventSource) { source.addEventListener('last', (event) => { try { @@ -361,6 +397,22 @@ function bindStreamEvents(source: EventSource) { } }) + source.addEventListener('app_notification', (event) => { + try { + handleAppNotificationPayload(JSON.parse(event.data)) + } catch (error) { + console.warn('App notification SSE parse failed', error) + } + }) + + source.addEventListener('unread-count', (event) => { + try { + handleUnreadCountPayload(JSON.parse(event.data)) + } catch (error) { + console.warn('Unread-count SSE parse failed', error) + } + }) + source.addEventListener('ping', () => { touchStreamActivity() }) diff --git a/src/utils/zIndex.ts b/src/utils/zIndex.ts index dfcea2c..be047b4 100644 --- a/src/utils/zIndex.ts +++ b/src/utils/zIndex.ts @@ -1,2 +1,5 @@ +/** toast اعلان درون‌برنامه — بالای هدر و ناوبری */ +export const APP_Z_TOAST = 5050 + /** Overlay dialogs (loading / blocking) */ export const APP_Z_OVERLAY = 5600 diff --git a/src/views/base/home/desktop/GhatrehHomeDesktopView.vue b/src/views/base/home/desktop/GhatrehHomeDesktopView.vue index 769d1b9..3ec3f8a 100644 --- a/src/views/base/home/desktop/GhatrehHomeDesktopView.vue +++ b/src/views/base/home/desktop/GhatrehHomeDesktopView.vue @@ -158,6 +158,7 @@ import { getAllPrices } from '@/api/home' import { loading } from '@/utils' import { useUserStore } from '@/store/userStore' import { useWalletStore } from '@/store/walletStore' +import { useSettingsStore } from '@/store/settingsStore' import { useWalletAssets } from '@/composables/useWalletAssets' import { resolveAssetsRialValue } from '@/utils/productAssets' import { usePrice, usePriceWithoutType, useTowDecimal, formatGoldAmount } from '@/utils/usePrice' @@ -178,6 +179,7 @@ import { message } from 'ant-design-vue' const emit = defineEmits<{ logout: []; openBalance: [] }>() const userStore = useUserStore() const walletStore = useWalletStore() +const settingsStore = useSettingsStore() const { assetsSummary, loadWalletAssets } = useWalletAssets() const goldVal = ref() @@ -220,13 +222,29 @@ const totalRialDisplay = computed(() => resolveAssetsRialValue(assetsSummary.value, getTradeBuyPrice(price.value) || Number(goldVal.value ?? 0)) ) -const mainActions = [ - { name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) }, - { 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: () => emitBalance() }, - { name: 'fund', icon: 'vuesax-linear:archive-1', label: 'دارایی ها', handler: () => router.push({ name: 'fund' }) }, - { name: 'earn', icon: 'vuesax-linear:dollar-circle', label: 'کسب درآمد', handler: () => router.push({ name: 'earnMoney' }) }, -] +const mainActions = computed(() => { + const actions = [ + { name: 'buySell', icon: 'vuesax-linear:money-receive', label: 'خرید و فروش', handler: () => router.push({ name: 'gold' }) }, + { 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: () => emitBalance() }, + { name: 'fund', icon: 'vuesax-linear:archive-1', 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' }), + }) + } + actions.push({ + name: 'earn', + icon: 'vuesax-linear:dollar-circle', + label: 'کسب درآمد', + handler: () => router.push({ name: 'earnMoney' }), + }) + return actions +}) function emitBalance() { emit('openBalance') diff --git a/src/views/base/home/index.vue b/src/views/base/home/index.vue index 2489832..81a9548 100644 --- a/src/views/base/home/index.vue +++ b/src/views/base/home/index.vue @@ -67,7 +67,7 @@
-
+
@@ -136,6 +136,20 @@
دارایی ها
+
+
+ +
+
قلک
+
settingsStore.isSettingTruthy('enable_product_funds')) const chartLoading = ref(false) const openModal = ref(false) const wholeBalanceVisible = ref(false) diff --git a/src/views/base/piggy/index.vue b/src/views/base/piggy/index.vue new file mode 100644 index 0000000..fa96d5e --- /dev/null +++ b/src/views/base/piggy/index.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/src/views/base/piggy/info.vue b/src/views/base/piggy/info.vue new file mode 100644 index 0000000..16ddca1 --- /dev/null +++ b/src/views/base/piggy/info.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/views/base/piggy/new.vue b/src/views/base/piggy/new.vue new file mode 100644 index 0000000..60ab529 --- /dev/null +++ b/src/views/base/piggy/new.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/src/views/base/piggy/piggyIcons.ts b/src/views/base/piggy/piggyIcons.ts new file mode 100644 index 0000000..54707dc --- /dev/null +++ b/src/views/base/piggy/piggyIcons.ts @@ -0,0 +1,51 @@ +import homeImg from '@/assets/img/cash_home.svg' +import giftImg from '@/assets/img/cash_gift.svg' +import circulationImg from '@/assets/img/cash_circulation.svg' +import loveImg from '@/assets/img/cash_love.svg' +import repairsImg from '@/assets/img/cash_repairs.svg' +import savingImg from '@/assets/img/cash_saving.svg' +import type { Fund } from '@/models/fund' + +export const PIGGY_PRESETS = [ + 'پس انداز', + 'هدیه', + 'ازدواج', + 'تعمیرات', + 'گردش', + 'خرید لوازم منزل', +] as const + +export function piggyIconForTitle(title?: string | null): string { + switch ((title ?? '').trim()) { + case 'خرید لوازم منزل': + return homeImg + case 'هدیه': + return giftImg + case 'گردش': + return circulationImg + case 'ازدواج': + return loveImg + case 'تعمیرات': + return repairsImg + case 'پس انداز': + default: + return savingImg + } +} + +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 formatPiggyAmount(value: number): string { + const n = Number(value) || 0 + if (Math.abs(n) < 0.0005) return '۰' + return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 }) +} diff --git a/src/views/base/user/composables/useUserPage.ts b/src/views/base/user/composables/useUserPage.ts index eccee6d..19b437a 100644 --- a/src/views/base/user/composables/useUserPage.ts +++ b/src/views/base/user/composables/useUserPage.ts @@ -111,6 +111,13 @@ export function useUserPage() { onClick: () => router.push({ name: 'notifyTrigger' }), 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', label: 'درباره ما',