added chat 2
This commit is contained in:
+2
-1
@@ -39,7 +39,8 @@
|
||||
"vue3-apexcharts": "^1.4.4",
|
||||
"vue3-lazyload": "^0.3.8",
|
||||
"vue3-odometer": "^0.1.3",
|
||||
"vue3-otp-input": "^0.4.1"
|
||||
"vue3-otp-input": "^0.4.1",
|
||||
"wavesurfer.js": "^7.12.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
|
||||
+87
-2
@@ -46,7 +46,7 @@
|
||||
</transition>
|
||||
|
||||
<div
|
||||
v-if="showSupportButton && !showSplash"
|
||||
v-if="showSupportButton && !showSplash && route.name !== 'support'"
|
||||
id="support-button"
|
||||
ref="supportButtonEl"
|
||||
class="draggable-support-button"
|
||||
@@ -61,6 +61,12 @@
|
||||
<div class="support-button-inner" :style="{ '--support-accent': supportAccentColor }">
|
||||
<BasicIcon :icon="supportIcon" size="18" class="support-button-icon" />
|
||||
<span class="support-button-label">پشتیبانی</span>
|
||||
<span
|
||||
v-if="supportChatUnreadCount > 0"
|
||||
class="support-fab-badge"
|
||||
>
|
||||
{{ supportChatUnreadCount > 99 ? '99+' : supportChatUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +91,9 @@
|
||||
import { colors } from './themeConfig'
|
||||
import pkg from '../package.json'
|
||||
import { getDisplayAppVersion } from '@/config/config-cache'
|
||||
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import img_topGradiant from '@/views/login/assets/img/gr_top_white.svg'
|
||||
import img_pattern from '@/views/login/assets/img/pattern.svg'
|
||||
import { LoadingOutlined } from '@ant-design/icons-vue'
|
||||
@@ -97,6 +105,10 @@ import { useDraggableFloatingButton } from '@/utils/useDraggableFloatingButton'
|
||||
import { useApplyDesktopLayoutClass } from '@/layouts/composables/useAppLayout'
|
||||
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||
import PwaInstallGuideModal from '@/components/pwa/PwaInstallGuideModal.vue'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { connectChatIfExists, disconnectChat, isSettingFlagOn, unreadSupportCount, useChatService } from '@/utils/chat'
|
||||
import { getToken } from '@/utils'
|
||||
|
||||
import img_bottomGradiant from '@/views/login/assets/img/gr_bottom_white.svg'
|
||||
import logoFull from '@/assets/img/logo_full_.png'
|
||||
@@ -113,6 +125,7 @@ const indicator = h(LoadingOutlined, {
|
||||
})
|
||||
const displayVersion = getDisplayAppVersion(pkg.version)
|
||||
const showSplash = ref(true)
|
||||
const route = useRoute()
|
||||
|
||||
useApplyDesktopLayoutClass()
|
||||
|
||||
@@ -127,6 +140,7 @@ const {
|
||||
supportPickerAnchor,
|
||||
supportMessengerEntries,
|
||||
messengerMeta,
|
||||
isChatEnabled,
|
||||
hasMessengerLink: showSupportButton,
|
||||
openMessengerEntry,
|
||||
openSupportMessenger,
|
||||
@@ -135,6 +149,10 @@ const {
|
||||
const supportButtonEl = ref<HTMLElement | null>(null)
|
||||
const pickerAnchorEl = computed(() => supportPickerAnchor.value ?? supportButtonEl.value)
|
||||
|
||||
const supportChatUnreadCount = computed(() =>
|
||||
isChatEnabled.value ? Number(unreadSupportCount.value) || 0 : 0,
|
||||
)
|
||||
|
||||
const supportIcon = computed(() => messengerMeta.value.icon)
|
||||
|
||||
const supportAccentColor = computed(() =>
|
||||
@@ -171,6 +189,54 @@ onMounted(async () => {
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { latestIncomingNotification } = useChatService()
|
||||
let lastConnectedChatSessionKey = ''
|
||||
let lastSupportToastAt = 0
|
||||
|
||||
watch(
|
||||
() => [
|
||||
userStore.userInfo?.chat_room_id,
|
||||
userStore.userInfo?.chat_user_id,
|
||||
settingsStore.getSettingByKey('chat_api_key'),
|
||||
settingsStore.getSettingByKey('show_to_users_chat'),
|
||||
settingsStore.getSettingByKey('has_chat'),
|
||||
getToken(),
|
||||
],
|
||||
async ([roomId, chatUserId, chatApiKey, chatAdminFlag, chatCustomerFlag, token]) => {
|
||||
const chatOn =
|
||||
isSettingFlagOn(chatAdminFlag) && isSettingFlagOn(chatCustomerFlag)
|
||||
if (!token || !chatOn || !chatApiKey || !roomId || !chatUserId) {
|
||||
lastConnectedChatSessionKey = ''
|
||||
disconnectChat()
|
||||
return
|
||||
}
|
||||
|
||||
const sessionKey = `${roomId}:${chatUserId}:${chatApiKey}`
|
||||
if (lastConnectedChatSessionKey === sessionKey) return
|
||||
|
||||
try {
|
||||
await connectChatIfExists()
|
||||
lastConnectedChatSessionKey = sessionKey
|
||||
} catch (error) {
|
||||
lastConnectedChatSessionKey = ''
|
||||
console.error('connectChatIfExists failed:', error)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(latestIncomingNotification, (payload) => {
|
||||
if (!payload) return
|
||||
if (payload.at === lastSupportToastAt) return
|
||||
lastSupportToastAt = payload.at
|
||||
message.info({
|
||||
content: `پیام جدید پشتیبانی: ${payload.body}`,
|
||||
duration: 3,
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@@ -193,6 +259,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.support-button-inner {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -218,6 +285,24 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.support-fab-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -4px;
|
||||
z-index: 2;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
#support-button:not(.is-dragging):hover .support-button-inner {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import useAxios from '@/utils/useAxios'
|
||||
import type { User } from '@/models/login'
|
||||
|
||||
export const startChat = async (): Promise<{ message: string; user: User }> => {
|
||||
return await useAxios.get<{ message: string; user: User }>(`/customer/start-chat`)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -30,7 +30,13 @@
|
||||
:aria-label="entry.label"
|
||||
@click.stop="select(entry)"
|
||||
>
|
||||
<SupportMessengerBrandIcon :messenger="entry.messenger" />
|
||||
<SupportMessengerBrandIcon v-if="entry.messenger !== 'chat'" :messenger="entry.messenger" />
|
||||
<BasicIcon
|
||||
v-else
|
||||
:icon="entry.icon || 'vuesax-linear:messages-2'"
|
||||
size="22"
|
||||
:color="entry.accent || '#16a34a'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -39,6 +45,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import SupportMessengerBrandIcon from '@/components/SupportMessengerBrandIcon.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import type { ResolvedSupportMessengerEntry } from '@/utils/supportMessenger'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
buildDepositSmsUrl,
|
||||
buildSupportPhoneUrl,
|
||||
@@ -26,39 +27,66 @@ import {
|
||||
hasDepositFieldValue,
|
||||
} from '@/utils/depositGuideText'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { isUserSupportChatEnabled } from '@/utils/chat'
|
||||
|
||||
/** plain: منوی شناور / درباره ما — بدون پیشمتن | deposit: مودال واریز — با deposit_support_message_text */
|
||||
type SupportPickerIntent = 'plain' | 'deposit'
|
||||
|
||||
const CHAT_PICKER_ENTRY: ResolvedSupportMessengerEntry = {
|
||||
messenger: 'chat',
|
||||
id: 'support-chat',
|
||||
label: 'چت پشتیبانی',
|
||||
icon: 'vuesax-linear:messages-2',
|
||||
accent: '#16a34a',
|
||||
url: '',
|
||||
}
|
||||
|
||||
export function useSupportMessenger() {
|
||||
const settingsStore = useSettingsStore()
|
||||
const router = useRouter()
|
||||
const supportPickerOpen = supportMessengerPickerOpen
|
||||
const supportPickerIntent = ref<SupportPickerIntent>('plain')
|
||||
|
||||
const isChatEnabled = computed(() => isUserSupportChatEnabled())
|
||||
|
||||
const legacySupportLink = computed(() => '')
|
||||
|
||||
const depositSupportMessage = computed(() => resolveDepositSupportMessage())
|
||||
|
||||
/** لینکهای تمیز بدون پیشمتن — متن فقط موقع باز کردن (قصد deposit) اضافه میشود */
|
||||
const supportMessengerEntries = computed(() =>
|
||||
resolveSupportMessengerEntries(supportMessengerSettings.value),
|
||||
)
|
||||
|
||||
const pickerEntries = computed(() => {
|
||||
const list = [...supportMessengerEntries.value]
|
||||
if (isChatEnabled.value) {
|
||||
list.unshift(CHAT_PICKER_ENTRY)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const primaryMessengerEntry = computed(() => supportMessengerEntries.value[0] ?? null)
|
||||
|
||||
const messengerType = computed(
|
||||
() => (primaryMessengerEntry.value?.messenger ?? supportMessengerSettings.value.messenger ?? '').trim().toLowerCase(),
|
||||
)
|
||||
|
||||
const messengerMeta = computed(() =>
|
||||
primaryMessengerEntry.value
|
||||
const messengerMeta = computed(() => {
|
||||
if (isChatEnabled.value && supportMessengerEntries.value.length === 0) {
|
||||
return {
|
||||
label: CHAT_PICKER_ENTRY.label,
|
||||
icon: CHAT_PICKER_ENTRY.icon,
|
||||
accent: CHAT_PICKER_ENTRY.accent,
|
||||
}
|
||||
}
|
||||
return primaryMessengerEntry.value
|
||||
? {
|
||||
label: primaryMessengerEntry.value.label,
|
||||
icon: primaryMessengerEntry.value.icon,
|
||||
accent: primaryMessengerEntry.value.accent,
|
||||
}
|
||||
: resolveSupportMessengerMeta(messengerType.value),
|
||||
)
|
||||
: resolveSupportMessengerMeta(messengerType.value)
|
||||
})
|
||||
|
||||
const messengerLink = computed(() =>
|
||||
primaryMessengerEntry.value?.url
|
||||
@@ -69,8 +97,16 @@ export function useSupportMessenger() {
|
||||
),
|
||||
)
|
||||
|
||||
function goToSupportChat() {
|
||||
void router.push({ name: 'support' })
|
||||
return true
|
||||
}
|
||||
|
||||
async function openMessengerEntry(entry: ResolvedSupportMessengerEntry, text?: string) {
|
||||
// فقط وقتی از مودال واریز آمدهایم پیشنویس میگذاریم؛ منوی شناور همیشه خالی
|
||||
if (entry.messenger === 'chat') {
|
||||
return goToSupportChat()
|
||||
}
|
||||
|
||||
const messageText =
|
||||
text !== undefined
|
||||
? text.trim()
|
||||
@@ -99,7 +135,12 @@ export function useSupportMessenger() {
|
||||
|
||||
const draft =
|
||||
intent === 'deposit' ? depositSupportMessage.value.trim() : ''
|
||||
const entries = supportMessengerEntries.value
|
||||
const messengerEntries = supportMessengerEntries.value
|
||||
const entries = pickerEntries.value
|
||||
|
||||
if (isChatEnabled.value && messengerEntries.length === 0) {
|
||||
return goToSupportChat()
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return openSupportMessengerWithMessage(
|
||||
@@ -126,12 +167,10 @@ export function useSupportMessenger() {
|
||||
return true
|
||||
}
|
||||
|
||||
/** منوی شناور / صفحات درباره ما — بدون پیشنویس پیام */
|
||||
async function openSupportMessenger(anchorEl?: HTMLElement | null) {
|
||||
return openSupportPicker('plain', anchorEl)
|
||||
}
|
||||
|
||||
/** مودال راهنمای واریز — با متن deposit_support_message_text */
|
||||
async function openDepositSupportMessenger(anchorEl?: HTMLElement | null) {
|
||||
return openSupportPicker('deposit', anchorEl)
|
||||
}
|
||||
@@ -209,8 +248,10 @@ export function useSupportMessenger() {
|
||||
)
|
||||
}
|
||||
|
||||
const hasMessengerLink = computed(() => supportMessengerEntries.value.length > 0 || !!messengerLink.value)
|
||||
const hasMultipleMessengers = computed(() => supportMessengerEntries.value.length > 1)
|
||||
const hasMessengerLink = computed(
|
||||
() => isChatEnabled.value || supportMessengerEntries.value.length > 0 || !!messengerLink.value,
|
||||
)
|
||||
const hasMultipleMessengers = computed(() => pickerEntries.value.length > 1)
|
||||
const hasSupportPhoneLink = computed(() => !!supportPhoneLink.value)
|
||||
const hasSupportActions = computed(() => hasSupportPhoneLink.value || hasMessengerLink.value)
|
||||
const opensInNewTab = computed(() => /^https?:\/\//i.test(messengerLink.value))
|
||||
@@ -218,7 +259,8 @@ export function useSupportMessenger() {
|
||||
return {
|
||||
supportPickerOpen,
|
||||
supportPickerAnchor: supportMessengerPickerAnchor,
|
||||
supportMessengerEntries,
|
||||
supportMessengerEntries: pickerEntries,
|
||||
isChatEnabled,
|
||||
messengerType,
|
||||
messengerMeta,
|
||||
messengerLink,
|
||||
@@ -240,6 +282,7 @@ export function useSupportMessenger() {
|
||||
openDepositSupportMessenger,
|
||||
openMessengerEntry,
|
||||
handleSupportMessengerClick,
|
||||
goToSupportChat,
|
||||
hasMessengerLink,
|
||||
hasMultipleMessengers,
|
||||
hasSupportPhoneLink,
|
||||
|
||||
@@ -8,6 +8,10 @@ export function useAppNavigation() {
|
||||
const popVisible = ref(false)
|
||||
const currentRoute = computed(() => route.name)
|
||||
const hideMenu = computed(() => !!route.meta.hideMenu)
|
||||
/** فقط ناو پایین موبایل — سایدبار دسکتاپ را مخفی نمیکند */
|
||||
const hideBottomNav = computed(
|
||||
() => !!route.meta.hideBottomNav || !!route.meta.hideMenu,
|
||||
)
|
||||
|
||||
function isNavActive(routeName: string) {
|
||||
return currentRoute.value === routeName
|
||||
@@ -34,6 +38,7 @@ export function useAppNavigation() {
|
||||
popVisible,
|
||||
currentRoute,
|
||||
hideMenu,
|
||||
hideBottomNav,
|
||||
isNavActive,
|
||||
navItemClass,
|
||||
navigateTo,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@navigate="navigateTo"
|
||||
/>
|
||||
|
||||
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideMenu }]">
|
||||
<div :class="['main-with-bottom-chrome', { 'main-with-bottom-chrome--no-nav': hideBottomNav }]">
|
||||
<router-view v-slot="{ Component, route: activeRoute }">
|
||||
<transition :name="activeRoute.meta.transition || 'fade'">
|
||||
<component :is="Component" :key="activeRoute.fullPath" class="main-with-bottom-chrome-inner" />
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<Teleport to="body">
|
||||
<MobileBottomNav
|
||||
v-if="!hideMenu"
|
||||
v-if="!hideBottomNav"
|
||||
:nav-item-class="navItemClass"
|
||||
@navigate="navigateTo"
|
||||
@toggle-overflow="toggleOverflowMenu"
|
||||
@@ -37,7 +37,7 @@ const route = useRoute()
|
||||
|
||||
const {
|
||||
popVisible,
|
||||
hideMenu,
|
||||
hideBottomNav,
|
||||
navItemClass,
|
||||
navigateTo,
|
||||
toggleOverflowMenu,
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface User {
|
||||
virtual_card_number: string
|
||||
finger_print: boolean
|
||||
gold: number;
|
||||
chat_room_id?: string | null
|
||||
chat_user_id?: string | null
|
||||
settings?: Array<{ key?: string; value?: string | number }>
|
||||
}
|
||||
|
||||
export interface Login {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export type ChatAttachmentType = 'image' | 'audio'
|
||||
|
||||
export type ChatMessageSender = 'user' | 'support' | 'admin'
|
||||
|
||||
export type ChatMessageStatus = 'sending' | 'sent' | 'seen' | 'failed'
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string
|
||||
type: 'image' | 'audio'
|
||||
url: string
|
||||
name?: string
|
||||
size?: number
|
||||
mimeType?: string
|
||||
durationMs?: number
|
||||
file?: File
|
||||
uploading?: boolean
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
export type ChatReplyPreview = {
|
||||
id: string
|
||||
sender: string
|
||||
text?: string
|
||||
attachment?: {
|
||||
type: 'image' | 'audio'
|
||||
url: string
|
||||
name?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
text?: string
|
||||
sender: 'user' | 'support'
|
||||
createdAt: string
|
||||
createdAtRaw?: string | number | Date | null
|
||||
status?: 'sending' | 'sent' | 'seen'
|
||||
attachments?: ChatAttachment[]
|
||||
|
||||
// reply
|
||||
reply?: string | null
|
||||
replyTo?: ChatReplyPreview | null
|
||||
}
|
||||
|
||||
export type ComposerSendPayload = {
|
||||
text: string
|
||||
attachments: ChatAttachment[]
|
||||
reply?: string | null
|
||||
replyTo?: ChatReplyPreview | null
|
||||
}
|
||||
|
||||
|
||||
export interface SupportMessage {
|
||||
id: string
|
||||
sender: 'user' | 'admin'
|
||||
text?: string
|
||||
createdAt: string
|
||||
createdAtRaw?: string | number | Date | null
|
||||
status?: ChatMessageStatus
|
||||
attachments?: ChatAttachment[]
|
||||
|
||||
// reply
|
||||
reply?: string | null
|
||||
replyTo?: ChatReplyPreview | null
|
||||
}
|
||||
|
||||
export interface SupportConversation {
|
||||
id: string
|
||||
userName: string
|
||||
avatar: string
|
||||
online: boolean
|
||||
unreadCount: number
|
||||
lastSeenText: string
|
||||
lastMessagePreview: string
|
||||
updatedAtText: string
|
||||
messages: SupportMessage[]
|
||||
}
|
||||
@@ -70,6 +70,12 @@ const router = createRouter({
|
||||
name: 'about',
|
||||
component: () => import('@/views/base/about/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'support',
|
||||
name: 'support',
|
||||
meta: { hideBottomNav: true },
|
||||
component: () => import('@/views/support/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifyTrigger',
|
||||
name: 'notifyTrigger',
|
||||
|
||||
+1576
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -113,6 +113,27 @@ function isToken() {
|
||||
return !!token
|
||||
}
|
||||
|
||||
function createUuid() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join('-')
|
||||
}
|
||||
return `${Date.now()}-${Math.floor(Math.random() * 1e9)}`
|
||||
}
|
||||
|
||||
export {
|
||||
getToken,
|
||||
setToken,
|
||||
@@ -123,5 +144,6 @@ export {
|
||||
removeFingerPrint,
|
||||
getIsActiveFingerprint,
|
||||
setFingerPrint,
|
||||
isToken
|
||||
isToken,
|
||||
createUuid
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
>
|
||||
<Icon icon="vuesax-linear:menu" size="18" />
|
||||
<span
|
||||
v-if="notificationEnabled && unreadCount > 0"
|
||||
v-if="menuBadgeCount > 0"
|
||||
class="mhome-header-menu__badge"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
{{ menuBadgeCount > 99 ? '99+' : menuBadgeCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -73,6 +73,22 @@
|
||||
<span>{{ canNativeInstall ? 'نصب اپلیکیشن' : 'آموزش نصب' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="chatEnabled"
|
||||
type="button"
|
||||
class="mhome-header-menu__item"
|
||||
role="menuitem"
|
||||
@click="goSupportChat"
|
||||
>
|
||||
<span class="mhome-header-btn__icon-wrap">
|
||||
<Icon icon="vuesax-linear:messages-2" size="16" />
|
||||
<span v-if="chatUnreadCount > 0" class="mhome-header-btn__badge">
|
||||
{{ chatUnreadCount > 99 ? '99+' : chatUnreadCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span>چت پشتیبانی</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mhome-header-menu__item mhome-header-menu__item--danger"
|
||||
@@ -90,9 +106,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { nextTick, onActivated, onMounted, onUnmounted, ref, watch, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useAppNotificationBadge } from '@/composables/useAppNotificationBadge'
|
||||
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||
import { PWA_MANIFEST_READY_EVENT } from '@/modules/pwa/manifest'
|
||||
@@ -106,6 +123,7 @@ import {
|
||||
} from '@/utils/browserNotification'
|
||||
import router from '@/router'
|
||||
import Icon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { unreadSupportCount, isUserSupportChatEnabled } from '@/utils/chat'
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
@@ -119,11 +137,19 @@ const canNativeInstall = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const chatEnabled = computed(() => isUserSupportChatEnabled())
|
||||
const chatUnreadCount = computed(() => unreadSupportCount.value)
|
||||
const {
|
||||
enabled: notificationEnabled,
|
||||
unreadCount,
|
||||
clearUnreadLocally,
|
||||
} = useAppNotificationBadge()
|
||||
const menuBadgeCount = computed(() => {
|
||||
const notifications = notificationEnabled.value ? Number(unreadCount.value) || 0 : 0
|
||||
const chat = chatEnabled.value ? Number(chatUnreadCount.value) || 0 : 0
|
||||
return notifications + chat
|
||||
})
|
||||
const { tryInstallOrOpenGuide, showInstallEntry } = usePwaInstall()
|
||||
|
||||
function refreshCanInstall() {
|
||||
@@ -143,6 +169,11 @@ async function goNotifications() {
|
||||
void router.push({ name: 'appNotifications' })
|
||||
}
|
||||
|
||||
function goSupportChat() {
|
||||
closeMenu()
|
||||
void router.push({ name: 'support' })
|
||||
}
|
||||
|
||||
async function onInstall() {
|
||||
closeMenu()
|
||||
await tryInstallOrOpenGuide()
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useUserStore } from '@/store/userStore'
|
||||
import { useWalletStore } from '@/store/walletStore'
|
||||
import { useTrackingSystemsFeature } from '@/composables/useTrackingSystemsFeature'
|
||||
import { useSettingsStore } from '@/store/settingsStore'
|
||||
import { useSupportMessenger } from '@/composables/useSupportMessenger'
|
||||
import { isUserSupportChatEnabled, unreadSupportCount } from '@/utils/chat'
|
||||
|
||||
export interface UserMenuAction {
|
||||
id: string
|
||||
@@ -22,6 +24,7 @@ export interface UserMenuAction {
|
||||
icon: string
|
||||
onClick: () => void
|
||||
visible?: boolean
|
||||
badge?: number
|
||||
}
|
||||
|
||||
export interface UserPageContext {
|
||||
@@ -70,6 +73,7 @@ export function useUserPage() {
|
||||
const showLogoutConfirm = ref(false)
|
||||
const { trackingSystemsEnabled } = useTrackingSystemsFeature()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { openSupportMessenger } = useSupportMessenger()
|
||||
|
||||
const isLegal = computed(() => {
|
||||
const result = { title: '', color: '', icon: '' }
|
||||
@@ -114,6 +118,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: 'recoverySites',
|
||||
label: 'سایتهای ریگیری',
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
>
|
||||
<span class="duser-actions__icon">
|
||||
<BasicIcon :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 class="duser-actions__chevron" icon="vuesax-linear:arrow-left" size="16" />
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -248,6 +249,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__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 {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
<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
|
||||
:disabled="disabled || isRecording || (!modelValue.trim() && attachments.length === 0)"
|
||||
class="support-send-btn !h-12 !w-12 shrink-0 items-center justify-center rounded-2xl transition hover:scale-[1.03] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@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: sticky;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: color-mix(in srgb, var(--bg, #fff) 92%, transparent);
|
||||
border-top: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
|
||||
box-shadow: 0 -8px 28px rgb(15 23 42 / 0.04);
|
||||
backdrop-filter: blur(14px);
|
||||
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(--bg2, #f4f6f8);
|
||||
border: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.1);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--colorPrimary, #2563eb) !important;
|
||||
color: var(--color6, #fff) !important;
|
||||
box-shadow: 0 8px 18px rgb(var(--colorPrimaryRGB, 37 99 235) / 0.28);
|
||||
}
|
||||
|
||||
.support-send-btn:disabled {
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.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: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<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">
|
||||
<svg
|
||||
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 BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.support-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid rgb(var(--colorPrimaryRGB, 37 99 235) / 0.16);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(var(--colorPrimaryRGB, 37 99 235) / 0.08),
|
||||
rgb(var(--colorPrimaryRGB, 37 99 235) / 0.03)
|
||||
),
|
||||
var(--bg, #fff);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.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;
|
||||
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__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;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,702 @@
|
||||
<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'
|
||||
|
||||
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)
|
||||
|
||||
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: var(--bg, #fff);
|
||||
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>
|
||||
Reference in New Issue
Block a user