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>
+11
View File
@@ -293,6 +293,10 @@ import {
} from "@/utils/config";
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
import {getConfig} from "@/api/config";
import { usePwaInstall } from '@/composables/usePwaInstall'
import {getProducts} from "@/api/products";
@@ -426,11 +430,18 @@ function phoneVerification() {
stepTwoVerify.value = true
reset()
} else {
// نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await verifyToken(userFormState)
await login(access_token)
}
} else {
userFormState.token = undefined
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await loginCustomer(userFormState)
await login(access_token)
}
@@ -19,6 +19,10 @@ import { completeLoginNavigation } from '@/layouts/composables/useMobileScrollRe
import { normalizeAuthFormFields } from '@/utils/persianDigits'
import { beginSmsOtpCapture, clearPendingSmsOtp } from '@/utils/webOtp'
import { usePwaInstall } from '@/composables/usePwaInstall'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
export function useLoginForm() {
const { time, onFinish, reset } = useCountdownTimer('00:02:00', 'mm:ss')
@@ -109,11 +113,18 @@ export function useLoginForm() {
stepTwoVerify.value = true
reset()
} else {
// نوتیف را در gesture شروع کن؛ await نکن تا ورود بلاک نشود
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await verifyToken(userFormState)
await login(access_token)
}
} else {
userFormState.token = undefined
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
const { access_token } = await loginCustomer(userFormState)
await login(access_token)
}
@@ -1,6 +1,10 @@
<template>
<div class="dauth dauth--boursecu">
<div class="dauth__layout">
<main class="dauth-form-panel">
<slot />
</main>
<aside class="dauth-brand dauth-brand--boursecu">
<div class="dauth-brand__pattern" />
<div class="dauth-brand__content">
@@ -22,10 +26,6 @@
</div>
<div class="dauth-brand__footer">نسخه {{ version }}</div>
</aside>
<main class="dauth-form-panel">
<slot />
</main>
</div>
</div>
</template>
@@ -10,7 +10,8 @@
.dauth__layout {
display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(420px, 520px);
/* فرم | برند — در RTL: برند چپ، فرم راست؛ نسبت نزدیک به توازن */
grid-template-columns: minmax(480px, 1fr) minmax(420px, 1.05fr);
width: 100%;
min-height: 100vh;
}
@@ -136,12 +137,12 @@
display: flex;
align-items: center;
justify-content: center;
padding: 48px 56px;
padding: 48px 48px;
}
.dauth-card {
width: 100%;
max-width: 420px;
max-width: 520px;
background: #ffffff;
border-radius: 16px;
padding: 36px 40px;
@@ -471,7 +472,7 @@
@media (max-width: 900px) {
.dauth__layout {
grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr);
grid-template-columns: minmax(340px, 1fr) minmax(300px, 1fr);
}
.dauth-brand {
@@ -0,0 +1,826 @@
<template>
<div class="support-composer">
<div class="support-composer__inner mx-auto w-full max-w-6xl">
<div
class="support-composer-panel transition-all"
:class="isDragOver ? 'is-drag-over' : ''"
@dragenter.prevent="isDragOver = true"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="onFileChange"
/>
<Transition name="support-reply-preview">
<div v-if="replyingTo" class="support-composer-reply">
<div class="support-composer-reply__bar" />
<div
v-if="replyingTo.attachment?.type === 'image'"
class="support-composer-reply__thumb"
>
<img
:src="replyingTo.attachment.url"
:alt="replyingTo.attachment.name || 'reply image'"
/>
</div>
<div class="support-composer-reply__body">
<div class="support-composer-reply__title">
پاسخ به {{ getReplySenderLabel(replyingTo.sender) }}
</div>
<div class="support-composer-reply__text">
{{ getReplyPreviewText(replyingTo.text) }}
</div>
</div>
<BasicButton class="support-composer-reply__close" @click="emit('cancel-reply')" danger>
<BasicIcon icon="vuesax-linear:add" class="rotate-45" />
</BasicButton>
</div>
</Transition>
<div v-if="attachments.length" class="mb-3 flex gap-2 overflow-x-auto pb-1">
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
draggable="true"
class="support-attachment-item group relative shrink-0 cursor-move overflow-hidden rounded-2xl border"
:class="
attachment.type === 'image'
? 'h-24 w-24'
: 'flex min-w-[240px] items-center gap-3 p-3'
"
@dragstart="onPreviewDragStart(index)"
@dragover.prevent
@drop.prevent="onPreviewDrop(index)"
>
<template v-if="attachment.type === 'image'">
<img
:src="attachment.url"
:alt="attachment.name"
class="h-full w-full object-cover"
/>
</template>
<template v-else>
<div
class="support-audio-badge flex h-10 w-10 items-center justify-center rounded-2xl"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M12 3a3 3 0 0 1 3 3v6a3 3 0 1 1-6 0V6a3 3 0 0 1 3-3Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3M8 22h8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-[var(--colorText)]">
{{ attachment.name }}
</div>
<audio :src="attachment.url" controls class="mt-2 block w-full max-w-[160px]" />
</div>
</template>
<button
type="button"
class="support-remove-btn absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full opacity-100 transition md:opacity-0 md:group-hover:opacity-100"
@click="removeAttachment(attachment.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M18 6 6 18M6 6l12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<div
v-show="showRecorder || isRecording || recordedBlobUrl"
class="support-recorder-panel mb-3 overflow-hidden rounded-2xl border"
>
<div class="support-recorder-head border-b px-4 py-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span
v-if="isRecording"
class="h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
/>
<span class="text-sm font-semibold text-[var(--colorText)]">
{{ isRecording ? 'در حال ضبط وویس...' : 'پیش‌نمایش وویس' }}
</span>
</div>
<span class="text-xs text-[var(--colorText)]/65">
{{ formattedRecordTime }}
</span>
</div>
</div>
<div class="px-3 py-3">
<div ref="waveformEl" class="min-h-[64px]" />
</div>
<div
v-if="recordedBlobUrl && !isRecording"
class="support-recorder-actions flex items-center gap-2 border-t px-4 py-3"
>
<button
type="button"
class="support-recorder-secondary rounded-xl border px-3 py-2 text-sm transition"
@click="discardRecordedVoice"
>
حذف وویس
</button>
<button
type="button"
class="support-recorder-primary rounded-xl px-3 py-2 text-sm transition"
@click="appendRecordedVoiceToAttachments"
>
افزودن به پیام
</button>
</div>
</div>
<div class="support-composer__row flex items-end gap-2">
<BasicButton
type="primary"
:disabled="disabled || isRecording || (!modelValue.trim() && attachments.length === 0)"
class="support-send-btn !h-12 !w-12 shrink-0 !p-0"
@mousedown.prevent
@click="submit"
>
<BasicIcon icon="vuesax-linear:direct-right" size="20" />
</BasicButton>
<div
class="support-textarea-shell flex min-h-12 min-w-0 flex-1 items-center rounded-2xl px-1.5 py-1"
>
<textarea
ref="textareaRef"
:value="modelValue"
:disabled="disabled || isRecording"
rows="1"
placeholder="پیامت را بنویس..."
class="composer-textarea w-full resize-none overflow-y-auto bg-transparent px-2 text-sm leading-7 outline-none py-[10px] !text-[16px]"
@input="onInput"
@keydown="onKeydown"
/>
<BasicButton
class="support-tool-btn flex !h-10 !w-10 shrink-0 items-center justify-center rounded-xl"
:disabled="disabled || isRecording"
type="link"
@mousedown.prevent
@click="openFilePicker"
>
<BasicIcon icon="vuesax-linear:gallery" size="20" />
</BasicButton>
</div>
</div>
<div
v-if="isDragOver"
class="support-drop-zone mt-3 rounded-2xl border border-dashed px-4 py-3 text-center text-sm"
>
عکس را اینجا رها کن
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import WaveSurfer from 'wavesurfer.js'
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.esm.js'
import type { ChatAttachment, ChatReplyPreview, ComposerSendPayload } from '@/models/support'
import BasicButton from '@/components/BasicPacks/BasicButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
const props = defineProps<{
modelValue: string
disabled?: boolean
replyingTo?: ChatReplyPreview | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', payload: ComposerSendPayload): void
(e: 'cancel-reply'): void
}>()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const waveformEl = ref<HTMLElement | null>(null)
const attachments = ref<ChatAttachment[]>([])
const isDragOver = ref(false)
const draggedPreviewIndex = ref<number | null>(null)
const shouldRefocusAfterSend = ref<boolean>(false)
const showRecorder = ref(false)
const isRecording = ref(false)
const recordTimeMs = ref(0)
const recordedBlob = ref<Blob | null>(null)
const recordedBlobUrl = ref('')
const recordedMimeType = ref('audio/webm')
let wavesurfer: WaveSurfer | null = null
let recordPlugin: ReturnType<typeof RecordPlugin.create> | null = null
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const getReplySenderLabel = (sender: string) => {
if (sender === 'user') return 'خودتان'
if (sender === 'support') return 'پشتیبانی'
return 'پیام'
}
const getReplyPreviewText = (text: string | undefined) => {
const value = String(text || '').trim()
return value || 'پیام'
}
const focusTextarea = async () => {
await nextTick()
const el = textareaRef.value
if (!el || props.disabled || isRecording.value) return
el.focus()
const length = el.value.length
el.setSelectionRange(length, length)
}
const formattedRecordTime = computed(() => {
const totalSeconds = Math.floor(recordTimeMs.value / 1000)
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0')
const seconds = String(totalSeconds % 60).padStart(2, '0')
return `${minutes}:${seconds}`
})
const resizeTextarea = () => {
if (!textareaRef.value) return
textareaRef.value.style.height = '48px'
textareaRef.value.style.height = `${Math.min(textareaRef.value.scrollHeight, 144)}px`
}
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
resizeTextarea()
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}
const openFilePicker = () => {
fileInputRef.value?.click()
}
const createImageAttachment = (file: File): ChatAttachment => ({
id: createId(),
type: 'image',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
file
})
const createAudioAttachment = (file: File, durationMs?: number): ChatAttachment => ({
id: createId(),
type: 'audio',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
durationMs,
file
})
const addImageFiles = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith('image/'))
if (!imageFiles.length) return
attachments.value.push(...imageFiles.map(createImageAttachment))
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
addImageFiles(files)
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const onDrop = (event: DragEvent) => {
isDragOver.value = false
const files = Array.from(event.dataTransfer?.files ?? [])
addImageFiles(files)
}
const onDragLeave = (event: DragEvent) => {
const target = event.currentTarget as HTMLElement | null
const relatedTarget = event.relatedTarget as Node | null
if (!target || (relatedTarget && target.contains(relatedTarget))) return
isDragOver.value = false
}
const removeAttachment = (id: string) => {
const target = attachments.value.find((item) => item.id === id)
if (target?.url.startsWith('blob:')) {
URL.revokeObjectURL(target.url)
}
attachments.value = attachments.value.filter((item) => item.id !== id)
}
const onPreviewDragStart = (index: number) => {
draggedPreviewIndex.value = index
}
const onPreviewDrop = (dropIndex: number) => {
if (draggedPreviewIndex.value === null) return
if (draggedPreviewIndex.value === dropIndex) return
const items = [...attachments.value]
const movedItem = items[draggedPreviewIndex.value]
items.splice(draggedPreviewIndex.value, 1)
items.splice(dropIndex, 0, movedItem)
attachments.value = items
draggedPreviewIndex.value = null
}
const initWaveSurfer = async () => {
if (!waveformEl.value || wavesurfer) return
recordPlugin = RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: true,
mediaRecorderTimeslice: 250,
mimeType: 'audio/webm'
})
wavesurfer = WaveSurfer.create({
container: waveformEl.value,
height: 64,
cursorWidth: 0,
barWidth: 3,
barGap: 2,
barRadius: 999,
normalize: true,
interact: true,
plugins: [recordPlugin]
})
recordPlugin.on('record-progress', (duration) => {
recordTimeMs.value = duration
})
recordPlugin.on('record-start', () => {
isRecording.value = true
recordTimeMs.value = 0
recordedBlob.value = null
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
})
recordPlugin.on('record-end', (blob) => {
isRecording.value = false
recordedBlob.value = blob
recordedMimeType.value = blob.type || 'audio/webm'
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordedBlobUrl.value = URL.createObjectURL(blob)
})
}
const requestMicrophonePermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
console.error('mic permission failed:', error)
return false
}
}
const toggleRecording = async () => {
if (props.disabled) return
try {
const granted = await requestMicrophonePermission()
if (!granted) return
if (!showRecorder.value) {
showRecorder.value = true
await nextTick()
}
await initWaveSurfer()
if (!recordPlugin) return
if (recordPlugin.isRecording()) {
recordPlugin.stopRecording()
} else {
await recordPlugin.startRecording()
}
} catch (error) {
console.error('Voice recording failed:', error)
}
}
const discardRecordedVoice = () => {
recordedBlob.value = null
recordTimeMs.value = 0
isRecording.value = false
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
showRecorder.value = false
wavesurfer?.empty()
}
const appendRecordedVoiceToAttachments = () => {
if (!recordedBlob.value) return
const extension = recordedMimeType.value.includes('ogg')
? 'ogg'
: recordedMimeType.value.includes('mp4')
? 'm4a'
: 'webm'
const file = new File([recordedBlob.value], `voice-${Date.now()}.${extension}`, {
type: recordedMimeType.value || 'audio/webm'
})
attachments.value.push(createAudioAttachment(file, recordTimeMs.value))
discardRecordedVoice()
}
const clearComposerAttachments = () => {
attachments.value.forEach((item) => {
if (item.url.startsWith('blob:')) {
URL.revokeObjectURL(item.url)
}
})
attachments.value = []
}
const submit = async () => {
const text = props.modelValue.trim()
if (!text && attachments.value.length === 0) return
if (props.disabled || isRecording.value) return
const payloadAttachments = attachments.value.map((item) => ({
...item,
url: item.file ? URL.createObjectURL(item.file) : item.url
}))
shouldRefocusAfterSend.value = true
emit('send', {
text,
attachments: payloadAttachments,
reply: props.replyingTo?.id ?? null,
replyTo: props.replyingTo ?? null
})
emit('update:modelValue', '')
clearComposerAttachments()
await nextTick()
resizeTextarea()
await focusTextarea()
}
watch(
() => props.modelValue,
() => {
resizeTextarea()
},
{ immediate: true }
)
onBeforeUnmount(() => {
clearComposerAttachments()
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordPlugin?.destroy()
wavesurfer?.destroy()
recordPlugin = null
wavesurfer = null
})
watch(
() => props.disabled,
async (disabled) => {
if (disabled) return
if (!shouldRefocusAfterSend.value) return
shouldRefocusAfterSend.value = false
await focusTextarea()
}
)
</script>
<style scoped lang="less">
.support-composer {
position: relative;
z-index: 3;
padding: 10px 12px 12px;
background: color-mix(in srgb, var(--bg, #fff) 82%, transparent);
border-top: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 12%, transparent);
box-shadow: none;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
font-family: var(--font-family, iran-sans), serif;
}
.support-composer__inner {
min-width: 0;
}
.support-composer-reply__thumb {
width: 42px;
height: 42px;
flex: 0 0 42px;
overflow: hidden;
border-radius: 12px;
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.08);
}
.support-composer-reply__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.support-composer-panel {
width: 100%;
min-width: 0;
}
.support-composer-panel.is-drag-over {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.06);
border-radius: 16px;
}
.support-attachment-item {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.18);
background: var(--bg, #fff);
}
.support-audio-badge {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
color: var(--colorPrimary, #2563eb);
}
.support-remove-btn {
background: rgb(0 0 0 / 0.7);
color: #fff;
}
.support-recorder-panel {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.16);
background: var(--bg, #fff);
}
.support-recorder-head,
.support-recorder-actions {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
}
.support-recorder-secondary {
background: transparent;
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.18);
color: var(--colorText, #111827);
}
.support-recorder-primary {
background: var(--colorPrimary, #2563eb);
color: var(--color6, #fff);
border: 1px solid var(--colorPrimary, #2563eb);
}
.support-recorder-secondary:hover {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.05);
}
.support-recorder-primary:hover {
opacity: 0.92;
}
.support-textarea-shell {
background: var(--bg, #fff);
border: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 16%, transparent);
box-shadow: 0 6px 16px color-mix(in srgb, var(--colorPrimary, #2563eb) 8%, transparent);
}
.composer-textarea {
color: var(--colorText, #111827);
font-family: var(--font-family, iran-sans), serif;
min-height: 44px;
max-height: 144px;
scrollbar-width: thin;
scrollbar-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35) transparent;
}
.composer-textarea::placeholder {
color: color-mix(in srgb, var(--colorText, #111827) 45%, transparent);
}
.composer-textarea::-webkit-scrollbar {
width: 6px;
}
.composer-textarea::-webkit-scrollbar-thumb {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35);
border-radius: 999px;
}
.composer-textarea::-webkit-scrollbar-track {
background: transparent;
}
.support-tool-btn {
background: transparent !important;
color: var(--colorPrimary, #2563eb) !important;
box-shadow: none !important;
}
.support-tool-btn.is-recording {
background: rgb(239 68 68 / 0.08) !important;
border-color: rgb(239 68 68 / 0.28) !important;
color: #ef4444 !important;
}
.support-send-btn {
border-radius: 16px !important;
background: var(--colorPrimary, #2563eb) !important;
border-color: var(--colorPrimary, #2563eb) !important;
color: #fff !important;
box-shadow: 0 10px 22px color-mix(in srgb, var(--colorPrimary, #2563eb) 34%, transparent) !important;
}
.support-send-btn.ant-btn-primary:not(:disabled):not(.ant-btn-disabled) {
background: var(--colorPrimary, #2563eb) !important;
border-color: var(--colorPrimary, #2563eb) !important;
color: #fff !important;
}
.support-send-btn:disabled,
.support-send-btn.ant-btn-disabled,
.support-send-btn.ant-btn-primary:disabled {
opacity: 1 !important;
background: color-mix(in srgb, var(--colorPrimary, #2563eb) 38%, #fff) !important;
border-color: transparent !important;
color: #fff !important;
box-shadow: none !important;
}
.support-drop-zone {
border-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.28);
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.06);
color: var(--colorText, #111827);
}
.support-composer-reply {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
margin-bottom: 10px;
padding: 9px 10px;
border-radius: 16px;
background: var(--bg2, #f4f6f8);
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
overflow: hidden;
}
.support-composer-reply__bar {
width: 4px;
height: 38px;
border-radius: 999px;
background: var(--colorPrimary, #2563eb);
flex: 0 0 4px;
}
.support-composer-reply__body {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
.support-composer-reply__title {
margin-bottom: 2px;
font-size: 12px;
font-weight: 900;
color: var(--colorPrimary, #2563eb);
}
.support-composer-reply__text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
unicode-bidi: plaintext;
font-size: 12px;
line-height: 1.8;
color: color-mix(in srgb, var(--colorText, #111827) 72%, transparent);
}
.support-composer-reply__close {
width: 30px;
height: 30px;
flex: 0 0 30px;
border: 0;
border-radius: 999px;
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
color: var(--colorText, #111827);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.support-reply-preview-enter-active,
.support-reply-preview-leave-active {
transition: all 0.18s ease;
}
.support-reply-preview-enter-from,
.support-reply-preview-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (min-width: 768px) {
.support-composer {
padding: 12px 16px 14px;
}
}
@media (max-width: 767px) {
.support-composer {
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
}
}
</style>
@@ -0,0 +1,155 @@
<template>
<header class="support-header">
<div class="support-header__inner">
<div class="bg-glass rounded-full p-2" @click="$router.back()">
<BasicIcon icon="vuesax-linear:arrow-right" />
</div>
<div class="support-header__icon">
<img
v-if="logoUrl"
:src="logoUrl"
alt=""
class="support-header__logo"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="support-header__icon-svg"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M8 10.5h8M8 14h5M7 20l-3 1 1-3V6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9l-2 3Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div class="support-header__content">
<h1 class="support-header__title">{{ title }}</h1>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { useConfigStore } from '@/store/configStore'
defineProps<{
title: string
subtitle?: string
}>()
const config = useConfigStore()
const logoUrl = computed(() => String(config.config?.avatar || '').trim())
</script>
<style scoped lang="less">
.support-header {
position: sticky;
top: 0;
z-index: 2;
border-bottom: 1px solid color-mix(in srgb, var(--colorPrimary, #2563eb) 10%, transparent);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg, #fff) 55%, transparent) 0%,
color-mix(in srgb, var(--bg, #fff) 22%, transparent) 70%,
transparent 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.support-header__inner {
max-width: 64rem;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
}
.support-header__icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
flex: 0 0 52px;
overflow: hidden;
border-radius: 18px;
background: var(--bg2, #0a1b37);
color: var(--colorText, #fff);
box-shadow: 0 10px 24px rgb(var(--colorPrimaryRGB, 37 99 235) / 0.16);
}
.support-header__logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
padding: 6px;
background: #fff;
}
.support-header__icon-svg {
width: 22px;
height: 22px;
}
.support-header__content {
min-width: 0;
flex: 1;
}
.support-header__title {
margin: 0;
font-size: 1.125rem;
font-weight: 900;
color: var(--colorText, #111827);
}
.support-header__subtitle-row {
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.support-header__status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--color4, #4caf50);
box-shadow: 0 0 0 3px rgb(76 175 80 / 0.12);
}
.support-header__subtitle {
font-size: 0.95rem;
color: color-mix(in srgb, var(--colorText, #111827) 75%, transparent);
}
:global(body.dynamicMode) .support-header__title,
:global(body.dynamicMode) .support-header__subtitle {
color: var(--colorText, #fff);
}
@media (max-width: 767px) {
.support-header__inner {
padding: 14px 16px;
}
.support-header__icon {
width: 48px;
height: 48px;
flex-basis: 48px;
border-radius: 16px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+726
View File
@@ -0,0 +1,726 @@
<template>
<div class="support-page main-with-bottom-chrome-inner--contained">
<div class="support-shell">
<SupportChatHeader title="پشتیبانی" />
<SupportChatMessages
ref="messagesRef"
:messages="messages"
:support-typing="supportTyping"
:has-more="hasMoreMessages"
:loading-older="isFetchingOlderMessages"
:loading="chatLoading"
:loading-label="loadingLabel"
@load-older="loadOlderMessages"
@reply="handleReply"
@bottom-state-change="setSupportChatAtBottom"
/>
<SupportChatComposer
v-model="draft"
:disabled="isSending"
:replying-to="replyingTo"
@cancel-reply="clearReply"
@send="sendMessage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import SupportChatHeader from './components/SupportChatHeader.vue'
import SupportChatMessages from './components/SupportChatMessages.vue'
import SupportChatComposer from './components/SupportChatComposer.vue'
import type {
ChatAttachment,
ChatMessage,
ChatReplyPreview,
ComposerSendPayload
} from '@/models/support'
import {
connectChatIfExists,
ensureChatAndConnect,
fetchChatMessages,
fetchOlderChatMessages,
sendTextMessage,
sendMediaMessage,
getExistingChatSession,
useChatService,
chatLoading,
setSupportChatPageOpen
} from '@/utils/chat'
import { useSettingsStore } from '@/store/settingsStore'
import {
canRequestBrowserNotification,
requestBrowserNotificationPermission,
} from '@/utils/browserNotification'
const draft = ref('')
const isSending = ref(false)
const supportTyping = ref(false)
const replyingTo = ref<ChatReplyPreview | null>(null)
const uploadingMessagesByRoom = ref<Record<string, ChatMessage[]>>({})
const IMAGE_MAX_WIDTH = 1600
const IMAGE_MAX_HEIGHT = 1600
const IMAGE_WEBP_QUALITY = 0.85
const messagesRef = ref<InstanceType<typeof SupportChatMessages> | null>(null)
const {
messages: rawMessages,
currentRoomId,
currentChatUserId,
status,
hasMoreMessages,
isFetchingOlderMessages,
setSupportChatAtBottom
} = useChatService()
const loadingLabel = computed(() => {
if (!chatLoading.value) return ''
if (status.value === 'CONNECTING') {
return 'در حال دریافت پیام‌ها و اتصال به پشتیبانی...'
}
return 'در حال دریافت پیام‌ها...'
})
const formatTime = (date: Date | string | number | null | undefined) => {
const value = date ? new Date(date) : new Date()
return new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit'
}).format(value)
}
const getSettingValue = (key: string) => {
const setting = useSettingsStore().getSettingByKey(key)
if (setting == null) return ''
if (typeof setting === 'string') return setting.trim()
return String(setting).trim()
}
const getChatUploadUrl = () => getSettingValue('chat_upload_url')
const getChatDownloadUrl = () => getSettingValue('chat_download_url')
const buildChatFileUrl = (value: string) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^(https?:\/\/|blob:|data:)/i.test(raw)) {
return raw
}
const downloadUrl = getChatDownloadUrl()
if (!downloadUrl) {
console.error('chat_download_url is empty')
return raw
}
const cleanRaw = raw.replace(/^\/+/, '')
try {
const baseUrl = new URL(downloadUrl)
const basePath = baseUrl.pathname.replace(/^\/+|\/+$/g, '')
const baseParts = basePath.split('/').filter(Boolean)
const rawParts = cleanRaw.split('/').filter(Boolean)
if (basePath && cleanRaw.startsWith(`${basePath}/`)) {
return `${baseUrl.origin}/${cleanRaw}`
}
const lastBasePart = baseParts[baseParts.length - 1]
if (lastBasePart && rawParts[0] === lastBasePart) {
const parentPath = baseParts.slice(0, -1).join('/')
return `${baseUrl.origin}/${[parentPath, cleanRaw].filter(Boolean).join('/')}`
}
return `${downloadUrl.replace(/\/+$/, '')}/${cleanRaw}`
} catch (error) {
console.error('invalid chat_download_url:', downloadUrl, error)
return raw
}
}
const extractUploadedPath = (result: any) => {
const value =
result?.path ||
result?.url ||
result?.file_url ||
result?.fileUrl ||
result?.data?.path ||
result?.data?.url ||
result?.data?.file_url ||
result?.data?.fileUrl ||
result?.file?.path ||
result?.file?.url
return String(value || '').trim()
}
const getCompressedImageName = (fileName: string) => {
const cleanName = String(fileName || `image-${Date.now()}`)
.replace(/\.[^/.]+$/, '')
.replace(/[^\w\u0600-\u06FF.-]+/g, '-')
return `${cleanName}.webp`
}
const canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality: number) => {
return new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => resolve(blob), type, quality)
})
}
const loadImageFromFile = (file: File) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('image load failed'))
}
img.src = url
})
}
const shouldCompressImage = (file: File) => {
if (!file.type.startsWith('image/')) return false
// gif متحرک خراب میشه، svg هم نیازی به canvas/webp نداره
if (file.type === 'image/gif') return false
if (file.type === 'image/svg+xml') return false
return true
}
const compressImageToWebp = async (file: File) => {
if (!shouldCompressImage(file)) return file
try {
const img = await loadImageFromFile(file)
const ratio = Math.min(
IMAGE_MAX_WIDTH / img.naturalWidth,
IMAGE_MAX_HEIGHT / img.naturalHeight,
1
)
const width = Math.round(img.naturalWidth * ratio)
const height = Math.round(img.naturalHeight * ratio)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return file
ctx.drawImage(img, 0, 0, width, height)
const blob = await canvasToBlob(canvas, 'image/webp', IMAGE_WEBP_QUALITY)
if (!blob) return file
const webpFile = new File([blob], getCompressedImageName(file.name), {
type: 'image/webp',
lastModified: Date.now()
})
// اگر WebP بزرگ‌تر شد، فایل اصلی رو آپلود کن
if (webpFile.size >= file.size) return file
return webpFile
} catch (error) {
console.error('compress image to webp failed:', error)
return file
}
}
const uploadChatFile = (file: File, onProgress?: (percent: number) => void) => {
return new Promise<string>((resolve, reject) => {
const uploadUrl = getChatUploadUrl()
if (!uploadUrl) {
reject(new Error('chat_upload_url is empty'))
return
}
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', uploadUrl)
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return
const percent = Math.round((event.loaded / event.total) * 100)
onProgress?.(Math.min(percent, 99))
}
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`chat file upload failed: ${xhr.status}`))
return
}
try {
const contentType = xhr.getResponseHeader('content-type') || ''
if (contentType.includes('application/json')) {
const result = JSON.parse(xhr.responseText)
const uploadedPath = extractUploadedPath(result)
if (!uploadedPath) {
console.error('upload response has no path/url:', result)
reject(new Error('upload response has no path/url'))
return
}
resolve(uploadedPath)
return
}
const uploadedPath = xhr.responseText.trim()
if (!uploadedPath) {
reject(new Error('upload response is empty'))
return
}
resolve(uploadedPath)
} catch (error) {
reject(error)
}
}
xhr.onerror = () => {
reject(new Error('chat file upload network error'))
}
xhr.send(formData)
})
}
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const mapRawAttachment = (item: any): ChatAttachment | null => {
if (!item) return null
const type = String(item.type || item.file_type || item.kind || '').toLowerCase()
const rawUrl = item.url || item.file_url || item.fileUrl || item.path || item.content
const url = buildChatFileUrl(rawUrl)
const name = item.name || item.file_name || 'file'
if (!url) return null
if (type === 'image') {
return {
id: item.id || createId(),
type: 'image',
url,
name
}
}
if (type === 'audio' || type === 'voice') {
return {
id: item.id || createId(),
type: 'audio',
url,
name
}
}
return null
}
const getMessageType = (normalized: any) => {
return String(normalized.type_content ?? normalized.type ?? 'text').toLowerCase()
}
const getMessagePreviewText = (message: Pick<ChatMessage, 'text' | 'attachments'>) => {
const text = String(message.text || '').trim()
const firstAttachment = message.attachments?.[0]
if (text) return text
if (firstAttachment?.type === 'image') return 'تصویر'
if (firstAttachment?.type === 'audio') return 'ویس'
return 'پیام'
}
const toReplyPreview = (message: ChatMessage): ChatReplyPreview => {
const firstAttachment = message.attachments?.[0] || null
return {
id: message.id,
sender: message.sender,
text: getMessagePreviewText(message),
attachment: firstAttachment
? {
type: firstAttachment.type,
url: firstAttachment.url,
name: firstAttachment.name
}
: null
}
}
const handleReply = async (message: ChatMessage) => {
replyingTo.value = toReplyPreview(message)
console.log('replying to:', replyingTo.value)
await nextTick()
}
const clearReply = () => {
replyingTo.value = null
}
const messages = computed<ChatMessage[]>(() => {
const baseMessages = rawMessages.value
.map((item: any) => {
const normalized = item.message ?? item
const senderId =
normalized.sender_id ??
normalized.user_id ??
normalized.from_user_id ??
normalized.sender?.id
const typeContent = getMessageType(normalized)
const rawContent = normalized.content ?? normalized.text ?? normalized.body ?? ''
const fileContent =
typeContent === 'image' || typeContent === 'voice' || typeContent === 'audio'
? buildChatFileUrl(rawContent)
: ''
let attachments: ChatAttachment[] = []
if (typeContent === 'image' && fileContent) {
attachments = [
{
id: normalized.id || normalized.message_id || createId(),
type: 'image',
url: fileContent,
name: normalized.file_name || normalized.name || 'image'
}
]
} else if ((typeContent === 'voice' || typeContent === 'audio') && fileContent) {
attachments = [
{
id: normalized.id || normalized.message_id || createId(),
type: 'audio',
url: fileContent,
name: normalized.file_name || normalized.name || 'voice'
}
]
} else {
const rawAttachments = normalized.attachments ?? normalized.files ?? normalized.media ?? []
attachments = Array.isArray(rawAttachments)
? (rawAttachments.map(mapRawAttachment).filter(Boolean) as ChatAttachment[])
: []
}
const text = typeContent === 'text' ? String(rawContent || '').trim() : ''
if (!text && attachments.length === 0) return null
const isUserMessage = String(senderId) === String(currentChatUserId.value)
const realMessageId = normalized.id || normalized.message_id
const messageId =
realMessageId ||
normalized._client_key ||
`${normalized.room_id || 'room'}-${senderId || 'sender'}-${
normalized.created_at || normalized.timestamp || ''
}-${rawContent}`
const reply = normalized.reply ?? normalized.reply_id ?? normalized.reply_to_id ?? null
return {
id: String(messageId),
text,
sender: isUserMessage ? 'user' : 'support',
createdAt: formatTime(
normalized.created_at || normalized.createdAt || normalized.timestamp
),
createdAtRaw: normalized.created_at || normalized.createdAt || normalized.timestamp,
status: isUserMessage
? normalized._optimistic
? 'sending'
: normalized.read_at
? 'seen'
: 'sent'
: undefined,
attachments,
reply: reply ? String(reply) : null,
replyTo: null
}
})
.filter(Boolean) as ChatMessage[]
const messageMap = new Map(baseMessages.map((item) => [String(item.id), item]))
const mappedMessages = baseMessages.map((message) => {
const replyId = message.reply ? String(message.reply) : ''
if (!replyId) return message
const repliedMessage = messageMap.get(replyId)
if (!repliedMessage) {
return {
...message,
replyTo: {
id: replyId,
sender: 'support',
text: 'پیام پاسخ داده‌شده',
attachment: null
}
}
}
return {
...message,
replyTo: toReplyPreview(repliedMessage)
}
})
const roomId = currentRoomId.value || ''
const uploadingMessages = uploadingMessagesByRoom.value[roomId] || []
return [...mappedMessages, ...uploadingMessages]
})
onMounted(async () => {
setSupportChatPageOpen(true)
if (canRequestBrowserNotification()) {
void requestBrowserNotificationPermission()
}
try {
const existing = getExistingChatSession()
if (!existing) {
chatLoading.value = false
return
}
await connectChatIfExists()
await fetchChatMessages()
await nextTick()
messagesRef.value?.scrollToBottom(false)
} catch (error) {
chatLoading.value = false
console.error('chat page init failed:', error)
}
})
onBeforeUnmount(() => {
setSupportChatPageOpen(false)
})
const loadOlderMessages = async () => {
try {
messagesRef.value?.prepareForPrepend()
await fetchOlderChatMessages()
await messagesRef.value?.restoreAfterPrepend()
} catch (error) {
console.error('load older messages failed:', error)
}
}
const sendMessage = async (payload: ComposerSendPayload) => {
const text = payload.text.trim()
const attachments = payload.attachments?.map((item) => ({ ...item })) || []
const hasAttachments = attachments.length > 0
const replyId = payload.reply
? String(payload.reply)
: replyingTo.value?.id
? String(replyingTo.value.id)
: null
if (!text && !hasAttachments) return
if (isSending.value) return
try {
isSending.value = true
const existing = getExistingChatSession()
if (!existing) {
await ensureChatAndConnect()
} else if (status.value !== 'OPEN') {
await connectChatIfExists()
}
const roomId = currentRoomId.value || getExistingChatSession()?.roomId || ''
if (text) {
await sendTextMessage(text, replyId)
}
for (const attachment of attachments) {
if (!attachment.file) {
if (attachment.url) {
await sendMediaMessage(
attachment.url,
attachment.type === 'audio' ? 'voice' : 'image',
replyId
)
}
continue
}
const tempMessageId = `uploading-${createId()}`
const previewUrl = attachment.url || URL.createObjectURL(attachment.file)
const uploadingMessage: ChatMessage = {
id: tempMessageId,
sender: 'user',
text: '',
createdAt: formatTime(new Date()),
createdAtRaw: new Date().toISOString(),
status: 'sending',
reply: replyId,
replyTo: replyingTo.value,
attachments: [
{
...attachment,
id: attachment.id || createId(),
url: previewUrl,
uploading: true,
uploadProgress: 0
}
]
}
uploadingMessagesByRoom.value[roomId] = [
...(uploadingMessagesByRoom.value[roomId] || []),
uploadingMessage
]
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
await nextTick()
messagesRef.value?.scrollToBottom(true)
const setUploadProgress = (percent: number) => {
const list = uploadingMessagesByRoom.value[roomId] || []
const target = list.find((message) => message.id === tempMessageId)
const targetAttachment = target?.attachments?.[0]
if (targetAttachment) {
targetAttachment.uploadProgress = percent
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
}
}
try {
setUploadProgress(5)
const fileForUpload =
attachment.type === 'image' ? await compressImageToWebp(attachment.file) : attachment.file
setUploadProgress(12)
const uploadedPath = await uploadChatFile(fileForUpload, setUploadProgress)
setUploadProgress(99)
await sendMediaMessage(
uploadedPath,
attachment.type === 'audio' ? 'voice' : 'image',
replyId
)
} finally {
uploadingMessagesByRoom.value[roomId] = (
uploadingMessagesByRoom.value[roomId] || []
).filter((message) => message.id !== tempMessageId)
uploadingMessagesByRoom.value = { ...uploadingMessagesByRoom.value }
}
}
clearReply()
await nextTick()
messagesRef.value?.scrollToBottom(true)
} catch (error) {
console.error('send message failed:', error)
} finally {
draft.value = ''
isSending.value = false
}
}
</script>
<style scoped lang="less">
.support-page {
height: 100%;
min-height: 0;
padding: 0;
box-sizing: border-box;
font-family: var(--font-family, iran-sans), serif;
}
.support-shell {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background:
radial-gradient(
ellipse 110% 70% at 100% -5%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 28%, transparent),
transparent 68%
),
radial-gradient(
ellipse 95% 60% at -5% 105%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 18%, transparent),
transparent 62%
),
radial-gradient(
circle at 50% 45%,
color-mix(in srgb, var(--colorPrimary, #2563eb) 8%, transparent),
transparent 55%
),
var(--bg, #f7f8fb);
border: 0;
border-radius: 0;
box-shadow: none;
}
@media (min-width: 768px) {
.support-page {
height: calc(100dvh - 4.5rem);
padding: 12px 16px;
}
.support-shell {
border-radius: 16px;
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.12);
box-shadow: 0 16px 48px rgb(15 23 42 / 0.06);
}
}
</style>