fix some bugs
This commit is contained in:
@@ -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<typeof setInterval> | null = null
|
||||
let started = false
|
||||
let lastStreamNotifId = 0
|
||||
let hydratedUnread = false
|
||||
let lastShownNotifId = 0
|
||||
let toastConfigured = false
|
||||
let hydrateFallbackTimer: ReturnType<typeof setTimeout> | 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<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() {
|
||||
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<string, unknown>) {
|
||||
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,
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+18
-25
@@ -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<TableOutput<Fund> | undefined>(undefined)
|
||||
const myPiggyFunds = ref<TableOutput<Fund> | 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<string[]> = 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 }
|
||||
})
|
||||
|
||||
@@ -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<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>) {
|
||||
try {
|
||||
const { bumpUnreadFromStream } = await import('@/composables/useAppNotificationBadge')
|
||||
bumpUnreadFromStream(payload)
|
||||
} catch (error) {
|
||||
console.warn('App notification SSE apply failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/** toast اعلان درونبرنامه — بالای هدر و ناوبری */
|
||||
export const APP_Z_TOAST = 5050
|
||||
|
||||
/** Overlay dialogs (loading / blocking) */
|
||||
export const APP_Z_OVERLAY = 5600
|
||||
|
||||
@@ -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<number | undefined>()
|
||||
@@ -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')
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<!-- <div class="bg-gradient-to-l mt-3 p-1 shadow-lg from-primary rounded-lg p-px to-transparent ">-->
|
||||
<div class="mt-3 p-1 shadow-lg from-primary rounded-lg p-px to-transparent border-solid border border-primary">
|
||||
<div class=" p-1 py-6 rounded-lg bg-white">
|
||||
<div class="flex justify-between ">
|
||||
<div class="flex justify-between items-start gap-1">
|
||||
<!-- <div
|
||||
class="text-center w-full flex-flex-col items-center home_appear_fade_in_up"
|
||||
style="text-align: -webkit-center"
|
||||
@@ -83,7 +83,7 @@
|
||||
<div class="text-primary">فروش</div>
|
||||
</div>-->
|
||||
<div
|
||||
class="text-center w-full flex-flex-col items-center home_appear_fade_in_up"
|
||||
class="text-center min-w-0 flex-1 flex-flex-col items-center home_appear_fade_in_up"
|
||||
style="text-align: -webkit-center"
|
||||
@click="router.push({ name: 'gold' })"
|
||||
>
|
||||
@@ -136,6 +136,20 @@
|
||||
</div>
|
||||
<div class="text-primary">دارایی ها</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="productFundsEnabled"
|
||||
class="text-center w-full flex-flex-col items-center home_appear_fade_in_down"
|
||||
style="text-align: -webkit-center"
|
||||
@click="router.push({ name: 'piggy' })"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-[50px] h-[50px] bg-primary flex items-center justify-center"
|
||||
style=" border: solid 1px #a72b77"
|
||||
>
|
||||
<Icon class="text-white font-bold" icon="vuesax-linear:safe-home" />
|
||||
</div>
|
||||
<div class="text-primary">قلک</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-center w-full flex-flex-col items-center home_appear_fade_in_down"
|
||||
style="text-align: -webkit-center"
|
||||
@@ -438,6 +452,7 @@ import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { loading, removeToken } from '@/utils'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import DialogVant from '@/components/DialogVant.vue'
|
||||
import { getAllPrices, getChart } from '@/api/home'
|
||||
import Vue3Odometer from 'vue3-odometer'
|
||||
@@ -470,7 +485,9 @@ import AppHeader from '@/views/base/home/components/AppHeader.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const walletStore = useWalletStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const isDesktop = useDesktopBreakpoint()
|
||||
const productFundsEnabled = computed(() => settingsStore.isSettingTruthy('enable_product_funds'))
|
||||
const chartLoading = ref<boolean>(false)
|
||||
const openModal = ref<boolean>(false)
|
||||
const wholeBalanceVisible = ref<boolean>(false)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="piggy-page">
|
||||
<header class="piggy-topbar">
|
||||
<button type="button" class="piggy-icon-btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-topbar__title">قلک</h1>
|
||||
<span class="w-9" />
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<section class="piggy-hero">
|
||||
<div class="piggy-hero__text">
|
||||
<p class="piggy-hero__label">موجودی قلکها</p>
|
||||
<p class="piggy-hero__value" dir="ltr">
|
||||
<span>{{ formatPiggyAmount(totalGold) }}</span>
|
||||
<span class="piggy-hero__unit">گرم</span>
|
||||
</p>
|
||||
<p class="piggy-hero__hint">{{ rows.length }} قلک فعال</p>
|
||||
</div>
|
||||
<img class="piggy-hero__art" :src="heroImg" alt="" />
|
||||
</section>
|
||||
|
||||
<section class="piggy-sheet">
|
||||
<div v-if="!rows.length" class="piggy-empty">
|
||||
<img :src="emptyImg" alt="" class="piggy-empty__img" />
|
||||
<p class="piggy-empty__title">هنوز قلکی نساختهاید</p>
|
||||
<p class="piggy-empty__desc">موجودی هر محصول را در قلکهای جدا نگه دارید</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="piggy-grid">
|
||||
<button
|
||||
v-for="item in rows"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="piggy-tile"
|
||||
@click="openInfo(item.id!)"
|
||||
>
|
||||
<div class="piggy-tile__icon-wrap">
|
||||
<img :src="piggyIconForTitle(item.title)" alt="" class="piggy-tile__icon" />
|
||||
</div>
|
||||
<div class="piggy-tile__title">{{ item.title || 'بدون عنوان' }}</div>
|
||||
<div class="piggy-tile__product">{{ fundProductLabel(item) }}</div>
|
||||
<div class="piggy-tile__amount" dir="ltr">
|
||||
{{ formatPiggyAmount(fundWalletGold(item)) }}
|
||||
<span class="piggy-tile__unit">گرم</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BasicButton class="!w-full mt-5" 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>افزودن قلک</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
</section>
|
||||
</a-spin>
|
||||
</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 cashImg from '@/assets/img/cash_img.svg'
|
||||
import emptyImg from '@/assets/img/cash_empty.svg'
|
||||
import {
|
||||
formatPiggyAmount,
|
||||
fundProductLabel,
|
||||
fundWalletGold,
|
||||
piggyIconForTitle,
|
||||
} from './piggyIcons'
|
||||
|
||||
const fundStore = useFundUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const busy = ref(false)
|
||||
const heroImg = cashImg
|
||||
|
||||
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
|
||||
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 {
|
||||
@apply min-h-full pb-28;
|
||||
background: linear-gradient(180deg, #f3efe8 0%, #f8fafc 42%, #f8fafc 100%);
|
||||
}
|
||||
.piggy-topbar {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between px-3 py-3;
|
||||
background: rgba(243, 239, 232, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.piggy-topbar__title {
|
||||
@apply m-0 text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-icon-btn {
|
||||
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-white/80 text-gray-800;
|
||||
}
|
||||
.piggy-hero {
|
||||
@apply relative flex items-end justify-between gap-3 overflow-hidden px-5 pb-8 pt-2;
|
||||
}
|
||||
.piggy-hero__label {
|
||||
@apply m-0 text-sm text-gray-600;
|
||||
}
|
||||
.piggy-hero__value {
|
||||
@apply m-0 mt-1 flex items-baseline gap-2 text-3xl font-bold text-gray-900;
|
||||
}
|
||||
.piggy-hero__unit {
|
||||
@apply text-sm font-bold text-gray-500;
|
||||
}
|
||||
.piggy-hero__hint {
|
||||
@apply m-0 mt-2 text-xs text-gray-500;
|
||||
}
|
||||
.piggy-hero__art {
|
||||
@apply h-28 w-28 shrink-0 object-contain;
|
||||
}
|
||||
.piggy-sheet {
|
||||
@apply relative z-10 -mt-2 rounded-t-3xl bg-white px-4 pb-6 pt-5 shadow-sm;
|
||||
min-height: 55vh;
|
||||
}
|
||||
.piggy-grid {
|
||||
@apply grid grid-cols-2 gap-3;
|
||||
}
|
||||
.piggy-tile {
|
||||
@apply flex flex-col items-center rounded-2xl border-none bg-gray-50 p-3 text-center;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.piggy-tile__icon-wrap {
|
||||
@apply mb-2 flex h-20 w-full items-center justify-center rounded-xl bg-sky-50;
|
||||
}
|
||||
.piggy-tile__icon {
|
||||
@apply h-16 w-16 object-contain;
|
||||
}
|
||||
.piggy-tile__title {
|
||||
@apply w-full truncate text-sm font-bold text-gray-900;
|
||||
}
|
||||
.piggy-tile__product {
|
||||
@apply mt-0.5 w-full truncate text-[11px] text-gray-500;
|
||||
}
|
||||
.piggy-tile__amount {
|
||||
@apply mt-2 flex items-baseline justify-center gap-1 text-sm font-bold text-primary;
|
||||
}
|
||||
.piggy-tile__unit {
|
||||
@apply text-[11px] font-medium text-gray-500;
|
||||
}
|
||||
.piggy-empty {
|
||||
@apply flex flex-col items-center px-4 py-10 text-center;
|
||||
}
|
||||
.piggy-empty__img {
|
||||
@apply mb-3 h-28 w-28 object-contain opacity-90;
|
||||
}
|
||||
.piggy-empty__title {
|
||||
@apply m-0 text-base font-bold text-gray-800;
|
||||
}
|
||||
.piggy-empty__desc {
|
||||
@apply m-0 mt-2 text-sm text-gray-500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="piggy-info">
|
||||
<header class="piggy-info__top">
|
||||
<button type="button" class="piggy-icon-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-icon-btn piggy-icon-btn--danger" aria-label="حذف" @click="onDelete">
|
||||
<BasicIcon icon="vuesax-linear:trash" size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<section class="piggy-info__hero">
|
||||
<div class="piggy-info__icon-wrap">
|
||||
<img :src="iconSrc" alt="" class="piggy-info__icon" />
|
||||
</div>
|
||||
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
|
||||
<p class="piggy-info__balance-label">موجودی این قلک</p>
|
||||
<p class="piggy-info__balance" dir="ltr">
|
||||
<span>{{ formatPiggyAmount(potGold) }}</span>
|
||||
<span class="piggy-info__unit">گرم</span>
|
||||
</p>
|
||||
<p class="piggy-info__free">
|
||||
آزاد قابل واریز:
|
||||
<span dir="ltr" class="font-bold text-gray-800">{{ formatPiggyAmount(freeGold) }}</span>
|
||||
گرم
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="piggy-info__actions">
|
||||
<BasicButton class="!flex-1" size="middle" @click="openTx(false)">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
|
||||
<span>برداشت</span>
|
||||
</div>
|
||||
</BasicButton>
|
||||
<BasicButton class="!flex-1" type="primary" size="middle" @click="openTx(true)">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<BasicIcon icon="vuesax-linear:arrow-up-1" 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">
|
||||
<img :src="emptyImg" alt="" class="piggy-info__tx-empty-img" />
|
||||
<p class="m-0 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="min-w-0 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>
|
||||
</a-spin>
|
||||
|
||||
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
|
||||
<div class="flex flex-col gap-3 p-1">
|
||||
<p class="m-0 text-xs text-gray-500">
|
||||
{{ isDeposit ? `حداکثر آزاد: ${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 emptyImg from '@/assets/img/cash_empty.svg'
|
||||
import { formatPiggyAmount, piggyIconForTitle } 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 iconSrc = computed(() => piggyIconForTitle(fund.value?.title))
|
||||
|
||||
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 {
|
||||
@apply min-h-full bg-gray-50 pb-28;
|
||||
}
|
||||
.piggy-info__top {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3 shadow-sm;
|
||||
}
|
||||
.piggy-info__title {
|
||||
@apply m-0 max-w-[55%] truncate text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-icon-btn {
|
||||
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-gray-50 text-gray-800;
|
||||
}
|
||||
.piggy-icon-btn--danger {
|
||||
@apply text-red-500;
|
||||
}
|
||||
.piggy-info__hero {
|
||||
@apply flex flex-col items-center px-4 pt-6;
|
||||
}
|
||||
.piggy-info__icon-wrap {
|
||||
@apply flex h-36 w-36 items-center justify-center rounded-3xl bg-sky-50;
|
||||
}
|
||||
.piggy-info__icon {
|
||||
@apply h-28 w-28 object-contain;
|
||||
}
|
||||
.piggy-info__product {
|
||||
@apply m-0 mt-3 rounded-full bg-white px-3 py-1 text-xs text-gray-600 shadow-sm;
|
||||
}
|
||||
.piggy-info__balance-label {
|
||||
@apply m-0 mt-4 text-sm text-gray-500;
|
||||
}
|
||||
.piggy-info__balance {
|
||||
@apply m-0 mt-1 flex items-baseline gap-2 text-3xl font-bold text-gray-900;
|
||||
}
|
||||
.piggy-info__unit {
|
||||
@apply text-sm font-bold text-gray-500;
|
||||
}
|
||||
.piggy-info__free {
|
||||
@apply m-0 mt-2 text-xs text-gray-500;
|
||||
}
|
||||
.piggy-info__actions {
|
||||
@apply mt-5 flex gap-3 px-4;
|
||||
}
|
||||
.piggy-info__tx {
|
||||
@apply mt-6 px-4;
|
||||
}
|
||||
.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 shadow-sm;
|
||||
}
|
||||
.piggy-info__tx-empty-img {
|
||||
@apply mb-3 h-24 w-24 object-contain opacity-80;
|
||||
}
|
||||
.piggy-info__tx-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.piggy-info__tx-row {
|
||||
@apply flex items-center justify-between gap-3 rounded-2xl bg-white p-3 shadow-sm;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="piggy-create">
|
||||
<header class="piggy-create__top">
|
||||
<button type="button" class="piggy-icon-btn" aria-label="بازگشت" @click="goBack">
|
||||
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
|
||||
</button>
|
||||
<h1 class="piggy-create__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
|
||||
<span class="w-9" />
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="busy">
|
||||
<div class="piggy-create__body">
|
||||
<div class="piggy-create__preview">
|
||||
<img :src="previewImg" alt="" class="piggy-create__preview-img" />
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__card">
|
||||
<label class="piggy-create__label">محصول قلک</label>
|
||||
<BasicSelect
|
||||
v-model:value="productId"
|
||||
class="!w-full"
|
||||
key-name="id"
|
||||
value-name="title"
|
||||
:items="products"
|
||||
:disabled="Boolean(editId)"
|
||||
placeholder="انتخاب محصول"
|
||||
/>
|
||||
<p class="piggy-create__hint">هر قلک فقط یک محصول دارد — طلا و سکه مخلوط نمیشوند</p>
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__card">
|
||||
<label class="piggy-create__label">عنوان قلک</label>
|
||||
<BasicInput v-model:value="title" placeholder="مثلاً پسانداز" maxlength="25" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="piggy-create__footer">
|
||||
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
|
||||
{{ editId ? 'ذخیره تغییرات' : 'ساخت قلک' }}
|
||||
</BasicButton>
|
||||
</div>
|
||||
</a-spin>
|
||||
</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, piggyIconForTitle } 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 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
|
||||
})
|
||||
|
||||
const previewImg = computed(() => piggyIconForTitle(title.value))
|
||||
|
||||
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
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.piggy-create {
|
||||
@apply min-h-full bg-gray-50 pb-28;
|
||||
}
|
||||
.piggy-create__top {
|
||||
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3 shadow-sm;
|
||||
}
|
||||
.piggy-create__title {
|
||||
@apply m-0 text-base font-bold text-gray-900;
|
||||
}
|
||||
.piggy-icon-btn {
|
||||
@apply flex h-9 w-9 items-center justify-center rounded-full border-none bg-gray-50 text-gray-800;
|
||||
}
|
||||
.piggy-create__body {
|
||||
@apply flex flex-col items-center gap-4 px-4 pt-6;
|
||||
}
|
||||
.piggy-create__preview {
|
||||
@apply flex h-32 w-32 items-center justify-center rounded-2xl bg-sky-50;
|
||||
}
|
||||
.piggy-create__preview-img {
|
||||
@apply h-24 w-24 object-contain;
|
||||
}
|
||||
.piggy-create__card {
|
||||
@apply w-full rounded-2xl bg-white p-4 shadow-sm;
|
||||
}
|
||||
.piggy-create__label {
|
||||
@apply mb-2 block text-sm font-bold text-gray-800;
|
||||
}
|
||||
.piggy-create__hint {
|
||||
@apply m-0 mt-2 text-xs leading-5 text-gray-500;
|
||||
}
|
||||
.piggy-create__presets {
|
||||
@apply mt-3 flex gap-2 overflow-x-auto whitespace-nowrap pb-1;
|
||||
}
|
||||
.piggy-create__chip {
|
||||
@apply shrink-0 rounded-full border-none bg-gray-100 px-3 py-2 text-xs text-gray-600;
|
||||
}
|
||||
.piggy-create__chip--on {
|
||||
@apply bg-primary text-white;
|
||||
}
|
||||
.piggy-create__footer {
|
||||
@apply fixed bottom-20 left-0 right-0 z-20 px-4;
|
||||
}
|
||||
</style>
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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: 'درباره ما',
|
||||
|
||||
Reference in New Issue
Block a user