826 lines
22 KiB
Vue
826 lines
22 KiB
Vue
<!--
|
||||
|
|
Copyright 2024-2026 ChatterMate
|
|||
|
|
|
|||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|||
|
|
you may not use this file except in compliance with the License.
|
|||
|
|
You may obtain a copy of the License at
|
|||
|
|
|
|||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|||
|
|
|
|||
|
|
Unless required by applicable law or agreed to in writing, software
|
|||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
|
|
See the License for the specific language governing permissions and
|
|||
|
|
limitations under the License.
|
|||
|
|
-->
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed } from 'vue'
|
|||
|
|
import type { ChatDetail } from '@/types/chat'
|
|||
|
|
import { chatService } from '@/services/chat'
|
|||
|
|
import { userService } from '@/services/user'
|
|||
|
|
import { socketService } from '@/services/socket'
|
|||
|
|
import { toast } from 'vue-sonner'
|
|||
|
|
import LinkedTicketCard from '@/components/tickets/LinkedTicketCard.vue'
|
|||
|
|
import { permissionChecks } from '@/utils/permissions'
|
|||
|
|
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
|
|||
|
|
import { getInitials } from '@/utils/text'
|
|||
|
|
import type { Teammate } from '@/services/users'
|
|||
|
|
import { canTakeOverChat, chatAssignee, chatHandler } from '@/utils/chatState'
|
|||
|
|
import { routeChatToHuman } from '@/utils/chatActions'
|
|||
|
|
|
|||
|
|
const canViewTickets = permissionChecks.canViewTickets()
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
chatInfo: ChatDetail | null
|
|||
|
|
users: Teammate[]
|
|||
|
|
isLoading?: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Emits {
|
|||
|
|
(e: 'close'): void
|
|||
|
|
(e: 'refresh'): void
|
|||
|
|
(e: 'chatUpdated', chatInfo: ChatDetail): void
|
|||
|
|
(e: 'chatClosed', sessionId: string): void
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = defineProps<Props>()
|
|||
|
|
const emit = defineEmits<Emits>()
|
|||
|
|
|
|||
|
|
const showEndChatConfirm = ref(false)
|
|||
|
|
const actionLoading = ref(false)
|
|||
|
|
const reassigning = ref(false)
|
|||
|
|
const showReassign = ref(false)
|
|||
|
|
const selectedUserId = ref('')
|
|||
|
|
// Reassigning is a manage-chat action; the parent supplies the teammate list.
|
|||
|
|
const canReassign = permissionChecks.canTakeOverChats()
|
|||
|
|
|
|||
|
|
// Chat action functions
|
|||
|
|
const currentUserId = userService.getUserId()
|
|||
|
|
|
|||
|
|
// Only the web widget can render a rating; on external channels (WhatsApp,
|
|||
|
|
// Messenger, ...) the ask is dropped, so the confirmation must not promise it.
|
|||
|
|
const askRating = computed(() => canRequestRating(props.chatInfo?.channel))
|
|||
|
|
|
|||
|
|
// Shared with the chat pane so both surfaces offer takeover on the same rule.
|
|||
|
|
// This drops main's `status === 'transferred' && user_id !== currentUserId`
|
|||
|
|
// arm deliberately: takeover_session returns False once user_id is set, so
|
|||
|
|
// that arm only ever rendered a button that failed on click.
|
|||
|
|
const canTakeOver = computed(
|
|||
|
|
() => canTakeOverChat(props.chatInfo) && permissionChecks.canTakeOverChats()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Only worth offering while the AI still has it: once a chat is queued or
|
|||
|
|
// claimed there is no AI left to stop.
|
|||
|
|
const canRouteToHuman = computed(
|
|||
|
|
() => chatHandler(props.chatInfo, currentUserId).kind === 'ai'
|
|||
|
|
&& permissionChecks.canTakeOverChats()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const canEndChat = computed(() => {
|
|||
|
|
if (!props.chatInfo) return false
|
|||
|
|
return (
|
|||
|
|
props.chatInfo.status !== 'closed' &&
|
|||
|
|
props.chatInfo.user_id === currentUserId
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const handleRouteToHuman = async () => {
|
|||
|
|
if (!props.chatInfo) return
|
|||
|
|
try {
|
|||
|
|
actionLoading.value = true
|
|||
|
|
const updated = await routeChatToHuman(props.chatInfo.session_id)
|
|||
|
|
if (!updated) return
|
|||
|
|
emit('chatUpdated', updated)
|
|||
|
|
emit('refresh')
|
|||
|
|
} finally {
|
|||
|
|
actionLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleTakeover = async () => {
|
|||
|
|
if (!props.chatInfo) return
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
actionLoading.value = true
|
|||
|
|
await chatService.takeoverChat(props.chatInfo.session_id)
|
|||
|
|
|
|||
|
|
toast.success('已接管会话', {
|
|||
|
|
description: '现在可以在本会话中发送消息',
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const userName = userService.getUserName()
|
|||
|
|
const userId = userService.getUserId()
|
|||
|
|
|
|||
|
|
// Update local chat info state
|
|||
|
|
const updatedChatInfo = {
|
|||
|
|
...props.chatInfo,
|
|||
|
|
status: 'open' as const,
|
|||
|
|
user_id: userId,
|
|||
|
|
user_name: userName
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
socketService.emit('taken_over', {
|
|||
|
|
session_id: props.chatInfo.session_id,
|
|||
|
|
user_name: userName,
|
|||
|
|
profile_picture: userService.getCurrentUser()?.profile_pic || ''
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Emit updated chat info to parent components
|
|||
|
|
emit('chatUpdated', updatedChatInfo)
|
|||
|
|
|
|||
|
|
// Refresh conversations list
|
|||
|
|
emit('refresh')
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('Failed to takeover chat:', err)
|
|||
|
|
toast.error('接管会话失败', {
|
|||
|
|
description: err.response?.data?.detail || '请重试',
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} finally {
|
|||
|
|
actionLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleEndChatRequest = () => {
|
|||
|
|
showEndChatConfirm.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const confirmEndChat = async () => {
|
|||
|
|
if (!props.chatInfo) return
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
actionLoading.value = true
|
|||
|
|
|
|||
|
|
const timestamp = new Date().toISOString()
|
|||
|
|
const endChatMessage = {
|
|||
|
|
message: endChatMessageFor(props.chatInfo.channel),
|
|||
|
|
message_type: "system",
|
|||
|
|
created_at: timestamp,
|
|||
|
|
session_id: props.chatInfo.session_id,
|
|||
|
|
end_chat: true,
|
|||
|
|
request_rating: askRating.value,
|
|||
|
|
end_chat_reason: "AGENT_REQUEST",
|
|||
|
|
end_chat_description: "Agent manually ended the chat"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Emit message through socket to end chat
|
|||
|
|
socketService.emit('agent_message', {
|
|||
|
|
message: endChatMessage.message,
|
|||
|
|
session_id: props.chatInfo.session_id,
|
|||
|
|
message_type: endChatMessage.message_type,
|
|||
|
|
created_at: timestamp,
|
|||
|
|
end_chat: true,
|
|||
|
|
request_rating: askRating.value,
|
|||
|
|
end_chat_reason: "AGENT_REQUEST",
|
|||
|
|
end_chat_description: "Agent manually ended the chat"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
toast.success('会话已结束', {
|
|||
|
|
description: askRating.value ? '将邀请客户进行评价' : '会话已关闭',
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
showEndChatConfirm.value = false
|
|||
|
|
|
|||
|
|
// Refresh conversations list
|
|||
|
|
emit('refresh')
|
|||
|
|
|
|||
|
|
// Inform parent to close chat views
|
|||
|
|
emit('chatClosed', props.chatInfo.session_id)
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('Failed to end chat:', err)
|
|||
|
|
toast.error('结束会话失败', {
|
|||
|
|
description: err.response?.data?.detail || '请重试',
|
|||
|
|
duration: 4000,
|
|||
|
|
closeButton: true
|
|||
|
|
})
|
|||
|
|
} finally {
|
|||
|
|
actionLoading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cancelEndChat = () => {
|
|||
|
|
showEndChatConfirm.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Who holds the chat, sharing chatAssignee with the inbox badge and the chat
|
|||
|
|
// pane so all three agree. Only the unassigned wording is panel-specific: it
|
|||
|
|
// names the AI agent rather than saying a flat "AI".
|
|||
|
|
const assignedTo = computed(
|
|||
|
|
() => chatAssignee(props.chatInfo, currentUserId) || props.chatInfo?.agent?.name || 'AI 客服'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Helper function to format date
|
|||
|
|
const formatDate = (dateString: string): string => {
|
|||
|
|
const date = new Date(dateString)
|
|||
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Backend status enum stays English in the API; only the label is localised.
|
|||
|
|
const statusLabels: Record<string, string> = {
|
|||
|
|
open: '进行中',
|
|||
|
|
closed: '已结束',
|
|||
|
|
transferred: '已转交',
|
|||
|
|
pending: '等待中'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusLabel = (status: string) => statusLabels[status] || status
|
|||
|
|
|
|||
|
|
// Integrator-supplied fields (e.g. student_name, center_name) passed via
|
|||
|
|
// POST /generate-token's `custom_data`. Shown as a label/value list below the
|
|||
|
|
// customer's name and email; snake_case keys are prettified for display.
|
|||
|
|
const metaDataEntries = computed(() => {
|
|||
|
|
const metaData = props.chatInfo?.customer?.meta_data
|
|||
|
|
if (!metaData || typeof metaData !== 'object') return []
|
|||
|
|
return Object.entries(metaData)
|
|||
|
|
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
|||
|
|
.map(([key, value]) => ({
|
|||
|
|
key,
|
|||
|
|
label: key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|||
|
|
value: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|||
|
|
}))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Initials for the mobile hero avatar
|
|||
|
|
const customerInitials = computed(() =>
|
|||
|
|
getInitials(props.chatInfo?.customer?.full_name || props.chatInfo?.customer?.email)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// The teammate list arrives as a prop (ConversationsView loads it once from
|
|||
|
|
// /users/teammates). This component used to fetch /users itself into a local
|
|||
|
|
// ref that shadowed the prop — which needed manage_users, so for an agent the
|
|||
|
|
// dropdown was always empty even though the API would have allowed the
|
|||
|
|
// reassign.
|
|||
|
|
const openReassign = () => {
|
|||
|
|
showReassign.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const cancelReassign = () => {
|
|||
|
|
showReassign.value = false
|
|||
|
|
selectedUserId.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const confirmReassign = async () => {
|
|||
|
|
if (!props.chatInfo || !selectedUserId.value) return
|
|||
|
|
try {
|
|||
|
|
reassigning.value = true
|
|||
|
|
const updated = await chatService.reassignChat(props.chatInfo.session_id, selectedUserId.value)
|
|||
|
|
toast.success('会话已重新分配')
|
|||
|
|
emit('chatUpdated', updated)
|
|||
|
|
emit('refresh')
|
|||
|
|
showReassign.value = false
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('Failed to reassign chat:', err)
|
|||
|
|
toast.error('重新分配失败', { description: err.response?.data?.detail || '请重试' })
|
|||
|
|
} finally {
|
|||
|
|
reassigning.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div v-if="chatInfo" class="chat-info-sidebar">
|
|||
|
|
<div class="chat-info-header">
|
|||
|
|
<h3>会话信息</h3>
|
|||
|
|
<button @click="emit('close')" class="close-btn" aria-label="关闭会话信息">
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|||
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|||
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="chat-info-content">
|
|||
|
|
<!-- Mobile-only hero per the app design; hidden on desktop -->
|
|||
|
|
<div class="customer-hero">
|
|||
|
|
<div class="customer-avatar">{{ customerInitials }}</div>
|
|||
|
|
<div class="customer-hero-name">{{ chatInfo.customer.full_name || chatInfo.customer.email || '客户' }}</div>
|
|||
|
|
<div v-if="chatInfo.customer.email" class="customer-hero-email">{{ chatInfo.customer.email }}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="info-section">
|
|||
|
|
<h4>客户</h4>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">姓名:</span>
|
|||
|
|
<span class="value">{{ chatInfo.customer.full_name || '暂无' }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">邮箱:</span>
|
|||
|
|
<span class="value">{{ chatInfo.customer.email || '暂无' }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-for="entry in metaDataEntries" :key="entry.key" class="info-item">
|
|||
|
|
<span class="label">{{ entry.label }}:</span>
|
|||
|
|
<span class="value">{{ entry.value }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="canViewTickets && chatInfo.session_id" class="info-section">
|
|||
|
|
<LinkedTicketCard :session-id="chatInfo.session_id" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="info-section">
|
|||
|
|
<h4>分配</h4>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">分配给:</span>
|
|||
|
|
<span class="value">{{ assignedTo }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="info-section">
|
|||
|
|
<h4>时间线</h4>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">创建时间:</span>
|
|||
|
|
<span class="value">{{ formatDate(chatInfo.created_at) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">更新时间:</span>
|
|||
|
|
<span class="value">{{ formatDate(chatInfo.updated_at) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="info-section">
|
|||
|
|
<h4>状态</h4>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">当前状态:</span>
|
|||
|
|
<span class="value status-badge" :class="chatInfo.status">
|
|||
|
|
{{ statusLabel(chatInfo.status) }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="info-item">
|
|||
|
|
<span class="label">消息数:</span>
|
|||
|
|
<span class="value">{{ chatInfo.messages.length }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Chat Actions Section -->
|
|||
|
|
<div class="info-section">
|
|||
|
|
<h4>操作</h4>
|
|||
|
|
<div class="chat-actions">
|
|||
|
|
<button
|
|||
|
|
v-if="canTakeOver"
|
|||
|
|
class="action-btn takeover-btn"
|
|||
|
|
:disabled="actionLoading"
|
|||
|
|
@click="handleTakeover"
|
|||
|
|
>
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|||
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|||
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|||
|
|
</svg>
|
|||
|
|
{{ actionLoading ? '接管中…' : '接管会话' }}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<!-- Stops the AI without making the chat yours. -->
|
|||
|
|
<button
|
|||
|
|
v-if="canRouteToHuman"
|
|||
|
|
class="action-btn"
|
|||
|
|
:disabled="actionLoading"
|
|||
|
|
@click="handleRouteToHuman"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-user-group" />
|
|||
|
|
转给团队
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-if="canEndChat"
|
|||
|
|
class="action-btn end-chat-btn"
|
|||
|
|
:disabled="actionLoading"
|
|||
|
|
@click="handleEndChatRequest"
|
|||
|
|
>
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|||
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
|||
|
|
<polyline points="16,17 21,12 16,7"/>
|
|||
|
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
|||
|
|
</svg>
|
|||
|
|
结束会话
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-if="canReassign && chatInfo.status === 'open' && chatInfo.user_id && users.length"
|
|||
|
|
class="action-btn"
|
|||
|
|
:disabled="reassigning"
|
|||
|
|
@click="openReassign"
|
|||
|
|
>
|
|||
|
|
重新分配
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- End Chat Confirmation Modal -->
|
|||
|
|
<div v-if="showEndChatConfirm" class="end-chat-modal">
|
|||
|
|
<div class="end-chat-modal-content">
|
|||
|
|
<h3>结束会话</h3>
|
|||
|
|
<p v-if="askRating">确定要结束此会话并邀请客户评价吗?</p>
|
|||
|
|
<p v-else>确定要结束此会话吗?</p>
|
|||
|
|
<div class="end-chat-modal-actions">
|
|||
|
|
<button class="cancel-btn" @click="cancelEndChat">取消</button>
|
|||
|
|
<button class="confirm-btn" @click="confirmEndChat" :disabled="actionLoading">
|
|||
|
|
{{ actionLoading ? '结束中…' : (askRating ? '结束会话并请求评价' : '结束会话') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Reassign Modal -->
|
|||
|
|
<div v-if="showReassign" class="end-chat-modal">
|
|||
|
|
<div class="end-chat-modal-content">
|
|||
|
|
<h3>重新分配会话</h3>
|
|||
|
|
<p>选择要分配此会话的用户。</p>
|
|||
|
|
<div style="margin-bottom: 12px;">
|
|||
|
|
<select v-model="selectedUserId" class="filter-input" style="width: 100%;">
|
|||
|
|
<option value="" disabled>选择用户</option>
|
|||
|
|
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.full_name }} ({{ u.email }})</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="end-chat-modal-actions">
|
|||
|
|
<button class="cancel-btn" @click="cancelReassign">取消</button>
|
|||
|
|
<button class="confirm-btn" :disabled="!selectedUserId || reassigning" @click="confirmReassign">
|
|||
|
|
{{ reassigning ? '分配中…' : '确认分配' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.chat-info-sidebar {
|
|||
|
|
background: var(--bg2);
|
|||
|
|
border-left: 1px solid var(--o08);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
animation: slideInRight 0.3s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes slideInRight {
|
|||
|
|
from {
|
|||
|
|
transform: translateX(100%);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
transform: translateX(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: var(--space-md) var(--space-lg);
|
|||
|
|
border-bottom: 1px solid var(--o07);
|
|||
|
|
background: var(--bg2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-header h3 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close-btn {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
color: var(--muted);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.close-btn:hover {
|
|||
|
|
background: var(--o10);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-content {
|
|||
|
|
padding: var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mobile-only customer hero (hidden on desktop) */
|
|||
|
|
.customer-hero {
|
|||
|
|
display: none;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
text-align: center;
|
|||
|
|
padding: var(--space-sm) 0 var(--space-lg);
|
|||
|
|
border-bottom: 1px solid var(--o07);
|
|||
|
|
margin-bottom: var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customer-avatar {
|
|||
|
|
width: 84px;
|
|||
|
|
height: 84px;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: var(--grad-purple-teal);
|
|||
|
|
color: var(--on-light);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 30px;
|
|||
|
|
font-weight: var(--font-weight-bold);
|
|||
|
|
margin-bottom: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customer-hero-name {
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 22px;
|
|||
|
|
font-weight: var(--font-weight-bold);
|
|||
|
|
letter-spacing: var(--tracking-display);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.customer-hero-email {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-section {
|
|||
|
|
margin-bottom: var(--space-lg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-section:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-section h4 {
|
|||
|
|
margin: 0 0 var(--space-md) 0;
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10.5px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--faint);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
border-bottom: 1px solid var(--o07);
|
|||
|
|
padding-bottom: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
margin-bottom: var(--space-sm);
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item .label {
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: var(--muted);
|
|||
|
|
min-width: 80px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-item .value {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--text);
|
|||
|
|
text-align: right;
|
|||
|
|
word-break: break-word;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge {
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
border-radius: var(--radius-full);
|
|||
|
|
font-size: 10px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Higher specificity than ".info-item .value" so the pill hugs the status text
|
|||
|
|
instead of inheriting .value's flex:1 and stretching its background across
|
|||
|
|
the rest of the row. */
|
|||
|
|
.info-item .value.status-badge {
|
|||
|
|
flex: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.open {
|
|||
|
|
background: rgba(201,242,78,.12);
|
|||
|
|
color: var(--accent-ink);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.closed {
|
|||
|
|
background: var(--o06);
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-badge.transferred {
|
|||
|
|
background: var(--warning-bg);
|
|||
|
|
color: var(--warning-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Chat Actions */
|
|||
|
|
.chat-actions {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
padding: var(--space-sm) var(--space-md);
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
color: var(--text3);
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
text-align: left;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn:hover:not(:disabled) {
|
|||
|
|
background: var(--o10);
|
|||
|
|
border-color: var(--o14);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-btn {
|
|||
|
|
border-color: rgba(201,242,78,.3);
|
|||
|
|
color: var(--accent-ink);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-btn:hover:not(:disabled) {
|
|||
|
|
background: rgba(201,242,78,.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-btn {
|
|||
|
|
border-color: rgba(255,138,115,.3);
|
|||
|
|
color: var(--c-coral);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-btn:hover:not(:disabled) {
|
|||
|
|
background: rgba(255,138,115,.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
/* End Chat Modal */
|
|||
|
|
.end-chat-modal {
|
|||
|
|
position: fixed;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.5);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 2000;
|
|||
|
|
animation: fadeIn 0.2s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes fadeIn {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-modal-content {
|
|||
|
|
background: var(--surface);
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
padding: var(--space-xl);
|
|||
|
|
max-width: 400px;
|
|||
|
|
width: 90%;
|
|||
|
|
box-shadow: var(--shadow-lg);
|
|||
|
|
animation: slideInUp 0.3s ease-out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes slideInUp {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
transform: translateY(20px);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-modal-content h3 {
|
|||
|
|
margin: 0 0 var(--space-md) 0;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-modal-content p {
|
|||
|
|
margin: 0 0 var(--space-lg) 0;
|
|||
|
|
color: var(--muted);
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.end-chat-modal-actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-btn, .confirm-btn {
|
|||
|
|
padding: var(--space-sm) var(--space-lg);
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
min-width: 80px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-btn {
|
|||
|
|
background: var(--o06);
|
|||
|
|
color: var(--text3);
|
|||
|
|
border-color: var(--o10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-btn:hover {
|
|||
|
|
background: var(--o10);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mobile: full-screen "Details" page (class applied by ConversationsView) */
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
z-index: var(--z-fullscreen-page);
|
|||
|
|
height: 100vh;
|
|||
|
|
height: 100dvh;
|
|||
|
|
border-left: none;
|
|||
|
|
background: var(--bg);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen .chat-info-header {
|
|||
|
|
padding-top: calc(var(--space-md) + var(--safe-top));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen .chat-info-content {
|
|||
|
|
flex: 1;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen .customer-hero {
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen .close-btn {
|
|||
|
|
width: 44px;
|
|||
|
|
height: 44px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-info-sidebar.mobile-fullscreen .action-btn {
|
|||
|
|
min-height: 48px;
|
|||
|
|
font-size: 15px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.confirm-btn {
|
|||
|
|
background: var(--error-color);
|
|||
|
|
color: white;
|
|||
|
|
border-color: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.confirm-btn:hover:not(:disabled) {
|
|||
|
|
filter: brightness(1.1);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.confirm-btn:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
transform: none;
|
|||
|
|
}
|
|||
|
|
</style>
|