first commit
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
||||
<template #default="{ close }">
|
||||
<div class="ticket-details">
|
||||
<LineTitleBlock title="جزئیات تیکت" title-en="Ticket Details" />
|
||||
|
||||
<div v-if="ticket" class="ticket-details__date">
|
||||
<span>{{ ticketDate }}</span>
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !ticket" :rows="3" :cols-per-row="1" />
|
||||
<div v-else-if="messages.length > 0" class="ticket-details__thread" ref="thread">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="ticket-details__row"
|
||||
:class="`ticket-details__row--${senderClass(message)}`"
|
||||
>
|
||||
<div class="ticket-details__bubble">
|
||||
<p class="ticket-details__text">{{ message.text }}</p>
|
||||
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NoItems v-else title="پیامی نیست" desc="هنوز پیامی در این تیکت ثبت نشده است." />
|
||||
|
||||
<div class="ticket-details__divider" />
|
||||
|
||||
<div v-if="attachment" class="ticket-details__attachment">
|
||||
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
|
||||
<span class="ticket-details__attachment-name">{{ attachment.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ticket-details__attachment-remove"
|
||||
aria-label="حذف فایل"
|
||||
@click="removeAttachment"
|
||||
>
|
||||
<SvgIcon name="close" :size="14" color="var(--color-error)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="ticket-details__compose" @submit.prevent="onSend">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canSend"
|
||||
class="ticket-details__send"
|
||||
aria-label="ارسال"
|
||||
>
|
||||
<SvgIcon name="paper-plane-right" :size="22" color="var(--color-primary)" />
|
||||
</button>
|
||||
<input
|
||||
v-model="text"
|
||||
type="text"
|
||||
placeholder="ارسال پیام"
|
||||
class="ticket-details__input"
|
||||
/>
|
||||
<div class="ticket-details__compose-actions">
|
||||
<button
|
||||
ref="emojiButton"
|
||||
type="button"
|
||||
class="ticket-details__compose-btn"
|
||||
:class="{ 'ticket-details__compose-btn--active': emojiOpen }"
|
||||
aria-label="ایموجی"
|
||||
@click="toggleEmoji"
|
||||
>
|
||||
<SvgIcon name="mood" :size="30" color="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ticket-details__compose-btn"
|
||||
aria-label="پیوست فایل"
|
||||
@click="triggerFilePicker"
|
||||
>
|
||||
<SvgIcon name="attach-file" :size="30" color="currentColor" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="ticket-details__file-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</BasicModal>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="emoji-pop">
|
||||
<div v-if="emojiOpen" ref="emojiPanel" class="ticket-emoji-panel" :style="emojiStyle">
|
||||
<button
|
||||
v-for="emoji in EMOJIS"
|
||||
:key="emoji"
|
||||
type="button"
|
||||
class="ticket-emoji-panel__emoji"
|
||||
@click="insertEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset as offsetMiddleware,
|
||||
shift,
|
||||
} from '@floating-ui/dom'
|
||||
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import {
|
||||
adminTicketsKeys,
|
||||
useAdminTicketQuery,
|
||||
useSendAdminTicketMessageMutation,
|
||||
} from '@/services/query/admin-tickets'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
|
||||
defineOptions({ name: 'TicketDetailsModal' })
|
||||
|
||||
const EMOJIS = [
|
||||
'😀',
|
||||
'😂',
|
||||
'😍',
|
||||
'🤔',
|
||||
'👍',
|
||||
'👎',
|
||||
'🙏',
|
||||
'👏',
|
||||
'🎉',
|
||||
'🔥',
|
||||
'✨',
|
||||
'⭐',
|
||||
'✅',
|
||||
'❌',
|
||||
'❤️',
|
||||
'💔',
|
||||
'😊',
|
||||
'😎',
|
||||
'😢',
|
||||
'😡',
|
||||
'🤝',
|
||||
'👌',
|
||||
'💡',
|
||||
'📌',
|
||||
]
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('TicketDetailsModal')?.data ?? {})
|
||||
const ticketId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
||||
enabled: () => !!ticketId.value,
|
||||
})
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const ticketDate = computed(
|
||||
() => ticket.value?.faCreatedAt || formatJalaaliDate(ticket.value?.createdAt) || '—'
|
||||
)
|
||||
|
||||
const senderClass = (message) => (message.sender === 'admin' ? 'admin' : 'user')
|
||||
|
||||
const messageTime = (message) => message.time || message.faSentAt || message.sentAt || '—'
|
||||
|
||||
const text = ref('')
|
||||
const attachment = ref(null)
|
||||
const canSend = computed(() => text.value.trim().length > 0 || !!attachment.value)
|
||||
|
||||
const fileInput = ref(null)
|
||||
const triggerFilePicker = () => fileInput.value?.click()
|
||||
const onFileSelected = (event) => {
|
||||
const file = event.target?.files?.[0]
|
||||
if (file) attachment.value = file
|
||||
if (event.target) event.target.value = ''
|
||||
}
|
||||
const removeAttachment = () => {
|
||||
attachment.value = null
|
||||
}
|
||||
|
||||
const emojiOpen = ref(false)
|
||||
const emojiButton = ref(null)
|
||||
const emojiPanel = ref(null)
|
||||
const emojiStyle = ref({ position: 'fixed', top: '0', left: '0' })
|
||||
let emojiCleanup = null
|
||||
|
||||
const toggleEmoji = () => {
|
||||
emojiOpen.value = !emojiOpen.value
|
||||
}
|
||||
const insertEmoji = (emoji) => {
|
||||
text.value += emoji
|
||||
}
|
||||
|
||||
const mountEmoji = async () => {
|
||||
await nextTick()
|
||||
if (!emojiButton.value || !emojiPanel.value) return
|
||||
emojiCleanup = autoUpdate(emojiButton.value, emojiPanel.value, () => {
|
||||
computePosition(emojiButton.value, emojiPanel.value, {
|
||||
placement: 'top-end',
|
||||
strategy: 'fixed',
|
||||
middleware: [offsetMiddleware(8), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
emojiStyle.value = { position: 'fixed', top: `${y}px`, left: `${x}px` }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const unmountEmoji = () => {
|
||||
emojiCleanup?.()
|
||||
emojiCleanup = null
|
||||
}
|
||||
|
||||
watch(emojiOpen, (open) => {
|
||||
if (open) mountEmoji()
|
||||
else unmountEmoji()
|
||||
})
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (!emojiOpen.value) return
|
||||
if (emojiPanel.value?.contains(event.target)) return
|
||||
if (emojiButton.value?.contains(event.target)) return
|
||||
emojiOpen.value = false
|
||||
}
|
||||
document.addEventListener('mousedown', onDocumentClick)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onDocumentClick)
|
||||
unmountEmoji()
|
||||
})
|
||||
|
||||
const sendMutation = useSendAdminTicketMessageMutation()
|
||||
|
||||
const onSend = async () => {
|
||||
if (!canSend.value || !ticketId.value) return
|
||||
const value = text.value.trim()
|
||||
const file = attachment.value
|
||||
text.value = ''
|
||||
attachment.value = null
|
||||
emojiOpen.value = false
|
||||
const payload = file ? objectToFormData({ text: value, attachment: file }) : { text: value }
|
||||
await sendMutation.mutateAsync({ id: ticketId.value, payload })
|
||||
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
|
||||
}
|
||||
|
||||
const thread = ref(null)
|
||||
|
||||
watch(messages, async () => {
|
||||
await nextTick()
|
||||
if (thread.value) {
|
||||
thread.value.scrollTop = thread.value.scrollHeight
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ticket-details {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 8rem);
|
||||
min-height: 28rem;
|
||||
|
||||
&__date {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.5rem 0 0.875rem;
|
||||
}
|
||||
|
||||
&__date span {
|
||||
background: #f7f7f7;
|
||||
color: #9c9c9c;
|
||||
border-radius: 9999px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__thread {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.5rem 1rem;
|
||||
padding-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
|
||||
&--admin {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
max-width: 72%;
|
||||
border-radius: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
|
||||
.ticket-details__row--admin & {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
||||
.ticket-details__row--user & {
|
||||
background: #f8f8f8;
|
||||
color: #5d5d5d;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.7rem;
|
||||
|
||||
.ticket-details__row--admin & {
|
||||
color: rgba(255, 255, 255, 80%);
|
||||
}
|
||||
|
||||
.ticket-details__row--user & {
|
||||
color: #b1b1b1;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
border-block-end: 1px solid var(--color-thd-gray);
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
&__compose {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__send {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover:enabled {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.875rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-thd-gray);
|
||||
}
|
||||
}
|
||||
|
||||
&__compose-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__compose-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #d1d1d1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 4%);
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
background: rgba(0, 0, 0, 4%);
|
||||
}
|
||||
}
|
||||
|
||||
&__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #f7f7f7;
|
||||
border-radius: 9999px;
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: #4b4b4b;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
&__attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__attachment-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket-emoji-panel {
|
||||
width: 17rem;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 10px 25px -8px rgba(0, 0, 0, 12%), 0 4px 10px -4px rgba(0, 0, 0, 8%);
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 0.125rem;
|
||||
z-index: 1000;
|
||||
|
||||
&__emoji {
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-pop-enter-active,
|
||||
.emoji-pop-leave-active {
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.emoji-pop-enter-from,
|
||||
.emoji-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user