| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- <template>
- <div class="order-chat flex flex-col" :class="compact ? 'h-[320px]' : 'h-[420px]'">
- <!-- Header -->
- <div class="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-card/60 backdrop-blur-sm rounded-t-2xl">
- <div class="flex items-center gap-2">
- <MessageCircle class="w-4 h-4 text-primary" />
- <span class="text-sm font-bold">{{ t("chat.title") }}</span>
- <span class="w-2 h-2 rounded-full" :class="wsConnected ? 'bg-emerald-500' : 'bg-rose-500'" :title="wsConnected ? 'Connected' : 'Disconnected'" />
- <span v-if="otherPartyOnline" class="text-[10px] text-emerald-500 font-bold ml-2 flex items-center gap-1 animate-in fade-in zoom-in duration-300">
- <Eye class="w-3 h-3" /> Online
- </span>
- </div>
- <button v-if="closable" @click="$emit('close')" class="p-1 hover:bg-secondary rounded-lg transition-colors">
- <X class="w-4 h-4 text-muted-foreground" />
- </button>
- </div>
- <!-- Messages area -->
- <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
- <div v-if="isLoading" class="flex items-center justify-center h-full">
- <Loader2 class="w-6 h-6 animate-spin text-primary" />
- </div>
- <div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center h-full text-center opacity-60">
- <MessageCircle class="w-10 h-10 text-muted-foreground mb-3 opacity-40" />
- <p class="text-sm text-muted-foreground">{{ t("chat.empty") }}</p>
- </div>
- <template v-else>
- <div
- v-for="msg in messages"
- :key="msg.id"
- class="flex"
- :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
- >
- <div
- class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
- :class="msg.is_from_admin
- ? 'bg-secondary/80 text-foreground rounded-bl-md'
- : 'bg-primary text-primary-foreground rounded-br-md'"
- >
- <div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
- <ShieldCheck class="w-3 h-3 opacity-70" />
- <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">{{ t("chat.admin") }}</span>
- </div>
- <p class="whitespace-pre-wrap break-words">{{ msg.message }}</p>
- <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
- </div>
- </div>
- </template>
- <!-- Typing Indicator -->
- <div v-if="isOtherPartyTyping" class="flex justify-start mb-2 animate-in fade-in zoom-in duration-300">
- <div class="bg-secondary/60 text-muted-foreground px-4 py-3 rounded-2xl rounded-bl-md flex items-center gap-1.5 shadow-sm">
- <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
- <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
- <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
- </div>
- </div>
- </div>
- <!-- Input area -->
- <div class="px-3 py-3 border-t border-border/50 bg-card/40 rounded-b-2xl">
- <form @submit.prevent="handleSend" class="flex gap-2 items-end">
- <textarea
- v-model="newMessage"
- :placeholder="t('chat.placeholder')"
- rows="1"
- class="flex-1 resize-none bg-background/80 border border-border/50 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 transition-all placeholder:text-muted-foreground/50 max-h-[80px] overflow-y-auto"
- @keydown.enter.exact.prevent="handleSend"
- @input="onTextareaInput($event); autoResize($event)"
- ref="textareaRef"
- />
- <button
- type="submit"
- :disabled="!newMessage.trim() || isSending || cooldownLeft > 0"
- class="p-2.5 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0 relative"
- >
- <Send v-if="!isSending && cooldownLeft === 0" class="w-4 h-4" />
- <Loader2 v-else-if="isSending" class="w-4 h-4 animate-spin" />
- <span v-else-if="cooldownLeft > 0" class="text-[10px] font-bold">{{ cooldownLeft }}s</span>
- </button>
- </form>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
- import { useI18n } from "vue-i18n";
- import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
- import { getOrderMessages, sendOrderMessage } from "@/lib/api";
- import { useAuthStore } from "@/stores/auth";
- const WS_BASE_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8000";
- const props = defineProps<{
- orderId: number;
- compact?: boolean;
- closable?: boolean;
- }>();
- defineEmits<{ (e: 'close'): void }>();
- const { t, locale } = useI18n();
- const messages = ref<any[]>([]);
- const newMessage = ref("");
- const isLoading = ref(true);
- const isSending = ref(false);
- const cooldownLeft = ref(0);
- const wsConnected = ref(false);
- const messagesContainer = ref<HTMLElement | null>(null);
- const textareaRef = ref<HTMLTextAreaElement | null>(null);
- const otherPartyOnline = ref(false);
- const isOtherPartyTyping = ref(false);
- let typingTimeout: ReturnType<typeof setTimeout> | null = null;
- let cooldownInterval: ReturnType<typeof setInterval> | null = null;
- function startCooldown() {
- if (authStore.user?.role === 'admin') return;
- cooldownLeft.value = 10;
- if (cooldownInterval) clearInterval(cooldownInterval);
- cooldownInterval = setInterval(() => {
- cooldownLeft.value--;
- if (cooldownLeft.value <= 0) {
- if (cooldownInterval) clearInterval(cooldownInterval);
- }
- }, 1000);
- }
- const authStore = useAuthStore();
- let ws: WebSocket | null = null;
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- function connectWebSocket() {
- const token = localStorage.getItem("token");
- if (!token) return;
- ws = new WebSocket(`${WS_BASE_URL}/chat?token=${encodeURIComponent(token)}&order_id=${props.orderId}`);
- ws.onopen = () => {
- wsConnected.value = true;
- };
- ws.onmessage = (event) => {
- try {
- const msg = JSON.parse(event.data);
- if (msg.type === "presence") {
- const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
- const otherRole = myRole === 'admin' ? 'user' : 'admin';
- otherPartyOnline.value = msg.online_roles.includes(otherRole);
- return;
- }
-
- if (msg.type === "typing" || msg.type === "stop_typing") {
- const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
- const isFromOther = (msg.is_admin && myRole === 'user') || (!msg.is_admin && myRole === 'admin');
- if (isFromOther) {
- isOtherPartyTyping.value = (msg.type === "typing");
- scrollToBottom();
- }
- return;
- }
-
- // If it's a message (type 'message' or has essential fields)
- if (msg.type === "message" || (msg.message && msg.id)) {
- // Use lenient comparison (==) for ID to avoid string/int mismatches
- const exists = messages.value.some(m => m.id == msg.id);
- if (!exists) {
- messages.value.push(msg);
- scrollToBottom();
-
- // Audio & Read Receipt for incoming messages
- const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
- const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
- if (!isMsgFromMe) {
- authStore.playChatSound();
- ws?.send("read");
- setTimeout(() => authStore.refreshUnreadCount(), 300);
- }
- }
- }
- } catch (e) {
- console.error("WS parse error:", e);
- }
- };
- ws.onclose = () => {
- wsConnected.value = false;
- // Auto-reconnect after 3 seconds
- reconnectTimer = setTimeout(() => connectWebSocket(), 3000);
- };
- ws.onerror = () => {
- ws?.close();
- };
- }
- function disconnectWebSocket() {
- if (reconnectTimer) {
- clearTimeout(reconnectTimer);
- reconnectTimer = null;
- }
- if (ws) {
- ws.onclose = null; // prevent auto-reconnect
- ws.close();
- ws = null;
- }
- wsConnected.value = false;
- }
- async function loadMessages() {
- try {
- messages.value = await getOrderMessages(props.orderId, locale.value);
- authStore.refreshUnreadCount();
- } catch (e) {
- console.error("Failed to load messages:", e);
- } finally {
- isLoading.value = false;
- }
- }
- async function handleSend() {
- const text = newMessage.value.trim();
- if (!text || isSending.value) return;
- isSending.value = true;
- try {
- const response = await sendOrderMessage(props.orderId, text, locale.value);
- newMessage.value = "";
- if (textareaRef.value) {
- textareaRef.value.style.height = "auto";
- }
-
- // Add message to list only if it hasn't arrived via WS yet
- if (!messages.value.some(m => m.id == response.id)) {
- const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
- messages.value.push({
- id: response.id,
- message: text,
- is_from_admin: myRole === 'admin',
- created_at: new Date().toISOString()
- });
- scrollToBottom();
- }
-
- // Start flood control cooldown
- startCooldown();
- } catch (e) {
- console.error("Failed to send message:", e);
- } finally {
- isSending.value = false;
- }
- }
- function scrollToBottom() {
- nextTick(() => {
- if (messagesContainer.value) {
- messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
- }
- });
- }
- function autoResize(e: Event) {
- const el = e.target as HTMLTextAreaElement;
- el.style.height = "auto";
- el.style.height = Math.min(el.scrollHeight, 80) + "px";
- }
- function onTextareaInput(e: Event) {
- if (wsConnected.value && ws) {
- ws.send("typing");
- if (typingTimeout) clearTimeout(typingTimeout);
- typingTimeout = setTimeout(() => {
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send("stop_typing");
- }
- }, 2000);
- }
- }
- function formatTime(dt: string) {
- if (!dt) return "";
- const d = new Date(dt);
- return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
- }
- onMounted(async () => {
- await loadMessages();
- scrollToBottom();
- connectWebSocket();
- });
- onUnmounted(() => {
- disconnectWebSocket();
- });
- watch(() => props.orderId, async () => {
- disconnectWebSocket();
- isLoading.value = true;
- await loadMessages();
- scrollToBottom();
- connectWebSocket();
- });
- </script>
- <style scoped>
- .scrollbar-thin::-webkit-scrollbar { width: 4px; }
- .scrollbar-thin::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 9999px; }
- .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
- </style>
|