815 lines
20 KiB
Vue
815 lines
20 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 type { Conversation, ChatDetail } from '@/types/chat'
|
||
|
|
import ConversationChat from '@/components/conversations/ConversationChat.vue'
|
||
|
|
import ChannelBadge from '@/components/common/ChannelBadge.vue'
|
||
|
|
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
|
||
|
|
import { useConversationsList } from '@/composables/useConversationsList'
|
||
|
|
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||
|
|
import { chatHandler } from '@/utils/chatState'
|
||
|
|
import { userService } from '@/services/user'
|
||
|
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||
|
|
|
||
|
|
const props = defineProps<{
|
||
|
|
conversations: Conversation[]
|
||
|
|
loading: boolean
|
||
|
|
error: string
|
||
|
|
statusFilter: 'open' | 'closed'
|
||
|
|
hasMore: boolean
|
||
|
|
loadingMore: boolean
|
||
|
|
showChatInfo?: boolean
|
||
|
|
initialSessionId?: string | null
|
||
|
|
mobilePane?: 'list' | 'chat'
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
(e: 'refresh'): void
|
||
|
|
(e: 'chatUpdated', data: ChatDetail): void
|
||
|
|
(e: 'chatSelected', data: ChatDetail): void
|
||
|
|
(e: 'clearUnread', sessionId: string): void
|
||
|
|
(e: 'updateFilter', status: 'open' | 'closed'): void
|
||
|
|
(e: 'loadMore'): void
|
||
|
|
(e: 'select', sessionId: string): void
|
||
|
|
(e: 'back'): void
|
||
|
|
(e: 'info'): void
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const { isMobile } = useBreakpoint()
|
||
|
|
const currentUserId = userService.getUserId()
|
||
|
|
|
||
|
|
// The preview used to be prefixed with the AI agent's name unconditionally,
|
||
|
|
// which read as "the bot said this" even on a chat a colleague had taken over.
|
||
|
|
// Anonymous visitors have neither a name nor a real email (the backend hides the
|
||
|
|
// placeholder), so the row would otherwise show nothing at all.
|
||
|
|
const customerName = (conv: Conversation) =>
|
||
|
|
conv.customer?.full_name || conv.customer?.email || '访客'
|
||
|
|
|
||
|
|
// Display labels for the backend status enum, which stays in English in the API.
|
||
|
|
const statusLabels: Record<string, string> = {
|
||
|
|
open: '进行中',
|
||
|
|
closed: '已结束',
|
||
|
|
transferred: '已转交',
|
||
|
|
pending: '等待中'
|
||
|
|
}
|
||
|
|
|
||
|
|
const statusLabel = (status: string) => statusLabels[status] || status
|
||
|
|
|
||
|
|
const previewPrefix = (conv: Conversation) => {
|
||
|
|
const handler = chatHandler(conv, currentUserId)
|
||
|
|
return handler.kind === 'human' ? handler.label : conv.agent.name
|
||
|
|
}
|
||
|
|
|
||
|
|
const {
|
||
|
|
selectedChat,
|
||
|
|
selectedId,
|
||
|
|
chatLoading,
|
||
|
|
formattedConversations,
|
||
|
|
loadChatDetail,
|
||
|
|
unreadMessages,
|
||
|
|
clearUnread
|
||
|
|
} = useConversationsList(props, emit)
|
||
|
|
|
||
|
|
// Enhanced loadChatDetail to also emit chat-selected event
|
||
|
|
const loadChatDetailWithSelection = async (sessionId: string) => {
|
||
|
|
await loadChatDetail(sessionId)
|
||
|
|
// Emit the chat-selected event with the loaded chat detail
|
||
|
|
if (selectedChat.value) {
|
||
|
|
emit('chatSelected', selectedChat.value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// On mobile a tap routes through the URL (?session=) so back navigation works;
|
||
|
|
// the parent pushes the query, which flows back in via initialSessionId.
|
||
|
|
const handleConversationClick = (sessionId: string) => {
|
||
|
|
if (isMobile.value) {
|
||
|
|
emit('select', sessionId)
|
||
|
|
} else {
|
||
|
|
loadChatDetailWithSelection(sessionId)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Function to update the selected chat from parent
|
||
|
|
const updateSelectedChat = (updatedChat: ChatDetail) => {
|
||
|
|
console.log('updateSelectedChat', updatedChat)
|
||
|
|
if (selectedChat.value && selectedChat.value.session_id === updatedChat.session_id) {
|
||
|
|
selectedChat.value = updatedChat
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Function to clear the selected chat from parent
|
||
|
|
const clearSelectedChat = () => {
|
||
|
|
selectedChat.value = null
|
||
|
|
selectedId.value = null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Expose the function to parent component
|
||
|
|
defineExpose({
|
||
|
|
updateSelectedChat,
|
||
|
|
clearSelectedChat
|
||
|
|
})
|
||
|
|
|
||
|
|
// Intersection observer for infinite scrolling
|
||
|
|
const listContainer = ref<HTMLElement | null>(null)
|
||
|
|
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
||
|
|
let observer: IntersectionObserver | null = null
|
||
|
|
|
||
|
|
// Scroll to top functionality
|
||
|
|
const showScrollToTop = ref(false)
|
||
|
|
const scrollThreshold = 300 // Show button after scrolling down 300px
|
||
|
|
|
||
|
|
// Handle scroll event to show/hide scroll-to-top button
|
||
|
|
const handleScroll = () => {
|
||
|
|
if (listContainer.value) {
|
||
|
|
showScrollToTop.value = listContainer.value.scrollTop > scrollThreshold
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scroll to top function
|
||
|
|
const scrollToTop = () => {
|
||
|
|
if (listContainer.value) {
|
||
|
|
listContainer.value.scrollTo({
|
||
|
|
top: 0,
|
||
|
|
behavior: 'smooth'
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Flag to track filter changes
|
||
|
|
const filterChanged = ref(false)
|
||
|
|
|
||
|
|
// Watch for filter changes to set the flag
|
||
|
|
watch(() => props.statusFilter, () => {
|
||
|
|
filterChanged.value = true
|
||
|
|
})
|
||
|
|
|
||
|
|
// Watch for loading state changes to select first conversation after filter change
|
||
|
|
watch(() => props.loading, (isLoading, prevLoading) => {
|
||
|
|
// Only proceed if loading has just completed (was true, now false)
|
||
|
|
if (prevLoading === true && isLoading === false && filterChanged.value) {
|
||
|
|
// Reset the flag
|
||
|
|
filterChanged.value = false
|
||
|
|
|
||
|
|
// When loading completes after filter change, select the first conversation if
|
||
|
|
// available (desktop only — on mobile the list pane stays put until a tap)
|
||
|
|
if (props.conversations.length > 0 && !isMobile.value) {
|
||
|
|
loadChatDetailWithSelection(props.conversations[0].session_id)
|
||
|
|
} else {
|
||
|
|
// If no conversations available, clear the selected chat
|
||
|
|
selectedChat.value = null
|
||
|
|
selectedId.value = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Deep-link: open a specific session directly (from analytics "Sessions Needing
|
||
|
|
// Attention", mobile ?session= navigation, or a push notification). Fetches the
|
||
|
|
// chat detail by id, so it works even if the session isn't in the currently
|
||
|
|
// loaded/filtered list. Reacts to every change so in-app deep-links keep working.
|
||
|
|
watch(
|
||
|
|
() => props.initialSessionId,
|
||
|
|
(sessionId) => {
|
||
|
|
if (sessionId && sessionId !== selectedId.value) {
|
||
|
|
loadChatDetailWithSelection(sessionId)
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ immediate: true }
|
||
|
|
)
|
||
|
|
|
||
|
|
// Setup intersection observer for infinite scrolling
|
||
|
|
onMounted(() => {
|
||
|
|
setupIntersectionObserver()
|
||
|
|
|
||
|
|
// Add scroll event listener
|
||
|
|
if (listContainer.value) {
|
||
|
|
listContainer.value.addEventListener('scroll', handleScroll)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Watch for changes in the loadMoreTrigger ref
|
||
|
|
watch(loadMoreTrigger, (newValue) => {
|
||
|
|
if (newValue && observer) {
|
||
|
|
observer.observe(newValue)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Watch for changes in the listContainer ref to add scroll listener
|
||
|
|
watch(listContainer, (newValue) => {
|
||
|
|
if (newValue) {
|
||
|
|
newValue.addEventListener('scroll', handleScroll)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Setup intersection observer
|
||
|
|
const setupIntersectionObserver = () => {
|
||
|
|
if (window.IntersectionObserver) {
|
||
|
|
// Disconnect previous observer if it exists
|
||
|
|
if (observer) {
|
||
|
|
observer.disconnect()
|
||
|
|
}
|
||
|
|
|
||
|
|
observer = new IntersectionObserver((entries) => {
|
||
|
|
const entry = entries[0]
|
||
|
|
if (entry.isIntersecting && props.hasMore && !props.loadingMore) {
|
||
|
|
emit('loadMore')
|
||
|
|
}
|
||
|
|
}, { threshold: 0.5 })
|
||
|
|
|
||
|
|
if (loadMoreTrigger.value) {
|
||
|
|
observer.observe(loadMoreTrigger.value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cleanup intersection observer and event listeners
|
||
|
|
onBeforeUnmount(() => {
|
||
|
|
if (observer) {
|
||
|
|
observer.disconnect()
|
||
|
|
observer = null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove scroll event listener
|
||
|
|
if (listContainer.value) {
|
||
|
|
listContainer.value.removeEventListener('scroll', handleScroll)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="conversations-container"
|
||
|
|
:class="{ 'with-chat-info': showChatInfo, 'mobile-show-chat': mobilePane === 'chat' }">
|
||
|
|
<!-- Sidebar with conversation list -->
|
||
|
|
<div class="conversations-sidebar">
|
||
|
|
<!-- Filter controls -->
|
||
|
|
<div class="filter-controls">
|
||
|
|
<button
|
||
|
|
class="filter-btn"
|
||
|
|
:class="{ active: statusFilter === 'open' }"
|
||
|
|
@click="emit('updateFilter', 'open')"
|
||
|
|
>
|
||
|
|
进行中
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
class="filter-btn"
|
||
|
|
:class="{ active: statusFilter === 'closed' }"
|
||
|
|
@click="emit('updateFilter', 'closed')"
|
||
|
|
>
|
||
|
|
已结束
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="loading && !loadingMore" class="loading-state">
|
||
|
|
加载会话中…
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="error" class="error-state">
|
||
|
|
{{ error }}
|
||
|
|
<button @click="emit('refresh')" class="refresh-button">重试</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="conversations.length === 0" class="empty-state">
|
||
|
|
暂无会话
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="conversations-list" ref="listContainer" tabindex="0" aria-label="会话列表">
|
||
|
|
<div
|
||
|
|
v-for="conv in formattedConversations"
|
||
|
|
:key="conv.session_id"
|
||
|
|
class="conversation-item"
|
||
|
|
:class="{ active: selectedId === conv.session_id }"
|
||
|
|
role="button"
|
||
|
|
tabindex="0"
|
||
|
|
:aria-label="`与 ${customerName(conv)} 的会话`"
|
||
|
|
:aria-current="selectedId === conv.session_id ? 'true' : undefined"
|
||
|
|
@click="handleConversationClick(conv.session_id)"
|
||
|
|
@keydown.enter.prevent="handleConversationClick(conv.session_id)"
|
||
|
|
@keydown.space.prevent="handleConversationClick(conv.session_id)"
|
||
|
|
>
|
||
|
|
<div class="conversation-item-header">
|
||
|
|
<h3>{{ customerName(conv) }}</h3>
|
||
|
|
<ChannelBadge :channel="conv.channel" />
|
||
|
|
<span class="timestamp">{{ conv.timeAgo }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="conversation-preview">
|
||
|
|
<span class="agent-name">{{ previewPrefix(conv) }}:</span>
|
||
|
|
<!-- Product message preview -->
|
||
|
|
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
|
||
|
|
<p class="last-message product-preview">
|
||
|
|
<span class="product-icon">🛍️</span>
|
||
|
|
已分享 {{ conv.shopify_output.products.length }} 件商品
|
||
|
|
</p>
|
||
|
|
</template>
|
||
|
|
<!-- Regular message preview -->
|
||
|
|
<template v-else>
|
||
|
|
<p class="last-message">{{ conv.last_message }}</p>
|
||
|
|
</template>
|
||
|
|
<div v-if="unreadMessages[conv.session_id]" class="unread-bubble">
|
||
|
|
{{ unreadMessages[conv.session_id] }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="message-count">{{ conv.message_count }} 条消息</div>
|
||
|
|
<div class="conversation-footer">
|
||
|
|
<div class="conversation-status" :class="conv.status">
|
||
|
|
{{ statusLabel(conv.status) }}
|
||
|
|
</div>
|
||
|
|
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Loading more indicator -->
|
||
|
|
<div
|
||
|
|
v-if="hasMore || loadingMore"
|
||
|
|
class="load-more-trigger"
|
||
|
|
ref="loadMoreTrigger"
|
||
|
|
>
|
||
|
|
<div v-if="loadingMore" class="loading-more">
|
||
|
|
<div class="loading-spinner"></div>
|
||
|
|
<span>加载更多会话中…</span>
|
||
|
|
</div>
|
||
|
|
<div v-else-if="hasMore" class="load-more-container">
|
||
|
|
<span class="load-more-hint">向下滚动加载更多</span>
|
||
|
|
<button
|
||
|
|
class="load-more-button"
|
||
|
|
@click="emit('loadMore')"
|
||
|
|
:disabled="loadingMore"
|
||
|
|
>
|
||
|
|
加载更多
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Scroll to top button -->
|
||
|
|
<button
|
||
|
|
v-if="showScrollToTop"
|
||
|
|
class="scroll-to-top-btn"
|
||
|
|
@click="scrollToTop"
|
||
|
|
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">
|
||
|
|
<path d="M18 15l-6-6-6 6"/>
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Chat view -->
|
||
|
|
<div class="chat-view">
|
||
|
|
<div v-if="chatLoading" class="loading-state">
|
||
|
|
加载会话中…
|
||
|
|
</div>
|
||
|
|
<ConversationChat
|
||
|
|
v-else-if="selectedChat"
|
||
|
|
:chat="selectedChat"
|
||
|
|
@refresh="() => {
|
||
|
|
// force: this chat is already open, and loadChatDetail skips a session
|
||
|
|
// it thinks it has — without it this refetch was a silent no-op.
|
||
|
|
selectedChat && loadChatDetail(selectedChat.session_id, true);
|
||
|
|
emit('refresh');
|
||
|
|
}"
|
||
|
|
@chatUpdated="(updated) => {
|
||
|
|
// Forward as well as store locally. Taking a chat over from the chat
|
||
|
|
// pane changes user_id, and the info panel decides whether to offer
|
||
|
|
// 'End Chat' from its own chatInfo prop — which only the parent can
|
||
|
|
// update. Without this the button stayed hidden until a refresh.
|
||
|
|
selectedChat = updated;
|
||
|
|
emit('chatUpdated', updated);
|
||
|
|
}"
|
||
|
|
@clearUnread="clearUnread"
|
||
|
|
@back="emit('back')"
|
||
|
|
@info="emit('info')"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.conversations-container {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: 320px 1fr;
|
||
|
|
/* Same as the parent grid: without a bounded row the sidebar's height: 100%
|
||
|
|
resolves against content, so .conversations-list can never scroll. */
|
||
|
|
grid-template-rows: minmax(0, 1fr);
|
||
|
|
gap: 0;
|
||
|
|
height: 100%;
|
||
|
|
width: 100%;
|
||
|
|
background: var(--bg);
|
||
|
|
color: var(--text);
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-container.with-chat-info {
|
||
|
|
grid-template-columns: 320px 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-sidebar {
|
||
|
|
border-right: 1px solid var(--o08);
|
||
|
|
background: var(--bg2);
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
height: 100%;
|
||
|
|
overflow: hidden;
|
||
|
|
min-width: 0;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-controls {
|
||
|
|
display: flex;
|
||
|
|
padding: 10px 12px;
|
||
|
|
border-bottom: 1px solid var(--o07);
|
||
|
|
background: var(--bg2);
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-btn {
|
||
|
|
flex: 1;
|
||
|
|
padding: 7px 12px;
|
||
|
|
background: transparent;
|
||
|
|
border: none;
|
||
|
|
color: var(--muted);
|
||
|
|
font-size: 13px;
|
||
|
|
font-weight: 500;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all var(--transition-fast);
|
||
|
|
border-radius: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-btn:hover {
|
||
|
|
background: var(--o05);
|
||
|
|
color: var(--text);
|
||
|
|
}
|
||
|
|
|
||
|
|
.filter-btn.active {
|
||
|
|
background: var(--o10);
|
||
|
|
color: var(--text);
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-list {
|
||
|
|
overflow-y: auto;
|
||
|
|
flex: 1;
|
||
|
|
min-height: 0;
|
||
|
|
padding: 0;
|
||
|
|
margin: 0;
|
||
|
|
scrollbar-width: thin; /* For Firefox */
|
||
|
|
scrollbar-color: var(--border-color) transparent; /* For Firefox */
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Webkit scrollbar styling */
|
||
|
|
.conversations-list::-webkit-scrollbar {
|
||
|
|
width: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-list::-webkit-scrollbar-track {
|
||
|
|
background: transparent;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-list::-webkit-scrollbar-thumb {
|
||
|
|
background-color: var(--border-color);
|
||
|
|
border-radius: 3px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Status and handler sit on one line and wrap together on a narrow row. */
|
||
|
|
.conversation-footer {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
margin-top: 4px;
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-status {
|
||
|
|
font-size: 10px;
|
||
|
|
font-family: var(--font-mono);
|
||
|
|
letter-spacing: 0.04em;
|
||
|
|
padding: 2px 7px;
|
||
|
|
border-radius: var(--radius-full);
|
||
|
|
text-transform: uppercase;
|
||
|
|
display: inline-block;
|
||
|
|
font-weight: 600;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-status.open {
|
||
|
|
background: rgba(201,242,78,.12);
|
||
|
|
color: var(--accent-ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-status.closed {
|
||
|
|
background: var(--o06);
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-status.transferred {
|
||
|
|
background-color: var(--warning-color-soft);
|
||
|
|
color: var(--warning-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
.chat-view {
|
||
|
|
height: 100%;
|
||
|
|
width: 100%;
|
||
|
|
overflow: hidden;
|
||
|
|
position: relative;
|
||
|
|
min-width: 0;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar-header {
|
||
|
|
padding: 12px 16px;
|
||
|
|
border-bottom: 1px solid var(--border-color);
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.sidebar-header h2 {
|
||
|
|
font-size: 14px;
|
||
|
|
font-weight: 500;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item {
|
||
|
|
padding: 12px 16px;
|
||
|
|
border-bottom: 1px solid var(--o06);
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all var(--transition-fast);
|
||
|
|
min-height: 72px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item:hover {
|
||
|
|
background: var(--o05);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item.active {
|
||
|
|
background: var(--accent-bg-06);
|
||
|
|
border-left: 3px solid var(--accent-ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
margin-bottom: var(--space-xs);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item-header h3 {
|
||
|
|
font-size: 13px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--text);
|
||
|
|
white-space: nowrap;
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
max-width: 170px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.timestamp {
|
||
|
|
font-size: 11px;
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-preview {
|
||
|
|
display: flex;
|
||
|
|
gap: 4px;
|
||
|
|
margin-bottom: 4px;
|
||
|
|
font-size: 12px;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.agent-name {
|
||
|
|
font-weight: 500;
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.last-message {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--muted);
|
||
|
|
overflow: hidden;
|
||
|
|
text-overflow: ellipsis;
|
||
|
|
white-space: nowrap;
|
||
|
|
max-width: 140px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.message-count {
|
||
|
|
font-size: 11px;
|
||
|
|
color: var(--faint);
|
||
|
|
}
|
||
|
|
|
||
|
|
.unread-bubble {
|
||
|
|
background: var(--accent-solid);
|
||
|
|
color: var(--on-accent-solid);
|
||
|
|
border-radius: 50%;
|
||
|
|
min-width: 18px;
|
||
|
|
height: 18px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
font-size: 10px;
|
||
|
|
font-weight: 700;
|
||
|
|
margin-left: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-chat-selected {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
height: 100%;
|
||
|
|
color: var(--muted);
|
||
|
|
opacity: 0.7;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-state,
|
||
|
|
.error-state,
|
||
|
|
.empty-state {
|
||
|
|
padding: 16px;
|
||
|
|
text-align: center;
|
||
|
|
color: var(--muted);
|
||
|
|
font-size: 12px;
|
||
|
|
flex: 1;
|
||
|
|
min-height: 0;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
justify-content: center;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.refresh-button {
|
||
|
|
padding: 4px 8px;
|
||
|
|
background: var(--o06);
|
||
|
|
border: 1px solid var(--o10);
|
||
|
|
border-radius: var(--radius-sm);
|
||
|
|
color: var(--text3);
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-trigger {
|
||
|
|
height: 40px;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
padding: 12px 0;
|
||
|
|
margin: 8px 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-more {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
text-align: center;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading-spinner {
|
||
|
|
width: 16px;
|
||
|
|
height: 16px;
|
||
|
|
border: 2px solid var(--primary-color);
|
||
|
|
border-radius: 50%;
|
||
|
|
border-top-color: transparent;
|
||
|
|
animation: spin 1s linear infinite;
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-container {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-hint {
|
||
|
|
font-size: 12px;
|
||
|
|
color: var(--text-muted);
|
||
|
|
opacity: 0.7;
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-button {
|
||
|
|
padding: 6px 12px;
|
||
|
|
background-color: var(--background-soft);
|
||
|
|
border: 1px solid var(--border-color);
|
||
|
|
border-radius: var(--radius-sm);
|
||
|
|
color: var(--text-primary);
|
||
|
|
font-size: 12px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all var(--transition-fast);
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-button:hover {
|
||
|
|
background-color: var(--accent-solid);
|
||
|
|
color: var(--on-accent-solid);
|
||
|
|
border-color: var(--primary-color);
|
||
|
|
}
|
||
|
|
|
||
|
|
.load-more-button:disabled {
|
||
|
|
opacity: 0.5;
|
||
|
|
cursor: not-allowed;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes spin {
|
||
|
|
to {
|
||
|
|
transform: rotate(360deg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
.scroll-to-top-btn {
|
||
|
|
position: absolute;
|
||
|
|
bottom: 16px;
|
||
|
|
right: 16px;
|
||
|
|
width: 36px;
|
||
|
|
height: 36px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent-solid);
|
||
|
|
color: var(--on-accent-solid);
|
||
|
|
border: none;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
cursor: pointer;
|
||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||
|
|
transition: all var(--transition-fast);
|
||
|
|
z-index: 10;
|
||
|
|
}
|
||
|
|
|
||
|
|
.scroll-to-top-btn:hover {
|
||
|
|
transform: translateY(-2px);
|
||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
|
|
}
|
||
|
|
|
||
|
|
.scroll-to-top-btn:active {
|
||
|
|
transform: translateY(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-preview {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 4px;
|
||
|
|
color: var(--primary-color);
|
||
|
|
font-weight: 500;
|
||
|
|
}
|
||
|
|
|
||
|
|
.product-icon {
|
||
|
|
font-size: 14px;
|
||
|
|
line-height: 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
.last-message.product-preview {
|
||
|
|
color: var(--primary-color);
|
||
|
|
max-width: 160px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Mobile: single stacked pane — the list by default, the chat when a session
|
||
|
|
is selected (?session= in the URL, via the mobilePane prop) */
|
||
|
|
@media (max-width: 768px) {
|
||
|
|
.conversations-container,
|
||
|
|
.conversations-container.with-chat-info {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-container .chat-view {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-container.mobile-show-chat .conversations-sidebar {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-container.mobile-show-chat .chat-view {
|
||
|
|
display: flex;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversations-sidebar {
|
||
|
|
border-right: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item {
|
||
|
|
padding: 14px 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item.active {
|
||
|
|
background: transparent;
|
||
|
|
border-left: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.conversation-item-header h3 {
|
||
|
|
max-width: none;
|
||
|
|
font-size: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.last-message {
|
||
|
|
max-width: none;
|
||
|
|
flex: 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|