Port modular features into boursecu and fix desktop login UX.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 20:14:46 +02:00
co-authored by Cursor
parent be896fe247
commit 42aef88064
55 changed files with 7488 additions and 434 deletions
@@ -33,6 +33,20 @@
</span>
</span>
</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
type="button"
class="mhome-header-btn"
@@ -67,6 +81,7 @@ import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
const headerRef = ref<HTMLElement | null>(null)
const isHeaderScrolled = ref(false)
@@ -74,6 +89,8 @@ const canNativeInstall = ref(false)
const route = useRoute()
const projectLogo = computed(() => getProjectMobileNavLogo())
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const {
enabled: notificationEnabled,
unreadCount,
@@ -98,6 +115,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' })
}
function goSupportChat() {
void router.push({ name: 'support' })
}
let intersectionObserver: IntersectionObserver | null = null
let scrollTarget: HTMLElement | Window | null = null
+31 -8
View File
@@ -39,10 +39,10 @@
<a-radio-button value="WEEKLY" @click="handleUpdate('WEEKLY')">هفته گذشته</a-radio-button>
<a-radio-button value="MONTHLY" @click="handleUpdate('MONTHLY')">ماه گذشته</a-radio-button>
</a-radio-group>
<div ref="chartContainerRef" id="chart-timeline" class="w-full" :class="chartInnerClass">
<div id="chart-timeline" class="w-full" :class="chartInnerClass">
<apexchart
v-if="canRenderChart"
:key="`${layout}-${variant}-${selectedMetal}-${chartSelection}-${seriesLength}`"
:key="chartRenderKey"
type="area"
width="100%"
:height="chartHeight"
@@ -66,7 +66,6 @@ import {
shouldUseKatodVisualChartTransform,
} from '@/utils/chartPriceAxis'
import { useSettingsStore } from '@/store/settingsStore'
import { useElementSize } from '@vueuse/core'
import { price } from '@/utils/price'
import type { ChartMetalType } from '../composables/usePriceChart'
@@ -117,12 +116,10 @@ const emit = defineEmits<{
const config = useConfigStore()
const settingsStore = useSettingsStore()
const { selectedMetalChartRatio } = usePriceChart()
const chartContainerRef = ref<HTMLElement | null>(null)
const chartMounted = ref(false)
const renderNonce = ref(0)
const selectedValue = ref(props.chartSelection)
const { width: chartWidth } = useElementSize(chartContainerRef)
const seriesLength = computed(() => props.priceChartSeries?.[0]?.data?.length ?? 0)
const amplifyKatodChart = computed(() =>
shouldAmplifyKatodMesChart({
@@ -175,20 +172,46 @@ const chartYBounds = computed(() => {
return computeKatodMesVisualYBounds(prices)
})
const canRenderChart = computed(
() => chartMounted.value && chartWidth.value > 0 && seriesLength.value > 0
/** Do not gate on measured width — useElementSize can stay 0 until a reflow (e.g. tab click). */
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(
() => props.chartSelection,
(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 () => {
await nextTick()
chartMounted.value = true
if (seriesLength.value > 0) {
scheduleChartRemount()
}
})
const chartOptions = computed(() => {
+40 -27
View File
@@ -81,34 +81,47 @@ export function useHomePage() {
const showClosedMessage = computed(() => !!marketClosedText.value)
const goldVal = computed(() => lastPrice.value?.price_last ?? 1)
const mainActions = computed<HomeActionItem[]>(() => [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
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
const mainActions = computed<HomeActionItem[]>(() => {
const actions: HomeActionItem[] = [
{
name: 'buySell',
icon: 'vuesax-linear:money-receive',
label: 'خرید و فروش',
handler: () => router.push({ name: 'buyProduct' }),
},
},
{
name: 'assets',
icon: 'vuesax-linear:cup',
label: 'دارایی ها',
handler: () => router.push({ name: 'fund' }),
},
])
{
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: '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() {
const code = userStore.userInfo?.referral_id
@@ -4,8 +4,10 @@ import { getProductsCatalog, getProductsCatalogRef } from '@/composables/useProd
import { getTradeBuyPrice, getTradeSellPrice } from '@/utils/price'
export type ChartMetalType = 'katod' | 'maftol' | 'shamsh'
export type ChartTimeline = 'DAILY' | 'WEEKLY' | 'MONTHLY'
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: [] },
])
const rawChartPoints = ref<ChartPoint[]>([])
const chartSelection = ref('DAILY')
const cachedPrices = ref<PricesMap | null>(null)
const chartSelection = ref<ChartTimeline>('DAILY')
const chartLoading = ref(false)
const selectedChartMetal = ref<ChartMetalType>('katod')
const catalogProducts = getProductsCatalogRef()
@@ -67,7 +70,7 @@ function normalizeChartTimestamp(timestamp: number) {
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') {
return null
}
@@ -76,11 +79,11 @@ function resolvePricesMap(response: unknown): Record<string, Array<{ timestamp:
const nested = body.data
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) {
return body.prices as Record<string, Array<{ timestamp: number; value: number }>>
return body.prices as PricesMap
}
return null
@@ -95,9 +98,21 @@ function buildChartPoints(items: Array<{ timestamp: number; value: number }>): C
function syncChartSeries() {
const ratio = selectedMetalChartRatio.value
priceChartSeries.value[0].data = rawChartPoints.value.map(
([timestamp, value]) => [timestamp, applyChartRatio(value, ratio)] as ChartPoint,
)
// New array so Vue invalidates parent computeds (nested .data mutation did not).
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], () => {
@@ -105,8 +120,8 @@ watch([selectedChartMetal, maftolChartRatio, shamshChartRatio], () => {
})
async function fetchPrices(force = false) {
if (!force && chartInitialized && rawChartPoints.value.length > 0) {
syncChartSeries()
if (!force && chartInitialized && cachedPrices.value) {
applyTimelineFromCache()
return
}
@@ -119,10 +134,9 @@ async function fetchPrices(force = false) {
chartLoading.value = true
const response = await getAllPrices()
const data = resolvePricesMap(response)
const selectedData = data?.[chartSelection.value] ?? []
rawChartPoints.value = buildChartPoints(selectedData)
syncChartSeries()
chartInitialized = true
cachedPrices.value = data
applyTimelineFromCache()
chartInitialized = Boolean(data)
} catch (error) {
console.error('Error fetching prices:', error)
} finally {
@@ -160,7 +174,11 @@ async function loadMetalChartOffsets(force = false) {
}
function updateChartData(timeline: string) {
chartSelection.value = timeline
chartSelection.value = timeline as ChartTimeline
if (cachedPrices.value) {
applyTimelineFromCache(chartSelection.value)
return
}
void fetchPrices(true)
}
@@ -197,6 +215,7 @@ export function loadHomeMaftolOffset(force = false) {
export function resetHomePriceChart() {
priceChartSeries.value = [{ name: 'قیمت', data: [] }]
rawChartPoints.value = []
cachedPrices.value = null
chartSelection.value = 'DAILY'
selectedChartMetal.value = 'katod'
chartInitialized = false
@@ -7,7 +7,11 @@ import DesktopHomeCard from '../components/DesktopHomeCard.vue'
const ctx = useHomePageContext()
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 selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
@@ -16,7 +16,11 @@ const props = withDefaults(
const ctx = useHomePageContext()
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 selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
@@ -18,7 +18,7 @@
@click="onInstall"
>
<BasicIcon icon="vuesax-linear:mobile" size="16" />
<span>{{ canNativeInstall ? 'نصب وب اپ' : 'آموزش نصب' }}</span>
<span>{{ canNativeInstall ? 'نصب وب اپلیکیشن' : 'آموزش نصب' }}</span>
</button>
<button
v-if="notificationEnabled"
@@ -35,6 +35,21 @@
</span>
<span>اعلان</span>
</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">
<BasicIcon icon="vuesax-linear:refresh-2" size="16" />
</button>
@@ -52,7 +67,7 @@
</template>
<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 { useUserStore } from '@/store/userStore'
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
@@ -63,6 +78,7 @@ import {
hasNativeInstallPrompt,
initPwaInstallPromptListener,
} from '@/modules/pwa/pwaInstall'
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
import router from '@/router'
withDefaults(
@@ -78,6 +94,8 @@ defineEmits<{
const userStore = useUserStore()
const canNativeInstall = ref(false)
const chatEnabled = computed(() => isUserSupportChatEnabled())
const chatUnreadCount = computed(() => unreadSupportCount.value)
const {
enabled: notificationEnabled,
unreadCount,
@@ -102,6 +120,10 @@ async function goNotifications() {
void router.push({ name: 'appNotifications' })
}
function goSupportChat() {
void router.push({ name: 'support' })
}
function reloadApp() {
window.location.reload()
}
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import DesktopHomeCard from './DesktopHomeCard.vue'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
@@ -10,10 +13,28 @@ const props = withDefaults(
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) {
router.push(action.route)
}
@@ -23,7 +44,7 @@ function handleClick(action: HomeQuickActionItem) {
<DesktopHomeCard title="دسترسی سریع">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<HomeQuickActionBox
v-for="action in props.actions"
v-for="action in resolvedActions"
:key="action.id"
:action="action"
class="home-desktop-quick-action"
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import DesktopHomeSection from './DesktopHomeSection.vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { boursecuQuickActions } from '../../layouts/configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../../layouts/types'
@@ -11,11 +14,29 @@ const props = withDefaults(
title?: string
}>(),
{
actions: () => boursecuQuickActions,
actions: undefined,
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) {
router.push(action.route)
}
@@ -25,7 +46,7 @@ function navigate(action: HomeQuickActionItem) {
<DesktopHomeSection :title="title">
<nav class="dhome-actions" aria-label="میانبرهای صفحه اصلی">
<button
v-for="action in props.actions"
v-for="action in resolvedActions"
:key="action.id"
type="button"
class="dhome-action"
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import router from '@/router'
import { useSettingsStore } from '@/store/settingsStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import HomeQuickActionBox from '../../components/HomeQuickActionBox.vue'
import { boursecuQuickActions } from '../configs/boursecuQuickActions'
import type { HomeQuickActionItem } from '../types'
@@ -9,10 +12,28 @@ const props = withDefaults(
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) {
router.push(action.route)
}
@@ -21,7 +42,7 @@ function handleClick(action: HomeQuickActionItem) {
<template>
<div class="grid grid-cols-3 gap-3 mt-4">
<HomeQuickActionBox
v-for="action in props.actions"
v-for="action in resolvedActions"
:key="action.id"
:action="action"
@click="handleClick(action)"
@@ -5,7 +5,12 @@ import { HOME_PAGE_CONTEXT_KEY } from '../../composables/useHomePage'
const ctx = inject(HOME_PAGE_CONTEXT_KEY)!
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 selectedChartMetal = computed(() => toValue(ctx.selectedChartMetal))
</script>
+280
View File
@@ -0,0 +1,280 @@
<template>
<div class="piggy-page">
<div class="piggy-page__inner">
<header class="piggy-bar">
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-bar__title">قلک‌ها</h1>
<button type="button" class="piggy-bar__btn piggy-bar__btn--primary" aria-label="قلک جدید" @click="goAdd">
<BasicIcon icon="vuesax-linear:add" size="20" />
</button>
</header>
<a-spin :spinning="busy">
<section class="piggy-summary">
<div class="piggy-summary__card">
<div class="piggy-summary__row">
<div class="min-w-0">
<p class="piggy-summary__label">جمع موجودی داخل قلک‌ها</p>
<p class="piggy-summary__value" dir="ltr">
{{ formatPiggyAmount(totalGold) }}
<span class="piggy-summary__unit">گرم</span>
</p>
</div>
<div class="piggy-summary__badge">
<BasicIcon icon="vuesax-linear:safe-home" size="22" />
</div>
</div>
<div class="piggy-summary__meta">
<span>{{ rows.length }} قلک</span>
<span class="piggy-summary__dot" />
<span>{{ groups.length }} محصول</span>
</div>
</div>
<BasicButton class="!w-full mt-3" type="primary" size="middle" block @click="goAdd">
<div class="flex items-center justify-center gap-2">
<BasicIcon icon="vuesax-linear:add-circle" size="18" />
<span>{{ rows.length ? 'افزودن قلک' : 'ساخت اولین قلک' }}</span>
</div>
</BasicButton>
</section>
<section class="piggy-body">
<div v-if="!rows.length" class="piggy-empty">
<div class="piggy-empty__icon">
<BasicIcon icon="vuesax-linear:safe-home" size="36" />
</div>
<p class="piggy-empty__title">قلکی ندارید</p>
<p class="piggy-empty__desc">
برای جدا کردن بخشی از موجودی هر محصول (مثلاً آبشده یا سکه) یک قلک بسازید.
</p>
</div>
<div v-else class="piggy-groups">
<div v-for="group in groups" :key="group.productId" class="piggy-group">
<div class="piggy-group__head">
<div class="min-w-0">
<p class="piggy-group__name">{{ group.productTitle }}</p>
<p class="piggy-group__count">{{ group.funds.length }} قلک</p>
</div>
<p class="piggy-group__total" dir="ltr">
{{ formatPiggyAmount(group.totalGold) }}
<span>گرم</span>
</p>
</div>
<div class="piggy-group__list">
<button
v-for="item in group.funds"
:key="item.id"
type="button"
class="piggy-row"
@click="openInfo(item.id!)"
>
<div class="piggy-row__icon">
<BasicIcon :icon="piggyIconName(item.title)" size="20" />
</div>
<div class="piggy-row__main">
<div class="piggy-row__title">{{ item.title || 'بدون عنوان' }}</div>
<div class="piggy-row__sub">کد {{ item.id }}</div>
</div>
<div class="piggy-row__amount" dir="ltr">
<strong>{{ formatPiggyAmount(fundWalletGold(item)) }}</strong>
<span>گرم</span>
</div>
<BasicIcon class="piggy-row__chevron" icon="vuesax-linear:arrow-left-1" size="16" />
</button>
</div>
</div>
</div>
</section>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import {
formatPiggyAmount,
fundWalletGold,
groupFundsByProduct,
piggyIconName,
} from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const rows = computed(() => (fundStore.myPiggyFunds?.rows ?? []) as Fund[])
const groups = computed(() => groupFundsByProduct(rows.value))
const totalGold = computed(() => rows.value.reduce((sum, item) => sum + fundWalletGold(item), 0))
function goBack() {
router.back()
}
function goAdd() {
router.push({ name: 'piggy_add' })
}
function openInfo(id: number) {
router.push({ name: 'piggy_info', params: { id: String(id) } })
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await fundStore.getMyPiggyFunds()
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-page {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-page__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-bar {
@apply sticky top-0 z-20 flex items-center justify-between bg-white px-3 py-3;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
}
.piggy-bar__title {
@apply m-0 truncate px-2 text-base font-bold text-gray-900;
}
.piggy-bar__btn {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-gray-100 text-gray-800;
}
.piggy-bar__btn--primary {
@apply bg-primary text-white;
}
.piggy-summary {
@apply px-3 pt-4 sm:px-4;
}
.piggy-summary__card {
@apply rounded-2xl bg-primary p-4 text-white;
}
.piggy-summary__row {
@apply flex items-start justify-between gap-3;
}
.piggy-summary__label {
@apply m-0 text-sm text-white/80;
}
.piggy-summary__value {
@apply m-0 mt-1 flex flex-wrap items-baseline gap-2 text-2xl font-bold sm:text-3xl;
}
.piggy-summary__unit {
@apply text-sm font-medium text-white/75;
}
.piggy-summary__badge {
@apply flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/15;
}
.piggy-summary__meta {
@apply mt-4 flex flex-wrap items-center gap-2 text-xs text-white/75;
}
.piggy-summary__dot {
@apply h-1 w-1 rounded-full bg-white/50;
}
.piggy-body {
@apply px-3 pt-4 sm:px-4;
}
.piggy-groups {
@apply flex flex-col gap-4;
}
.piggy-group {
@apply overflow-hidden rounded-2xl bg-white;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-group__head {
@apply flex items-center justify-between gap-3 border-0 border-b border-solid border-gray-100 px-3 py-3 sm:px-4;
}
.piggy-group__name {
@apply m-0 truncate text-sm font-bold text-gray-900;
}
.piggy-group__count {
@apply m-0 mt-0.5 text-[11px] text-gray-500;
}
.piggy-group__total {
@apply m-0 flex shrink-0 items-baseline gap-1 text-sm font-bold text-primary;
}
.piggy-group__total span {
@apply text-[11px] font-medium text-gray-500;
}
.piggy-group__list {
@apply flex flex-col;
}
.piggy-row {
@apply flex w-full min-w-0 items-center gap-2 border-0 border-b border-solid border-gray-50 bg-transparent px-2.5 py-3 text-right last:border-b-0 sm:gap-3 sm:px-3;
}
.piggy-row__icon {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-50 text-primary sm:h-10 sm:w-10;
}
.piggy-row__main {
@apply min-w-0 flex-1;
}
.piggy-row__title {
@apply truncate text-sm font-bold text-gray-900;
}
.piggy-row__sub {
@apply mt-0.5 text-[11px] text-gray-400;
}
.piggy-row__amount {
@apply flex shrink-0 flex-col items-end leading-tight;
}
.piggy-row__amount strong {
@apply text-sm font-bold text-gray-900;
}
.piggy-row__amount span {
@apply text-[11px] text-gray-500;
}
.piggy-row__chevron {
@apply shrink-0 text-gray-300;
}
.piggy-empty {
@apply rounded-2xl bg-white px-5 py-10 text-center;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-empty__icon {
@apply mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-gray-50 text-primary;
}
.piggy-empty__title {
@apply m-0 text-base font-bold text-gray-900;
}
.piggy-empty__desc {
@apply m-0 mt-2 text-sm leading-6 text-gray-500;
}
@media (min-width: 768px) {
.piggy-page {
padding: 16px 16px 2rem;
}
.piggy-page__inner {
max-width: 28rem;
}
.piggy-bar {
border-radius: 12px;
}
}
</style>
+317
View File
@@ -0,0 +1,317 @@
<template>
<div class="piggy-info">
<div class="piggy-info__inner">
<header class="piggy-info__hero">
<div class="piggy-info__top">
<button type="button" class="piggy-info__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-info__title">{{ fund?.title || 'قلک' }}</h1>
<button type="button" class="piggy-info__btn" aria-label="حذف" @click="onDelete">
<BasicIcon icon="vuesax-linear:trash" size="18" />
</button>
</div>
<a-spin :spinning="busy">
<div class="piggy-info__balance-block">
<p class="piggy-info__product">{{ fund?.product?.title || '—' }}</p>
<p class="piggy-info__label">موجودی قلک</p>
<p class="piggy-info__value" dir="ltr">
{{ formatPiggyAmount(potGold) }}
<span>گرم</span>
</p>
<p class="piggy-info__free">
آزاد قابل واریز:
<strong dir="ltr">{{ formatPiggyAmount(freeGold) }}</strong>
گرم
</p>
</div>
</a-spin>
</header>
<div class="piggy-info__sheet">
<div class="piggy-info__actions">
<BasicButton class="piggy-info__action" size="middle" @click="openTx(false)">
<div class="piggy-info__action-inner">
<BasicIcon icon="vuesax-linear:export" size="18" />
<span>برداشت به آزاد</span>
</div>
</BasicButton>
<BasicButton class="piggy-info__action" type="primary" size="middle" @click="openTx(true)">
<div class="piggy-info__action-inner">
<BasicIcon icon="vuesax-linear:import" size="18" />
<span>واریز از آزاد</span>
</div>
</BasicButton>
</div>
<section class="piggy-info__tx">
<h2 class="piggy-info__tx-title">تراکنش‌های این قلک</h2>
<div v-if="!transactions.length" class="piggy-info__tx-empty">
<BasicIcon icon="vuesax-linear:receipt-2" size="28" />
<p class="m-0 mt-2 text-sm text-gray-500">هنوز جابه‌جایی ثبت نشده</p>
</div>
<div v-else class="piggy-info__tx-list">
<div v-for="tx in transactions" :key="tx.id" class="piggy-info__tx-row">
<div class="piggy-info__tx-dot" />
<div class="min-w-0 flex-1 text-right">
<div class="truncate text-sm font-bold text-gray-900">
{{ tx.type_str || tx.type || 'تراکنش' }}
</div>
<div class="mt-0.5 text-[11px] text-gray-400">{{ tx.created_at || '' }}</div>
</div>
<div class="shrink-0 font-bold text-primary" dir="ltr">
{{ formatPiggyAmount(Number(tx.gold ?? 0)) }}
</div>
</div>
</div>
</section>
</div>
</div>
<BasicModal v-model:visible="txVisible" :title="isDeposit ? 'واریز به قلک' : 'برداشت از قلک'">
<div class="flex flex-col gap-3 p-1">
<p class="m-0 text-xs leading-5 text-gray-500">
{{
isDeposit
? `از موجودی آزاد «${fund?.product?.title || 'محصول'}» — حداکثر ${formatPiggyAmount(freeGold)} گرم`
: `برگرداندن به آزاد — حداکثر ${formatPiggyAmount(potGold)} گرم`
}}
</p>
<BasicInput v-model:value="txValue" type="number" placeholder="مقدار به گرم" />
<BasicButton class="!w-full" type="primary" block :disabled="busy" @click="submitTx">
تأیید
</BasicButton>
</div>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import {
deleteFund,
fundDeposit,
fundWithdraw,
getFundById,
getFundTransactionTable,
} from '@/api/fund'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { message, Modal } from 'ant-design-vue'
import type { Fund } from '@/models/fund'
import type { Transaction } from '@/models/transaction'
import { formatPiggyAmount } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const { assetsSummary, loadWalletAssets } = useWalletAssets()
const busy = ref(false)
const fund = ref<Fund>()
const transactions = ref<Transaction[]>([])
const txVisible = ref(false)
const isDeposit = ref(true)
const txValue = ref<string | number | undefined>()
const fundId = computed(() => parseInt(String(router.currentRoute.value.params.id), 10))
const potGold = computed(() => {
const wallets = Array.isArray(fund.value?.wallet)
? fund.value!.wallet
: fund.value?.wallet
? [fund.value.wallet]
: []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
})
const freeGold = computed(() => {
const productId = Number(fund.value?.product_id ?? 0)
if (!productId) return 0
const cards = assetsSummary.value?.inventory_cards ?? []
const card = cards.find((c: any) => Number(c.product_id ?? c.id) === productId)
if (card && card.quantity != null) return Number(card.quantity)
return Number(assetsSummary.value?.metal_totals?.[0]?.weight ?? 0)
})
function goBack() {
router.back()
}
function openTx(deposit: boolean) {
isDeposit.value = deposit
txValue.value = undefined
txVisible.value = true
}
async function submitTx() {
const value = Number(txValue.value)
if (!Number.isFinite(value) || value <= 0) {
message.error('مقدار نامعتبر است')
return
}
try {
busy.value = true
const payload = { fund_id: fundId.value, value }
if (isDeposit.value) await fundDeposit(payload)
else await fundWithdraw(payload)
message.success('انجام شد')
txVisible.value = false
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
function onDelete() {
if (potGold.value > 0) {
message.error('ابتدا موجودی قلک را صفر کنید')
return
}
Modal.confirm({
title: 'حذف قلک',
content: 'آیا از حذف این قلک مطمئن هستید؟',
okText: 'حذف',
cancelText: 'انصراف',
async onOk() {
await deleteFund(fundId.value)
await fundStore.getMyPiggyFunds()
message.success('حذف شد')
router.replace({ name: 'piggy' })
},
})
}
async function reload() {
fund.value = await getFundById(fundId.value)
const walletId = Array.isArray(fund.value?.wallet)
? fund.value?.wallet?.[0]?.id
: (fund.value?.wallet as { id?: number } | undefined)?.id
if (walletId) {
const table = await getFundTransactionTable({ wallet_id: walletId } as any)
transactions.value = (table?.rows ?? []) as Transaction[]
} else {
transactions.value = []
}
await loadWalletAssets()
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
await reload()
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-info {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-info__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-info__hero {
@apply bg-primary px-3 pb-8 pt-3 text-white;
}
.piggy-info__top {
@apply flex items-center justify-between gap-2;
}
.piggy-info__btn {
@apply flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-none bg-white/15 text-white;
}
.piggy-info__title {
@apply m-0 min-w-0 flex-1 truncate text-center text-base font-bold text-white;
}
.piggy-info__balance-block {
@apply mt-6 px-2 text-center;
}
.piggy-info__product {
@apply m-0 inline-flex max-w-full truncate rounded-full bg-white/15 px-3 py-1 text-xs text-white/90;
}
.piggy-info__label {
@apply m-0 mt-4 text-sm text-white/75;
}
.piggy-info__value {
@apply m-0 mt-1 flex flex-wrap items-baseline justify-center gap-2 text-3xl font-bold sm:text-4xl;
}
.piggy-info__value span {
@apply text-sm font-medium text-white/75;
}
.piggy-info__free {
@apply m-0 mt-3 text-xs leading-5 text-white/70;
}
.piggy-info__free strong {
@apply text-white;
}
.piggy-info__sheet {
@apply relative z-10 -mt-4 rounded-t-3xl bg-gray-50 px-3 pt-5 sm:px-4;
}
.piggy-info__actions {
@apply flex flex-col gap-2 sm:flex-row;
}
.piggy-info__action {
@apply !w-full sm:!flex-1;
}
.piggy-info__action-inner {
@apply flex items-center justify-center gap-2 text-sm;
}
.piggy-info__tx {
@apply mt-5;
}
.piggy-info__tx-title {
@apply m-0 mb-3 text-base font-bold text-gray-900;
}
.piggy-info__tx-empty {
@apply flex flex-col items-center rounded-2xl bg-white px-4 py-10 text-gray-400;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-info__tx-list {
@apply flex flex-col gap-2;
}
.piggy-info__tx-row {
@apply flex min-w-0 items-center gap-3 rounded-2xl bg-white p-3;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-info__tx-dot {
@apply h-2.5 w-2.5 shrink-0 rounded-full bg-primary;
}
@media (min-width: 768px) {
.piggy-info {
padding: 16px 16px 2rem;
}
.piggy-info__inner {
max-width: 26rem;
overflow: hidden;
border-radius: 16px;
}
.piggy-info__hero {
border-radius: 16px 16px 0 0;
}
.piggy-info__actions {
flex-direction: row;
}
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<template>
<div class="piggy-create">
<div class="piggy-create__inner">
<header class="piggy-bar">
<button type="button" class="piggy-bar__btn" aria-label="بازگشت" @click="goBack">
<BasicIcon icon="vuesax-linear:arrow-right-1" size="22" />
</button>
<h1 class="piggy-bar__title">{{ editId ? 'ویرایش قلک' : 'ساخت قلک' }}</h1>
<span class="w-9 shrink-0" />
</header>
<a-spin :spinning="busy">
<div class="piggy-create__body">
<section class="piggy-create__step">
<div class="piggy-create__step-head">
<span class="piggy-create__step-num">۱</span>
<div class="min-w-0">
<h2 class="piggy-create__step-title">محصول</h2>
<p class="piggy-create__step-desc">این قلک فقط برای همین محصول پر می‌شود</p>
</div>
</div>
<div v-if="editId" class="piggy-create__locked">
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="truncate">{{ lockedProductTitle || 'محصول ثابت' }}</span>
</div>
<div v-else class="piggy-create__products">
<button
v-for="p in products"
:key="p.id"
type="button"
class="piggy-create__product"
:class="{ 'piggy-create__product--on': productId === p.id }"
@click="productId = p.id"
>
<BasicIcon icon="vuesax-linear:box-1" size="18" />
<span class="min-w-0 truncate">{{ p.title || `محصول ${p.id}` }}</span>
</button>
</div>
</section>
<section class="piggy-create__step">
<div class="piggy-create__step-head">
<span class="piggy-create__step-num">۲</span>
<div class="min-w-0">
<h2 class="piggy-create__step-title">نام قلک</h2>
<p class="piggy-create__step-desc">مثلاً پس‌انداز، هدیه یا ازدواج</p>
</div>
</div>
<div class="piggy-create__title-row">
<div class="piggy-create__title-icon">
<BasicIcon :icon="piggyIconName(title)" size="22" />
</div>
<BasicInput v-model:value="title" class="!min-w-0 !flex-1" placeholder="عنوان قلک" maxlength="25" />
</div>
<div class="piggy-create__presets">
<button
v-for="preset in presets"
:key="preset"
type="button"
class="piggy-create__chip"
:class="{ 'piggy-create__chip--on': title === preset }"
@click="title = preset"
>
{{ preset }}
</button>
</div>
</section>
<BasicButton class="!w-full" type="primary" size="middle" block :disabled="busy" @click="submit">
{{ editId ? 'ذخیره' : 'ساخت قلک' }}
</BasicButton>
</div>
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import router from '@/router'
import { createFund, editFund, getFundById } from '@/api/fund'
import { getProducts } from '@/api/products'
import { useFundUserStore } from '@/store/fundStore'
import { useSettingsStore } from '@/store/settingsStore'
import { message } from 'ant-design-vue'
import type { Product } from '@/models/products'
import { PIGGY_PRESETS, piggyIconName } from './piggyIcons'
const fundStore = useFundUserStore()
const settingsStore = useSettingsStore()
const busy = ref(false)
const title = ref('پس انداز')
const productId = ref<number | undefined>()
const products = ref<Product[]>([])
const lockedProductTitle = ref('')
const presets = [...PIGGY_PRESETS]
const editId = computed(() => {
const raw = router.currentRoute.value.query.id
const n = parseInt(String(raw ?? ''), 10)
return Number.isFinite(n) && n > 0 ? n : 0
})
function goBack() {
router.back()
}
async function submit() {
if (!title.value.trim()) {
message.error('عنوان قلک را وارد کنید')
return
}
if (!editId.value && !productId.value) {
message.error('محصول قلک را انتخاب کنید')
return
}
try {
busy.value = true
if (editId.value) {
await editFund(editId.value, { title: title.value.trim() })
} else {
await createFund({ title: title.value.trim(), product_id: productId.value })
}
await fundStore.getMyPiggyFunds()
message.success(editId.value ? 'قلک ویرایش شد' : 'قلک ساخته شد')
router.replace({ name: 'piggy' })
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
}
onMounted(async () => {
await settingsStore.fetchSettings()
if (!settingsStore.isSettingTruthy('enable_product_funds')) {
message.warning('قابلیت قلک غیرفعال است')
router.replace({ name: 'user' })
return
}
try {
busy.value = true
const list = await getProducts()
products.value = Array.isArray(list) ? list : ((list as any)?.rows ?? (list as any)?.data ?? [])
if (editId.value) {
const fund = await getFundById(editId.value)
title.value = fund?.title ?? ''
productId.value = fund?.product_id
lockedProductTitle.value = fund?.product?.title || ''
} else if (products.value.length === 1) {
productId.value = products.value[0]?.id
}
} catch (e) {
console.log(e)
} finally {
busy.value = false
}
})
</script>
<style scoped>
.piggy-create {
box-sizing: border-box;
min-height: 100%;
width: 100%;
background: #f9fafb;
padding: 0 12px calc(var(--app-bottom-chrome-height, 108px) + 1rem);
}
.piggy-create__inner {
box-sizing: border-box;
width: 100%;
max-width: 26rem;
margin-inline: auto;
}
.piggy-bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 12px;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.06);
}
.piggy-bar__title {
margin: 0;
padding: 0 8px;
font-size: 1rem;
font-weight: 700;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.piggy-bar__btn {
display: flex;
width: 36px;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: none;
border-radius: 9999px;
background: #f3f4f6;
color: #1f2937;
}
.piggy-create__body {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
}
.piggy-create__step {
border-radius: 16px;
background: #fff;
padding: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
}
.piggy-create__step-head {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.piggy-create__step-num {
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: var(--App-primary, #0a1b37);
color: #fff;
font-size: 0.875rem;
font-weight: 700;
}
.piggy-create__step-title {
margin: 0;
font-size: 0.875rem;
font-weight: 700;
color: #111827;
}
.piggy-create__step-desc {
margin: 4px 0 0;
font-size: 0.75rem;
color: #6b7280;
}
.piggy-create__products {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.piggy-create__product {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 8px;
border: none;
border-radius: 12px;
background: #f9fafb;
padding: 10px 14px;
text-align: right;
font-size: 0.875rem;
color: #374151;
}
.piggy-create__product--on {
background: var(--App-primary, #0a1b37);
color: #fff;
}
.piggy-create__locked {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
border-radius: 12px;
background: #f9fafb;
padding: 12px;
font-size: 0.875rem;
font-weight: 700;
color: #1f2937;
}
.piggy-create__title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.piggy-create__title-icon {
display: flex;
width: 44px;
height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #f9fafb;
color: var(--App-primary, #0a1b37);
}
.piggy-create__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.piggy-create__chip {
border: none;
border-radius: 9999px;
background: #f3f4f6;
padding: 8px 12px;
font-size: 0.75rem;
color: #4b5563;
}
.piggy-create__chip--on {
background: var(--App-primary, #0a1b37);
color: #fff;
}
@media (min-width: 768px) {
.piggy-create {
padding: 16px 16px 2rem;
}
.piggy-create__inner {
max-width: 26rem;
}
.piggy-bar {
border-radius: 12px;
}
}
</style>
+73
View File
@@ -0,0 +1,73 @@
import type { Fund } from '@/models/fund'
export const PIGGY_PRESETS = [
'پس انداز',
'هدیه',
'ازدواج',
'تعمیرات',
'گردش',
'خرید لوازم منزل',
] as const
/** آیکون vuesax برای عنوان قلک — بدون تصویر سه‌بعدی گیج‌کننده */
export function piggyIconName(title?: string | null): string {
switch ((title ?? '').trim()) {
case 'خرید لوازم منزل':
return 'vuesax-linear:house-2'
case 'هدیه':
return 'vuesax-linear:gift'
case 'گردش':
return 'vuesax-linear:airplane'
case 'ازدواج':
return 'vuesax-linear:heart'
case 'تعمیرات':
return 'vuesax-linear:setting-2'
case 'پس انداز':
default:
return 'vuesax-linear:safe-home'
}
}
export function fundWalletGold(item?: Fund | null): number {
if (!item) return 0
const wallets = Array.isArray(item.wallet) ? item.wallet : item.wallet ? [item.wallet] : []
return wallets.reduce((acc, w) => acc + Number((w as { gold?: number })?.gold ?? 0), 0)
}
export function fundProductLabel(item?: Fund | null): string {
if (!item) return '—'
return item.product?.title || (item as { product_title?: string }).product_title || '—'
}
export function fundProductId(item?: Fund | null): number {
return Number(item?.product_id ?? item?.product?.id ?? 0)
}
export function formatPiggyAmount(value: number): string {
const n = Number(value) || 0
if (Math.abs(n) < 0.0005) return '۰'
return n.toLocaleString('fa-IR', { maximumFractionDigits: 3 })
}
export type PiggyProductGroup = {
productId: number
productTitle: string
totalGold: number
funds: Fund[]
}
export function groupFundsByProduct(funds: Fund[]): PiggyProductGroup[] {
const map = new Map<number, PiggyProductGroup>()
for (const fund of funds) {
const productId = fundProductId(fund) || -1
const productTitle = fundProductLabel(fund)
let group = map.get(productId)
if (!group) {
group = { productId, productTitle, totalGold: 0, funds: [] }
map.set(productId, group)
}
group.funds.push(fund)
group.totalGold += fundWalletGold(fund)
}
return [...map.values()].sort((a, b) => a.productTitle.localeCompare(b.productTitle, 'fa'))
}
@@ -223,7 +223,7 @@
<div>قیمت:</div>
<div class="flex items-center">
<div class="font-bold">
{{ usePriceWithoutType(props?.ProductItem?.price) }}
{{ usePriceWithoutType(isBuy ? buyTotalPrice : sellTotalPrice) }}
</div>
<div class="text-xs mr-1">ریال</div>
</div>
@@ -2,11 +2,11 @@
<a-form
ref="formRef"
:model="userFormState"
:class="showSubmit ? 'user-profile-form-mobile' : '!mt-5'"
:class="formClass"
name="basic"
@finish="ok"
>
<div :class="showSubmit ? 'user-profile-form-mobile__scroll' : undefined">
<div :class="scrollClass">
<a-form-item :rules="[isValidPersian()]" label="نام" name="fname">
<BasicInput v-model:value="userFormState.fname" />
</a-form-item>
@@ -28,10 +28,10 @@
<BasicInput v-model:value="userFormState.city" />
</a-form-item>
</div>
<div v-if="showSubmit" class="user-profile-form-mobile__footer">
<div v-if="showSubmit" :class="footerClass">
<BasicButton
:disabled="loading"
class="w-full py-2"
class="user-profile-form__submit"
html-type="submit"
type="primary"
>
@@ -43,16 +43,34 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicInput from '@/components/BasicPacks/BasicInput.vue'
import BaseDatePicker from '@/components/BasicPacks/BaseDatePicker'
import { useUserPageContext } from '../composables/useUserPage'
withDefaults(
const props = withDefaults(
defineProps<{
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 {
@@ -90,4 +108,17 @@ const {
background: #fff;
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>
+57 -1
View File
@@ -1,5 +1,5 @@
import { computed, inject, onMounted, provide, ref, watch, type InjectionKey } from 'vue'
import { loading } from '@/utils'
import { loading, removeToken } from '@/utils'
import {
guideTypePassword,
isValidPersian,
@@ -12,12 +12,21 @@ import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { updateMe, updatePass } from '@/api/me'
import { useUserStore } from '@/store/userStore'
import { useWalletStore } from '@/store/walletStore'
import { useWalletAssets } from '@/composables/useWalletAssets'
import { getUserAvatarUrl } from '@/utils/file'
import { useConfigStore } from '@/store/configStore'
import { getBoursecuActionIconSrc } from '@/utils/config'
import { getReferralShareText, getReferralShareTitle, shareReferralText } from '@/utils/referralShare'
import { normalizeAuthFormFields } from '@/utils/persianDigits'
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 {
id: string
@@ -26,6 +35,7 @@ export interface UserMenuAction {
iconSrc?: string
onClick: () => void
visible: boolean
badge?: number
}
export interface UserPageContext {
@@ -39,6 +49,7 @@ export interface UserPageContext {
isLegal: ReturnType<typeof computed<{ title: string; color: string; icon: string }>>
shareButtonLabel: ReturnType<typeof computed<string>>
menuActions: ReturnType<typeof computed<UserMenuAction[]>>
showLogoutConfirm: ReturnType<typeof ref<boolean>>
loading: typeof loading
guideTypePassword: typeof guideTypePassword
isValidPersian: typeof isValidPersian
@@ -48,6 +59,8 @@ export interface UserPageContext {
shareCode: () => void
ok: () => void
submitPassForm: () => void
requestLogout: () => void
logout: () => Promise<void>
}
export const USER_PAGE_CONTEXT_KEY: InjectionKey<UserPageContext> = Symbol('userPageContext')
@@ -65,11 +78,15 @@ export function useUserPage() {
const formRef = ref<FormInstance>()
const formPassRef = ref<FormInstance>()
const userStore = useUserStore()
const walletStore = useWalletStore()
const { clearWalletAssets } = useWalletAssets()
const settingsStore = useSettingsStore()
const nameProject = useConfigStore().config.name_fa
const editPassVisible = ref(false)
const editPasswordVisible = ref(false)
const showLogoutConfirm = ref(false)
const localAvatarUrl = ref<string | null>(null)
const { openSupportMessenger } = useSupportMessenger()
const profileAvatarUrl = computed(
() => localAvatarUrl.value || getUserAvatarUrl(userStore.userInfo),
@@ -121,6 +138,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: 'درباره ما',
@@ -128,6 +152,16 @@ export function useUserPage() {
onClick: () => router.push({ name: 'about' }),
visible: true,
},
{
id: 'support',
label: 'پشتیبانی',
icon: 'vuesax-linear:messages-2',
onClick: () => {
void openSupportMessenger()
},
visible: isUserSupportChatEnabled(),
badge: unreadSupportCount.value > 0 ? unreadSupportCount.value : undefined,
},
{
id: 'physicalReceive',
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(() => {
setForm()
if (userStore.userInfo?.is_legal === 0) {
@@ -251,6 +304,7 @@ export function useUserPage() {
isLegal,
shareButtonLabel,
menuActions,
showLogoutConfirm,
loading,
guideTypePassword,
isValidPersian,
@@ -260,6 +314,8 @@ export function useUserPage() {
shareCode,
ok,
submitPassForm,
requestLogout,
logout,
}
provide(USER_PAGE_CONTEXT_KEY, context)
@@ -50,15 +50,17 @@
</button>
</div>
<button
type="button"
class="duser-profile__btn duser-profile__btn--primary"
@click="editPassVisible = true"
>
<BasicIcon icon="vuesax-linear:edit-2" size="16" />
<span>ویرایش اطلاعات کاربری</span>
</button>
<InstallButton class="duser-profile__install" />
<div class="duser-profile__actions">
<button
type="button"
class="duser-profile__btn duser-profile__btn--primary"
@click="editPassVisible = true"
>
<BasicIcon icon="vuesax-linear:edit-2" size="16" />
<span>ویرایش اطلاعات کاربری</span>
</button>
<InstallButton class="duser-profile__install" />
</div>
</section>
<section class="duser-surface duser-actions">
@@ -79,6 +81,12 @@
class="duser-actions__icon-image"
/>
<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 class="duser-actions__label">{{ action.label }}</span>
<BasicIcon
@@ -87,6 +95,21 @@
size="16"
/>
</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>
</section>
</div>
@@ -107,6 +130,7 @@ const {
menuActions,
copyCode,
shareCode,
requestLogout,
} = useUserPageContext()
</script>
@@ -4,12 +4,16 @@
min-height: 100%;
background: #f6f7f9;
color: #111827;
padding-bottom: 88px;
box-sizing: border-box;
}
.duser__container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 28px 36px 40px;
box-sizing: border-box;
}
.duser-topbar {
@@ -33,6 +37,7 @@
font-family: inherit;
cursor: pointer;
padding: 8px 0;
flex-shrink: 0;
&:hover {
color: #111827;
@@ -63,7 +68,7 @@
.duser-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 320px);
gap: 20px;
align-items: start;
}
@@ -73,9 +78,14 @@
border-radius: 14px;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.duser-profile {
display: flex;
flex-direction: column;
min-width: 0;
&__header {
display: flex;
align-items: center;
@@ -83,6 +93,7 @@
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #f3f4f6;
min-width: 0;
}
&__avatar {
@@ -103,6 +114,7 @@
letter-spacing: -0.02em;
color: #111827;
margin: 0 0 4px;
word-break: break-word;
}
&__phone {
@@ -111,6 +123,7 @@
margin: 0;
direction: ltr;
text-align: right;
word-break: break-word;
}
&__fields {
@@ -122,12 +135,13 @@
&__field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 12px 14px;
border-radius: 10px;
background: #f9fafb;
min-width: 0;
}
&__label {
@@ -137,16 +151,18 @@
}
&__value {
width: 100%;
font-size: 14px;
font-weight: 600;
color: #111827;
direction: ltr;
text-align: left;
text-align: right;
word-break: break-word;
}
&__referral-actions {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
@@ -156,6 +172,8 @@
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
width: 100%;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid #e5e7eb;
@@ -167,17 +185,27 @@
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
background: #f9fafb;
border-color: #d1d5db;
}
&--primary {
width: 100%;
background: @App-primary-color;
border-color: @App-primary-color;
color: #fff;
span {
white-space: normal;
}
&:hover {
opacity: 0.92;
color: #fff;
@@ -185,13 +213,25 @@
}
}
&__actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: auto;
}
&__install {
width: 100%;
margin-top: 10px;
&.mt-2 {
margin-top: 0 !important;
}
}
}
.duser-actions {
min-width: 0;
&__title {
font-size: 13px;
font-weight: 600;
@@ -223,6 +263,16 @@
background: #f9fafb;
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 {
@@ -236,6 +286,29 @@
color: #374151;
flex-shrink: 0;
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 {
@@ -247,9 +320,16 @@
&__label {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #111827;
line-height: 1.35;
word-break: break-word;
&--logout {
color: #ef4444;
}
}
&__chevron {
@@ -258,8 +338,38 @@
}
}
@media (max-width: 860px) {
@media (max-width: 1080px) {
.duser-grid {
grid-template-columns: 1fr;
}
.duser-profile__referral-actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.duser__container {
padding: 20px 16px 32px;
}
.duser-topbar {
flex-wrap: wrap;
margin-bottom: 20px;
padding-bottom: 16px;
&__title {
font-size: 22px;
}
}
.duser-surface {
padding: 18px 16px;
}
}
@media (min-width: 1081px) and (max-width: 1240px) {
.duser-grid {
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
}
}
+66 -14
View File
@@ -22,18 +22,17 @@
v-else
v-model:visible="editPassVisible"
title="ویرایش اطلاعات"
width="520px"
width="440px"
destroy-on-close
custom-footer
wrap-class-name="user-edit-modal"
:loading="loading"
@ok="ok"
>
<div class="flex justify-end mb-4">
<div class="flex flex-col items-center">
<BasicIcon :icon="isLegal.icon" size="30" :class="isLegal.color" />
<p class="text-[10px] mt-1" :class="isLegal.color">{{ isLegal.title }}</p>
</div>
<div class="user-edit-modal__status" :class="isLegal.color">
<BasicIcon :icon="isLegal.icon" size="18" :class="isLegal.color" />
<span>{{ isLegal.title }}</span>
</div>
<UserProfileForm />
<UserProfileForm show-submit layout="desktop" />
</BasicModal>
<!-- ویرایش رمزعبور — موبایل -->
@@ -51,11 +50,13 @@
title="ویرایش رمزعبور"
width="480px"
destroy-on-close
custom-footer
:loading="loading"
@ok="submitPassForm"
>
<UserPasswordForm />
<UserPasswordForm show-submit />
</BasicModal>
<LogoutConfirmDialog v-model:show="showLogoutConfirm" @confirm="logout" />
</div>
</template>
@@ -64,6 +65,7 @@ import BasicActionSheet from '@/components/BasicPacks/BasicActionSheet.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import BasicModal from '@/components/BasicPacks/BasicModal.vue'
import { useDesktopBreakpoint } from '@/layouts/composables/useAppLayout'
import LogoutConfirmDialog from '@/views/base/home/components/LogoutConfirmDialog.vue'
import { useUserPage } from './composables/useUserPage'
import MobileUserView from './mobile/MobileUserView.vue'
import DesktopUserView from './desktop/DesktopUserView.vue'
@@ -77,8 +79,8 @@ const {
editPasswordVisible,
isLegal,
loading,
ok,
submitPassForm,
showLogoutConfirm,
logout,
} = useUserPage()
</script>
@@ -103,10 +105,60 @@ const {
<style lang="less">
.reject {
color: red;
color: #dc2626;
}
.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>
+51 -7
View File
@@ -72,16 +72,36 @@
@click="action.onClick"
>
<div class="flex">
<img
v-if="action.iconSrc"
:src="action.iconSrc"
alt=""
class="user-menu-icon-image absolute right-8"
/>
<BasicIcon v-else :icon="action.icon" class="absolute right-8" />
<span class="user-menu-icon-wrap absolute right-8">
<img
v-if="action.iconSrc"
:src="action.iconSrc"
alt=""
class="user-menu-icon-image"
/>
<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>
</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>
@@ -103,6 +123,7 @@ const {
menuActions,
copyCode,
shareCode,
requestLogout,
} = useUserPageContext()
</script>
@@ -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;
}
</style>