| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- <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>
- <!-- Image Modal -->
- <div v-if="selectedImage" class="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-md" @click="selectedImage = null">
- <div class="relative max-w-[90vw] max-h-[90vh]">
- <button @click.stop="selectedImage = null" class="absolute -top-12 right-0 p-2 text-foreground hover:text-primary transition-colors bg-card/50 rounded-full backdrop-blur-sm">
- <X class="w-6 h-6" />
- </button>
- <img :src="getImageUrl(selectedImage)" class="max-w-full max-h-[85vh] rounded-xl shadow-2xl object-contain border border-border/50 animate-in zoom-in-95 duration-200" @click.stop />
- </div>
- </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 parsedMessages"
- :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 v-if="msg.first_name || msg.email" class="lowercase font-mono ml-1 text-[9px] opacity-70">({{ msg.first_name || msg.email.split('@')[0] }})</span>
- </span>
- </div>
- <div v-else class="flex items-center justify-end gap-1.5 mb-1">
- <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">
- <span v-if="msg.first_name || msg.email" class="lowercase font-mono mr-1 text-[9px] opacity-70">{{ msg.first_name || msg.email.split('@')[0] }}</span>
- </span>
- </div>
- <div v-if="msg.imageUrl" class="mt-1 mb-1">
- <img :src="getImageUrl(msg.imageUrl)" @click="openImageModal(msg.imageUrl)" class="max-w-[150px] max-h-[150px] rounded-lg cursor-pointer hover:opacity-90 transition-opacity shadow-sm border border-border/20 object-cover" />
- </div>
- <p v-if="msg.parsedText" class="whitespace-pre-wrap break-words">{{ msg.parsedText }}</p>
- <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
- </div>
- </div>
- </template>
- </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="autoResize($event)"
- @paste="handlePaste"
- 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, computed, 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, RESOURCES_BASE_URL } from "@/lib/api";
- import { useAuthStore } from "@/stores/auth";
- import { toast } from "vue-sonner";
- import { getWsUrl } from "@/lib/utils";
- 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 selectedImage = ref<string | null>(null);
- let cooldownInterval: ReturnType<typeof setInterval> | null = null;
- const parsedMessages = computed(() => {
- return messages.value.map(msg => {
- let text = msg.message;
- let imageUrl = null;
- try {
- const parsed = JSON.parse(msg.message);
- if (parsed && typeof parsed === 'object') {
- if (parsed.image_url) imageUrl = parsed.image_url;
- if (parsed.text !== undefined) text = parsed.text;
- else if (parsed.image_url) text = ""; // If only image_url is present, don't render the json string as text
- }
- } catch {
- // Normal string message (fallback)
- }
- return { ...msg, parsedText: text, imageUrl };
- });
- });
- 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;
- const url = `${getWsUrl()}/chat?token=${encodeURIComponent(token)}&order_id=${props.orderId}`;
- ws = new WebSocket(url);
- 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 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 = (event) => {
- wsConnected.value = false;
- // Auto-reconnect after 3 seconds
- reconnectTimer = setTimeout(() => connectWebSocket(), 3000);
- };
- ws.onerror = (error) => {
- 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 {
- // If WS is connected and text only, send through it for "True Chat" experience
- if (ws && wsConnected.value && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: "message", text }));
- newMessage.value = "";
- if (textareaRef.value) {
- textareaRef.value.style.height = "auto";
- }
- } else {
- // Fallback to API if WS is down
- const response = await sendOrderMessage(props.orderId, text, null, 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: response.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;
- }
- }
- async function handlePaste(e: ClipboardEvent) {
- const items = e.clipboardData?.items;
- if (!items) return;
- for (let i = 0; i < items.length; i++) {
- if (items[i].type.indexOf("image") !== -1) {
- const file = items[i].getAsFile();
- if (file) {
- e.preventDefault();
- await uploadImage(file);
- }
- }
- }
- }
- async function uploadImage(file: File) {
- if (isSending.value) return;
- isSending.value = true;
- try {
- const response = await sendOrderMessage(props.orderId, "", file, locale.value);
- // 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: response.message,
- is_from_admin: myRole === 'admin',
- created_at: new Date().toISOString()
- });
- scrollToBottom();
- }
- startCooldown();
- } catch (e: any) {
- console.error("Failed to upload image:", e);
- toast.error(t("errors.upload_failed") || "Failed to upload image");
- } finally {
- isSending.value = false;
- }
- }
- function getImageUrl(url: string) {
- if (!url) return '';
- if (url.startsWith('http')) return url;
- return `${RESOURCES_BASE_URL}/${url}`;
- }
- function openImageModal(url: string) {
- selectedImage.value = url;
- }
- function handleKeydown(e: KeyboardEvent) {
- if (e.key === 'Escape' && selectedImage.value) {
- selectedImage.value = null;
- }
- }
- 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 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();
- window.addEventListener('keydown', handleKeydown);
- });
- onUnmounted(() => {
- disconnectWebSocket();
- window.removeEventListener('keydown', handleKeydown);
- });
- 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>
|