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
@@ -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>