695 lines
27 KiB
Vue
695 lines
27 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, nextTick, onUnmounted, computed, watch } from 'vue';
|
|
import { csrfFetch } from '@/utils/csrfFetch';
|
|
import { usePage } from '@inertiajs/vue3';
|
|
|
|
type Sender = 'user' | 'ai' | 'agent' | 'system';
|
|
type ServerMessage = { id: string; sender: Sender; body: string; at: string };
|
|
|
|
const page = usePage();
|
|
const user = computed(() => (page.props.auth as any).user || {});
|
|
|
|
const isOpen = ref(false);
|
|
const isDismissed = ref(false);
|
|
const hiddenByGame = ref(false);
|
|
const isClosing = ref(false);
|
|
const showCloseConfirm = ref(false);
|
|
const input = ref('');
|
|
const sending = ref(false);
|
|
const loading = ref(false);
|
|
const status = ref<'new'|'ai'|'stopped'|'handoff'|'agent'|'closed'>('new');
|
|
const threadId = ref<string | null>(null);
|
|
const topic = ref<string | null>(null);
|
|
const messages = ref<ServerMessage[]>([]);
|
|
let pollTimer: any = null;
|
|
let es: EventSource | null = null;
|
|
let esBackoff = 1000;
|
|
|
|
const notificationSound = ref<HTMLAudioElement | null>(null);
|
|
|
|
// --- Draggable support ---
|
|
const dragPos = ref({ right: 20, bottom: 20 });
|
|
const dragging = ref(false);
|
|
const wasDragged = ref(false);
|
|
let dragStart = { x: 0, y: 0, right: 20, bottom: 20 };
|
|
|
|
function onDragStart(e: MouseEvent | TouchEvent) {
|
|
// Don't prevent default here so clicks still work
|
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
|
|
dragging.value = true;
|
|
wasDragged.value = false;
|
|
dragStart = { x: clientX, y: clientY, right: dragPos.value.right, bottom: dragPos.value.bottom };
|
|
document.addEventListener('mousemove', onDragMove);
|
|
document.addEventListener('mouseup', onDragEnd);
|
|
document.addEventListener('touchmove', onDragMove, { passive: false });
|
|
document.addEventListener('touchend', onDragEnd);
|
|
}
|
|
|
|
function onDragMove(e: MouseEvent | TouchEvent) {
|
|
if (!dragging.value) return;
|
|
if (e instanceof TouchEvent) e.preventDefault();
|
|
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
|
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
|
|
const dx = dragStart.x - clientX;
|
|
const dy = dragStart.y - clientY;
|
|
// Only count as drag if moved more than 5px
|
|
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) wasDragged.value = true;
|
|
const newRight = Math.max(4, Math.min(window.innerWidth - 64, dragStart.right + dx));
|
|
const newBottom = Math.max(4, Math.min(window.innerHeight - 64, dragStart.bottom + dy));
|
|
dragPos.value = { right: newRight, bottom: newBottom };
|
|
}
|
|
|
|
function onDragEnd() {
|
|
dragging.value = false;
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
document.removeEventListener('mouseup', onDragEnd);
|
|
document.removeEventListener('touchmove', onDragMove);
|
|
document.removeEventListener('touchend', onDragEnd);
|
|
// Reset wasDragged after click event fires
|
|
if (wasDragged.value) setTimeout(() => { wasDragged.value = false; }, 50);
|
|
}
|
|
// --- End draggable ---
|
|
|
|
const getUserAvatar = (u: any) => {
|
|
if (!u) return null;
|
|
return u.avatar_url || u.avatar || u.profile_photo_url || null;
|
|
};
|
|
const getAvatarFallback = (name: string, background: string = 'random', color: string = 'fff') => {
|
|
const cleanName = name ? name.replace(/\s/g, '+') : 'User';
|
|
return `https://ui-avatars.com/api/?name=${cleanName}&background=${background}&color=${color}`;
|
|
};
|
|
|
|
function requestCloseChat() {
|
|
showCloseConfirm.value = true;
|
|
}
|
|
|
|
async function confirmClose() {
|
|
if (isClosing.value) return;
|
|
isClosing.value = true;
|
|
try {
|
|
await csrfFetch('/api/support/close', { method: 'POST', headers: { 'Accept': 'application/json' } });
|
|
stopEventStream();
|
|
stopPolling();
|
|
status.value = 'new';
|
|
messages.value = [];
|
|
threadId.value = null;
|
|
topic.value = null;
|
|
} catch {}
|
|
finally {
|
|
isClosing.value = false;
|
|
showCloseConfirm.value = false;
|
|
}
|
|
}
|
|
|
|
const topics = [
|
|
'Konto', 'Einzahlung', 'Auszahlung', 'Bonus/Promo', 'Technisches Problem'
|
|
];
|
|
|
|
function readUiState() {
|
|
try {
|
|
const ds = localStorage.getItem('supportchat:dismissed');
|
|
isDismissed.value = ds === '1';
|
|
} catch {}
|
|
}
|
|
|
|
function saveUiState() {
|
|
try {
|
|
localStorage.setItem('supportchat:dismissed', isDismissed.value ? '1' : '0');
|
|
} catch {}
|
|
}
|
|
|
|
function toggle() {
|
|
if (isDismissed.value) {
|
|
isDismissed.value = false;
|
|
saveUiState();
|
|
}
|
|
|
|
isOpen.value = !isOpen.value;
|
|
|
|
if (isOpen.value) {
|
|
checkStatus();
|
|
} else {
|
|
stopEventStream();
|
|
stopPolling();
|
|
}
|
|
|
|
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
|
|
}
|
|
async function checkStatus() {
|
|
if (loading.value) return;
|
|
try {
|
|
const res = await fetch('/api/support/status', { headers: { 'Accept': 'application/json' } });
|
|
if (res.ok) {
|
|
const json = await res.json();
|
|
const tid = json.thread_id || json.id;
|
|
if (tid && json.status !== 'closed') {
|
|
mapFromServer(json);
|
|
await nextTick();
|
|
scrollToBottom();
|
|
startEventStream(); // Always start listening for updates
|
|
if (!supportsSSE()) startPolling();
|
|
} else {
|
|
threadId.value = null;
|
|
messages.value = [];
|
|
status.value = 'new';
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function dismiss() {
|
|
isDismissed.value = true;
|
|
isOpen.value = false;
|
|
saveUiState();
|
|
}
|
|
|
|
function restore() {
|
|
isDismissed.value = false;
|
|
saveUiState();
|
|
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
|
}
|
|
|
|
// Re-initialize Lucide icons whenever FAB or restore button visibility changes
|
|
watch([isOpen, isDismissed], () => {
|
|
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
|
});
|
|
|
|
function scrollToBottom() {
|
|
const el = document.getElementById('support-chat-list');
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
function mapFromServer(resp: any) {
|
|
const oldMessagesCount = messages.value.length;
|
|
const newMessages = Array.isArray(resp.messages) ? resp.messages : [];
|
|
|
|
threadId.value = resp.thread_id || resp.id || null;
|
|
status.value = resp.status || 'new';
|
|
topic.value = resp.topic || null;
|
|
|
|
// Filter AI messages if agent is active
|
|
if (status.value === 'agent' || status.value === 'handoff') {
|
|
messages.value = newMessages.filter((m: ServerMessage) => m.sender !== 'ai');
|
|
} else {
|
|
messages.value = newMessages;
|
|
}
|
|
|
|
// Sound Logic: Check if we have MORE messages than before AND the last one is from agent
|
|
if (messages.value.length > oldMessagesCount) {
|
|
const lastMessage = messages.value[messages.value.length - 1];
|
|
if (lastMessage && lastMessage.sender === 'agent' && notificationSound.value) {
|
|
notificationSound.value.play().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
function typeAiMessage(fullText: string) {
|
|
if (status.value === 'agent' || status.value === 'handoff') return;
|
|
|
|
const messageId = String(Date.now());
|
|
const aiMessage = {
|
|
id: messageId,
|
|
sender: 'ai' as Sender,
|
|
body: '',
|
|
at: new Date().toISOString()
|
|
};
|
|
messages.value.push(aiMessage);
|
|
|
|
let i = 0;
|
|
const typingInterval = setInterval(() => {
|
|
if (status.value === 'agent' || status.value === 'handoff') {
|
|
clearInterval(typingInterval);
|
|
messages.value = messages.value.filter(m => m.id !== messageId);
|
|
return;
|
|
}
|
|
|
|
const targetMessage = messages.value.find(m => m.id === messageId);
|
|
if (targetMessage && i < fullText.length) {
|
|
targetMessage.body += fullText.charAt(i);
|
|
i++;
|
|
scrollToBottom();
|
|
} else {
|
|
clearInterval(typingInterval);
|
|
}
|
|
}, 20);
|
|
}
|
|
|
|
async function handleAiReply(state: any) {
|
|
if (state.status === 'agent' || state.status === 'handoff') {
|
|
mapFromServer(state);
|
|
await nextTick();
|
|
scrollToBottom();
|
|
return;
|
|
}
|
|
|
|
const aiReply = state.messages.findLast((m: ServerMessage) => m.sender === 'ai');
|
|
if (aiReply) {
|
|
state.messages = state.messages.filter((m: ServerMessage) => m.id !== aiReply.id);
|
|
}
|
|
mapFromServer(state);
|
|
await nextTick();
|
|
if (aiReply) {
|
|
typeAiMessage(aiReply.body);
|
|
} else {
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
async function ensureStarted(t?: string) {
|
|
if (loading.value) return;
|
|
loading.value = true;
|
|
try {
|
|
const res = await csrfFetch('/api/support/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ topic: t || topic.value })
|
|
});
|
|
if (!res.ok) throw new Error(`Server Fehler: ${res.status}`);
|
|
const json = await res.json();
|
|
|
|
// Process response first so threadId.value is set before startEventStream checks it
|
|
await handleAiReply(json);
|
|
await nextTick();
|
|
scrollToBottom();
|
|
startEventStream();
|
|
if (!supportsSSE()) startPolling();
|
|
} catch (e: any) {
|
|
messages.value.push({
|
|
id: String(Date.now()),
|
|
sender: 'system',
|
|
body: `Fehler beim Starten des Chats.`,
|
|
at: new Date().toISOString()
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function send() {
|
|
const text = input.value.trim();
|
|
if (!text || sending.value) return;
|
|
sending.value = true;
|
|
const userMessage = {
|
|
id: String(Date.now()),
|
|
sender: 'user' as Sender,
|
|
body: text,
|
|
at: new Date().toISOString()
|
|
};
|
|
messages.value.push(userMessage);
|
|
input.value = '';
|
|
await nextTick();
|
|
scrollToBottom();
|
|
|
|
try {
|
|
const res = await csrfFetch('/api/support/message', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ text })
|
|
});
|
|
if (!res.ok) throw new Error(`Server Fehler: ${res.status}`);
|
|
const json = await res.json();
|
|
handleAiReply(json);
|
|
} catch (e: any) {
|
|
messages.value.push({
|
|
id: String(Date.now() + 1),
|
|
sender: 'system',
|
|
body: `Nachricht konnte nicht gesendet werden.`,
|
|
at: new Date().toISOString()
|
|
});
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
}
|
|
|
|
function startPolling(){
|
|
if (pollTimer || es) return;
|
|
pollTimer = setInterval(async () => {
|
|
try {
|
|
const res = await fetch('/api/support/status', { headers: { 'Accept': 'application/json' } });
|
|
if (!res.ok) return;
|
|
const json = await res.json();
|
|
const prevLen = messages.value.length;
|
|
mapFromServer(json);
|
|
if (messages.value.length !== prevLen) nextTick(scrollToBottom);
|
|
} catch {}
|
|
}, 5000); // Faster polling (5s) for better responsiveness
|
|
}
|
|
|
|
function stopPolling(){ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
function supportsSSE(){ return typeof window !== 'undefined' && 'EventSource' in window; }
|
|
|
|
function startEventStream() {
|
|
if (!supportsSSE() || es || !threadId.value) return;
|
|
try {
|
|
es = new EventSource('/api/support/stream', { withCredentials: true } as any);
|
|
es.onmessage = (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.data);
|
|
const prevLen = messages.value.length;
|
|
mapFromServer(data);
|
|
if (messages.value.length !== prevLen) nextTick(scrollToBottom);
|
|
} catch {}
|
|
};
|
|
es.onerror = () => {
|
|
stopEventStream();
|
|
setTimeout(() => { if (isOpen.value && threadId.value) startEventStream(); else startPolling(); }, esBackoff);
|
|
esBackoff = Math.min(esBackoff * 2, 15000);
|
|
};
|
|
es.onopen = () => { esBackoff = 1000; if (pollTimer) stopPolling(); };
|
|
} catch { startPolling(); }
|
|
}
|
|
|
|
function stopEventStream(){ try { if (es) { es.close(); } } catch {} es = null; }
|
|
|
|
async function stopAi(){
|
|
try {
|
|
const res = await csrfFetch('/api/support/stop', { method: 'POST', headers: { 'Accept': 'application/json' } });
|
|
const json = await res.json().catch(() => ({}));
|
|
if (res.ok) { mapFromServer(json); nextTick(scrollToBottom); }
|
|
} catch {}
|
|
}
|
|
|
|
async function handoff(){
|
|
try {
|
|
const res = await csrfFetch('/api/support/handoff', { method: 'POST', headers: { 'Accept': 'application/json' } });
|
|
const json = await res.json().catch(() => ({}));
|
|
if (res.ok) { mapFromServer(json.state || json); nextTick(scrollToBottom); }
|
|
} catch {}
|
|
}
|
|
|
|
function formatTime(isoString: string) {
|
|
return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
onMounted(() => {
|
|
readUiState();
|
|
nextTick(() => { if (window.lucide) window.lucide.createIcons(); });
|
|
document.addEventListener('hide-support-chat', _hideSupport);
|
|
document.addEventListener('show-support-chat', _showSupport);
|
|
});
|
|
|
|
const _hideSupport = () => { hiddenByGame.value = true; };
|
|
const _showSupport = () => { hiddenByGame.value = false; };
|
|
|
|
onUnmounted(() => {
|
|
stopPolling();
|
|
stopEventStream();
|
|
document.removeEventListener('hide-support-chat', _hideSupport);
|
|
document.removeEventListener('show-support-chat', _showSupport);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="sc-wrap" v-show="!hiddenByGame" aria-live="polite" :style="{ right: dragPos.right + 'px', bottom: dragPos.bottom + 'px' }">
|
|
<audio ref="notificationSound" src="/sounds/notification.mp3" preload="auto"></audio>
|
|
|
|
<button
|
|
v-if="!isDismissed && !isOpen"
|
|
class="sc-fab"
|
|
:class="{ dragging: dragging }"
|
|
title="Support Chat öffnen (ziehen zum Verschieben)"
|
|
@mousedown="onDragStart"
|
|
@touchstart.passive="onDragStart"
|
|
@click="!wasDragged && toggle()"
|
|
>
|
|
<i data-lucide="message-circle"></i>
|
|
<span class="sc-fab-pulse"></span>
|
|
</button>
|
|
|
|
<div v-show="isOpen && !isDismissed" class="sc-panel" role="dialog" aria-modal="false" aria-label="Support chat" @keydown.esc="isOpen=false">
|
|
<header class="sc-head" @mousedown="onDragStart" @touchstart="onDragStart">
|
|
<div class="drag-handle" title="Verschieben">
|
|
<i data-lucide="grip-horizontal"></i>
|
|
</div>
|
|
<div class="title">
|
|
<div class="avatar-wrapper">
|
|
<img src="https://ui-avatars.com/api/?name=Support&background=00f2ff&color=000" alt="Support" class="avatar-img" />
|
|
<span class="status-dot"></span>
|
|
</div>
|
|
<div class="info">
|
|
<span class="name">Kundensupport</span>
|
|
<span class="status-text">Online</span>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="icon" title="Minimieren" @click.stop="isOpen=false"><i data-lucide="chevron-down"></i></button>
|
|
<button class="icon close-btn" title="Chat beenden" @click.stop="requestCloseChat"><i data-lucide="power"></i></button>
|
|
<button class="icon" title="Ausblenden" @click.stop="dismiss"><i data-lucide="x"></i></button>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="support-chat-list" class="sc-list">
|
|
<div v-if="!threadId" class="empty-state">
|
|
<div class="welcome-msg">
|
|
<h3>Willkommen, {{ user.name || 'Gast' }}! 👋</h3>
|
|
<p>Wie können wir dir heute helfen? Wähle ein Thema:</p>
|
|
</div>
|
|
<div class="topics">
|
|
<button
|
|
v-for="t in topics"
|
|
:key="t"
|
|
class="topic-btn"
|
|
:disabled="loading"
|
|
@click.stop.prevent="ensureStarted(t)"
|
|
>
|
|
<span v-if="loading !== true">{{ t }}</span>
|
|
<span v-else>Lade...</span>
|
|
<i data-lucide="chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else v-for="m in messages" :key="m.id" class="message-row" :class="m.sender">
|
|
<div class="message-avatar" v-if="m.sender !== 'user'">
|
|
<img v-if="m.sender === 'ai'" src="https://ui-avatars.com/api/?name=AI&background=00f2ff&color=000" alt="AI" />
|
|
<img v-else src="https://ui-avatars.com/api/?name=S&background=333&color=fff" alt="Support" />
|
|
</div>
|
|
<div class="message-content">
|
|
<div class="bubble" :class="m.sender">
|
|
<div class="text">{{ m.body }}</div>
|
|
<span v-if="m.sender === 'ai' && loading" class="typing-cursor"></span>
|
|
</div>
|
|
<div class="message-meta">
|
|
<span class="time">{{ formatTime(m.at) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="message-avatar" v-if="m.sender === 'user'">
|
|
<img :src="getUserAvatar(user) || getAvatarFallback(user.name, 'ff007a')" :alt="user.name" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="sc-compose" v-if="threadId && status !== 'closed'">
|
|
<div class="input-wrapper">
|
|
<textarea class="input" rows="1" placeholder="Schreibe eine Nachricht..." v-model="input" @keydown.enter.exact.prevent="send"></textarea>
|
|
<button class="send-btn" :disabled="!input.trim() || sending" @click.stop.prevent="send"><i data-lucide="send"></i></button>
|
|
</div>
|
|
<div class="quick-actions" v-if="status==='ai' || status==='stopped'">
|
|
<button class="action-btn stop" v-if="status==='ai'" title="KI stoppen" @click="stopAi"><i data-lucide="stop-circle"></i> KI Stoppen</button>
|
|
<button class="action-btn handoff" v-if="status==='stopped'" title="Mitarbeiter hinzuziehen" @click="handoff"><i data-lucide="users"></i> Mitarbeiter anfordern</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<div v-if="showCloseConfirm" class="confirm-overlay">
|
|
<div class="confirm-dialog">
|
|
<h4>Chat beenden?</h4>
|
|
<p>Möchtest du diesen Chat wirklich beenden? Du kannst jederzeit einen neuen starten.</p>
|
|
<div class="confirm-actions">
|
|
<button class="btn-secondary" @click="showCloseConfirm = false">Abbrechen</button>
|
|
<button class="btn-danger" @click="confirmClose">Ja, beenden</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button v-if="isDismissed" class="sc-restore" title="Chat wiederherstellen" @click="restore">
|
|
<i data-lucide="message-circle"></i>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* --- Base Styles --- */
|
|
.sc-wrap {
|
|
position: fixed; z-index: 2147483647;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
:deep(svg) { pointer-events: none; }
|
|
button, input, textarea { pointer-events: auto; }
|
|
|
|
/* --- FAB Button --- */
|
|
.sc-fab {
|
|
position: relative;
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, var(--primary, #df006a), #9b0052);
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
cursor: grab;
|
|
box-shadow: 0 4px 24px rgba(223,0,106,0.5), 0 0 0 0 rgba(223,0,106,0.4);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
animation: fab-enter 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
user-select: none;
|
|
}
|
|
.sc-fab:hover {
|
|
transform: scale(1.08);
|
|
box-shadow: 0 6px 30px rgba(223,0,106,0.7);
|
|
}
|
|
.sc-fab.dragging {
|
|
cursor: grabbing;
|
|
transform: scale(1.12);
|
|
box-shadow: 0 8px 36px rgba(223,0,106,0.8);
|
|
transition: none;
|
|
}
|
|
.sc-fab i { width: 26px; height: 26px; }
|
|
|
|
/* Pulsing dot on FAB */
|
|
.sc-fab-pulse {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: #00f2ff;
|
|
border: 2px solid #111;
|
|
animation: sc-pulse 2s ease-in-out infinite;
|
|
}
|
|
@keyframes sc-pulse {
|
|
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0,242,255,0.7); }
|
|
50% { transform: scale(1.1); box-shadow: 0 0 0 4px rgba(0,242,255,0); }
|
|
}
|
|
@keyframes fab-enter {
|
|
from { opacity: 0; transform: scale(0.6) translateY(20px); }
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
}
|
|
|
|
/* --- Restore Button --- */
|
|
.sc-restore {
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: var(--primary, #df006a);
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 20px rgba(223,0,106,0.5);
|
|
transition: transform .2s;
|
|
}
|
|
.sc-restore:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
/* --- Panel & Header --- */
|
|
.sc-panel {
|
|
width: 400px; height: 600px; max-height: 80vh;
|
|
background: #0a0a0a; border: 1px solid #1f1f1f; border-radius: 16px;
|
|
overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,0.8);
|
|
display: flex; flex-direction: column;
|
|
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
isolation: isolate;
|
|
}
|
|
.sc-head {
|
|
background: #111; padding: 12px 16px; display: flex;
|
|
justify-content: space-between; align-items: center;
|
|
border-bottom: 1px solid #1f1f1f; flex-shrink: 0;
|
|
cursor: grab; user-select: none;
|
|
}
|
|
.sc-head:active { cursor: grabbing; }
|
|
.drag-handle { color: #444; display: flex; align-items: center; margin-right: 8px; flex-shrink: 0; }
|
|
.drag-handle i { width: 16px; height: 16px; }
|
|
.title { display: flex; align-items: center; gap: 12px; }
|
|
.avatar-wrapper { position: relative; }
|
|
.avatar-img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid #222; }
|
|
.status-dot { position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; background: #00f2ff; border-radius: 50%; border: 2px solid #111; box-shadow: 0 0 5px #00f2ff; }
|
|
.info { display: flex; flex-direction: column; }
|
|
.info .name { color: #fff; font-weight: 700; font-size: 15px; }
|
|
.info .status-text { color: #00f2ff; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.actions { display: flex; gap: 8px; }
|
|
.icon { background: transparent; border: none; color: #666; cursor: pointer; padding: 6px; border-radius: 8px; transition: 0.2s; display: grid; place-items: center; }
|
|
.icon:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
.close-btn:hover { color: #ff3e3e; background: rgba(255, 62, 62, 0.1); }
|
|
|
|
/* --- Chat List & Messages --- */
|
|
.sc-list { flex: 1; padding: 20px; overflow-y: auto; background: #050505; display: flex; flex-direction: column; gap: 16px; }
|
|
.message-row { display: flex; gap: 12px; align-items: flex-end; max-width: 85%; }
|
|
.message-row.user { align-self: flex-end; flex-direction: row-reverse; }
|
|
.message-row.support, .message-row.ai, .message-row.system, .message-row.agent { align-self: flex-start; }
|
|
.message-row.system { max-width: 100%; justify-content: center; }
|
|
/* FIX: Avatar size and fit */
|
|
.message-avatar { flex-shrink: 0; }
|
|
.message-avatar img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid #222; display: block; }
|
|
.message-content { display: flex; flex-direction: column; }
|
|
.bubble { padding: 12px 16px; border-radius: 16px; font-size: 13px; line-height: 1.5; position: relative; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
|
|
.user .bubble { background: #ff007a; color: #fff; border-bottom-right-radius: 2px; }
|
|
.support .bubble, .ai .bubble { background: #1a1a1a; color: #eee; border: 1px solid #222; border-bottom-left-radius: 2px; }
|
|
.agent .bubble { background: rgba(0, 242, 255, 0.1); color: #fff; border: 1px solid rgba(0, 242, 255, 0.3); border-bottom-left-radius: 2px; }
|
|
.system .bubble { background: transparent; color: #666; font-style: italic; font-size: 12px; text-align: center; box-shadow: none; padding: 4px; }
|
|
.message-meta { text-align: right; font-size: 10px; color: #666; margin-top: 4px; font-weight: 600; }
|
|
|
|
/* --- Typing Effect --- */
|
|
.typing-cursor {
|
|
display: inline-block; width: 6px; height: 12px; background-color: #00f2ff;
|
|
animation: blink 1s infinite; margin-left: 2px; vertical-align: middle;
|
|
}
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
|
|
/* --- Empty State & Topics --- */
|
|
.empty-state { text-align: center; margin: auto 0; padding: 20px; }
|
|
.welcome-msg h3 { color: #fff; font-size: 18px; font-weight: 700; margin-bottom: 8px; }
|
|
.welcome-msg p { color: #888; font-size: 14px; margin-bottom: 24px; }
|
|
.topics { display: flex; flex-direction: column; gap: 10px; }
|
|
.topic-btn {
|
|
background: #1a1a1a; border: 1px solid #222; color: #ccc; padding: 14px 16px;
|
|
border-radius: 12px; cursor: pointer; display: flex; justify-content: space-between;
|
|
align-items: center; transition: all 0.2s; font-size: 14px; font-weight: 600;
|
|
}
|
|
.topic-btn:hover { background: #222; border-color: #333; color: #fff; transform: translateX(2px); }
|
|
.topic-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
|
|
|
/* --- Footer & Composer --- */
|
|
.sc-compose { background: #111; padding: 16px; border-top: 1px solid #1f1f1f; }
|
|
.input-wrapper { display: flex; gap: 10px; background: #0a0a0a; padding: 6px; border-radius: 24px; border: 1px solid #222; align-items: flex-end; transition: border-color 0.2s; }
|
|
.input-wrapper:focus-within { border-color: #ff007a; }
|
|
.input { flex: 1; background: transparent; border: none; color: #fff; padding: 10px 14px; resize: none; max-height: 100px; outline: none; font-size: 14px; }
|
|
.send-btn {
|
|
width: 40px; height: 40px; border-radius: 50%;
|
|
background: #222; color: #666;
|
|
border: none; display: grid; place-items: center;
|
|
cursor: not-allowed; transition: all 0.2s;
|
|
}
|
|
.send-btn:not(:disabled) { background: #ff007a; color: #fff; box-shadow: 0 0 15px rgba(255,0,122,0.4); }
|
|
.send-btn:not(:disabled):hover { background: #d60068; transform: scale(1.05); }
|
|
|
|
/* --- Quick Actions --- */
|
|
.quick-actions { display: flex; gap: 10px; margin-top: 12px; justify-content: center; }
|
|
.action-btn {
|
|
display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 20px;
|
|
font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid; background: transparent; transition: 0.2s;
|
|
}
|
|
.action-btn.stop { color: #ff3e3e; border-color: rgba(255, 62, 62, 0.3); background: rgba(255, 62, 62, 0.05); }
|
|
.action-btn.stop:hover { background: rgba(255, 62, 62, 0.15); border-color: #ff3e3e; }
|
|
.action-btn.handoff { color: #00f2ff; border-color: rgba(0, 242, 255, 0.3); background: rgba(0, 242, 255, 0.05); }
|
|
.action-btn.handoff:hover { background: rgba(0, 242, 255, 0.15); border-color: #00f2ff; }
|
|
|
|
/* --- Confirmation Dialog --- */
|
|
.confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 2147483648; animation: fadeIn 0.2s; }
|
|
.confirm-dialog { background: #1a1a1a; padding: 24px; border-radius: 20px; width: 90%; max-width: 320px; text-align: center; box-shadow: 0 20px 50px rgba(0,0,0,0.6); border: 1px solid #333; }
|
|
.confirm-dialog h4 { color: #fff; font-size: 18px; margin: 0 0 10px; font-weight: 700; }
|
|
.confirm-dialog p { color: #888; font-size: 14px; margin: 0 0 24px; line-height: 1.5; }
|
|
.confirm-actions { display: flex; justify-content: center; gap: 12px; }
|
|
.confirm-actions button { padding: 10px 20px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 13px; }
|
|
.btn-danger { background: #ff3e3e; color: #fff; }
|
|
.btn-danger:hover { background: #d43434; box-shadow: 0 0 15px rgba(255, 62, 62, 0.4); }
|
|
.btn-secondary { background: #222; color: #ccc; border: 1px solid #333; }
|
|
.btn-secondary:hover { background: #333; color: #fff; }
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
</style>
|