OrderChat.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. <template>
  2. <div class="order-chat flex flex-col" :class="compact ? 'h-[320px]' : 'h-[420px]'">
  3. <!-- Header -->
  4. <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">
  5. <div class="flex items-center gap-2">
  6. <MessageCircle class="w-4 h-4 text-primary" />
  7. <span class="text-sm font-bold">{{ t("chat.title") }}</span>
  8. <span class="w-2 h-2 rounded-full" :class="wsConnected ? 'bg-emerald-500' : 'bg-rose-500'" :title="wsConnected ? 'Connected' : 'Disconnected'" />
  9. <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">
  10. <Eye class="w-3 h-3" /> Online
  11. </span>
  12. </div>
  13. <button v-if="closable" @click="$emit('close')" class="p-1 hover:bg-secondary rounded-lg transition-colors">
  14. <X class="w-4 h-4 text-muted-foreground" />
  15. </button>
  16. </div>
  17. <!-- Image Modal -->
  18. <div v-if="selectedImage" class="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-md" @click="selectedImage = null">
  19. <div class="relative max-w-[90vw] max-h-[90vh]">
  20. <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">
  21. <X class="w-6 h-6" />
  22. </button>
  23. <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 />
  24. </div>
  25. </div>
  26. <!-- Messages area -->
  27. <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
  28. <div v-if="isLoading" class="flex items-center justify-center h-full">
  29. <Loader2 class="w-6 h-6 animate-spin text-primary" />
  30. </div>
  31. <div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center h-full text-center opacity-60">
  32. <MessageCircle class="w-10 h-10 text-muted-foreground mb-3 opacity-40" />
  33. <p class="text-sm text-muted-foreground">{{ t("chat.empty") }}</p>
  34. </div>
  35. <template v-else>
  36. <div
  37. v-for="msg in parsedMessages"
  38. :key="msg.id"
  39. class="flex"
  40. :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
  41. >
  42. <div
  43. class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
  44. :class="msg.is_from_admin
  45. ? 'bg-secondary/80 text-foreground rounded-bl-md'
  46. : 'bg-primary text-primary-foreground rounded-br-md'"
  47. >
  48. <div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
  49. <ShieldCheck class="w-3 h-3 opacity-70" />
  50. <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">
  51. {{ 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>
  52. </span>
  53. </div>
  54. <div v-else class="flex items-center justify-end gap-1.5 mb-1">
  55. <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">
  56. <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>
  57. </span>
  58. </div>
  59. <div v-if="msg.imageUrl" class="mt-1 mb-1">
  60. <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" />
  61. </div>
  62. <p v-if="msg.parsedText" class="whitespace-pre-wrap break-words">{{ msg.parsedText }}</p>
  63. <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
  64. </div>
  65. </div>
  66. </template>
  67. </div>
  68. <!-- Input area -->
  69. <div class="px-3 py-3 border-t border-border/50 bg-card/40 rounded-b-2xl">
  70. <form @submit.prevent="handleSend" class="flex gap-2 items-end">
  71. <textarea
  72. v-model="newMessage"
  73. :placeholder="t('chat.placeholder')"
  74. rows="1"
  75. 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"
  76. @keydown.enter.exact.prevent="handleSend"
  77. @input="autoResize($event)"
  78. @paste="handlePaste"
  79. ref="textareaRef"
  80. />
  81. <button
  82. type="submit"
  83. :disabled="!newMessage.trim() || isSending || cooldownLeft > 0"
  84. 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"
  85. >
  86. <Send v-if="!isSending && cooldownLeft === 0" class="w-4 h-4" />
  87. <Loader2 v-else-if="isSending" class="w-4 h-4 animate-spin" />
  88. <span v-else-if="cooldownLeft > 0" class="text-[10px] font-bold">{{ cooldownLeft }}s</span>
  89. </button>
  90. </form>
  91. </div>
  92. </div>
  93. </template>
  94. <script setup lang="ts">
  95. import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
  96. import { useI18n } from "vue-i18n";
  97. import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
  98. import { getOrderMessages, sendOrderMessage, RESOURCES_BASE_URL } from "@/lib/api";
  99. import { useAuthStore } from "@/stores/auth";
  100. import { toast } from "vue-sonner";
  101. import { getWsUrl } from "@/lib/utils";
  102. const props = defineProps<{
  103. orderId: number;
  104. compact?: boolean;
  105. closable?: boolean;
  106. }>();
  107. defineEmits<{ (e: 'close'): void }>();
  108. const { t, locale } = useI18n();
  109. const messages = ref<any[]>([]);
  110. const newMessage = ref("");
  111. const isLoading = ref(true);
  112. const isSending = ref(false);
  113. const cooldownLeft = ref(0);
  114. const wsConnected = ref(false);
  115. const messagesContainer = ref<HTMLElement | null>(null);
  116. const textareaRef = ref<HTMLTextAreaElement | null>(null);
  117. const otherPartyOnline = ref(false);
  118. const selectedImage = ref<string | null>(null);
  119. let cooldownInterval: ReturnType<typeof setInterval> | null = null;
  120. const parsedMessages = computed(() => {
  121. return messages.value.map(msg => {
  122. let text = msg.message;
  123. let imageUrl = null;
  124. try {
  125. const parsed = JSON.parse(msg.message);
  126. if (parsed && typeof parsed === 'object') {
  127. if (parsed.image_url) imageUrl = parsed.image_url;
  128. if (parsed.text !== undefined) text = parsed.text;
  129. else if (parsed.image_url) text = ""; // If only image_url is present, don't render the json string as text
  130. }
  131. } catch {
  132. // Normal string message (fallback)
  133. }
  134. return { ...msg, parsedText: text, imageUrl };
  135. });
  136. });
  137. function startCooldown() {
  138. if (authStore.user?.role === 'admin') return;
  139. cooldownLeft.value = 10;
  140. if (cooldownInterval) clearInterval(cooldownInterval);
  141. cooldownInterval = setInterval(() => {
  142. cooldownLeft.value--;
  143. if (cooldownLeft.value <= 0) {
  144. if (cooldownInterval) clearInterval(cooldownInterval);
  145. }
  146. }, 1000);
  147. }
  148. const authStore = useAuthStore();
  149. let ws: WebSocket | null = null;
  150. let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  151. function connectWebSocket() {
  152. const token = localStorage.getItem("token");
  153. if (!token) return;
  154. const url = `${getWsUrl()}/chat?token=${encodeURIComponent(token)}&order_id=${props.orderId}`;
  155. ws = new WebSocket(url);
  156. ws.onopen = () => {
  157. wsConnected.value = true;
  158. };
  159. ws.onmessage = (event) => {
  160. try {
  161. const msg = JSON.parse(event.data);
  162. if (msg.type === "presence") {
  163. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  164. const otherRole = myRole === 'admin' ? 'user' : 'admin';
  165. otherPartyOnline.value = msg.online_roles.includes(otherRole);
  166. return;
  167. }
  168. // If it's a message (type 'message' or has essential fields)
  169. if (msg.type === "message" || (msg.message && msg.id)) {
  170. // Use lenient comparison (==) for ID to avoid string/int mismatches
  171. const exists = messages.value.some(m => m.id == msg.id);
  172. if (!exists) {
  173. messages.value.push(msg);
  174. scrollToBottom();
  175. // Audio & Read Receipt for incoming messages
  176. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  177. const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
  178. if (!isMsgFromMe) {
  179. authStore.playChatSound();
  180. ws?.send("read");
  181. setTimeout(() => authStore.refreshUnreadCount(), 300);
  182. }
  183. }
  184. }
  185. } catch (e) {
  186. console.error("WS parse error:", e);
  187. }
  188. };
  189. ws.onclose = (event) => {
  190. wsConnected.value = false;
  191. // Auto-reconnect after 3 seconds
  192. reconnectTimer = setTimeout(() => connectWebSocket(), 3000);
  193. };
  194. ws.onerror = (error) => {
  195. ws?.close();
  196. };
  197. }
  198. function disconnectWebSocket() {
  199. if (reconnectTimer) {
  200. clearTimeout(reconnectTimer);
  201. reconnectTimer = null;
  202. }
  203. if (ws) {
  204. ws.onclose = null; // prevent auto-reconnect
  205. ws.close();
  206. ws = null;
  207. }
  208. wsConnected.value = false;
  209. }
  210. async function loadMessages() {
  211. try {
  212. messages.value = await getOrderMessages(props.orderId, locale.value);
  213. authStore.refreshUnreadCount();
  214. } catch (e) {
  215. console.error("Failed to load messages:", e);
  216. } finally {
  217. isLoading.value = false;
  218. }
  219. }
  220. async function handleSend() {
  221. const text = newMessage.value.trim();
  222. if (!text || isSending.value) return;
  223. isSending.value = true;
  224. try {
  225. // If WS is connected and text only, send through it for "True Chat" experience
  226. if (ws && wsConnected.value && ws.readyState === WebSocket.OPEN) {
  227. ws.send(JSON.stringify({ type: "message", text }));
  228. newMessage.value = "";
  229. if (textareaRef.value) {
  230. textareaRef.value.style.height = "auto";
  231. }
  232. } else {
  233. // Fallback to API if WS is down
  234. const response = await sendOrderMessage(props.orderId, text, null, locale.value);
  235. newMessage.value = "";
  236. if (textareaRef.value) {
  237. textareaRef.value.style.height = "auto";
  238. }
  239. // Add message to list only if it hasn't arrived via WS yet
  240. if (!messages.value.some(m => m.id == response.id)) {
  241. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  242. messages.value.push({
  243. id: response.id,
  244. message: response.message || text,
  245. is_from_admin: myRole === 'admin',
  246. created_at: new Date().toISOString()
  247. });
  248. scrollToBottom();
  249. }
  250. }
  251. // Start flood control cooldown
  252. startCooldown();
  253. } catch (e) {
  254. console.error("Failed to send message:", e);
  255. } finally {
  256. isSending.value = false;
  257. }
  258. }
  259. async function handlePaste(e: ClipboardEvent) {
  260. const items = e.clipboardData?.items;
  261. if (!items) return;
  262. for (let i = 0; i < items.length; i++) {
  263. if (items[i].type.indexOf("image") !== -1) {
  264. const file = items[i].getAsFile();
  265. if (file) {
  266. e.preventDefault();
  267. await uploadImage(file);
  268. }
  269. }
  270. }
  271. }
  272. async function uploadImage(file: File) {
  273. if (isSending.value) return;
  274. isSending.value = true;
  275. try {
  276. const response = await sendOrderMessage(props.orderId, "", file, locale.value);
  277. // Add message to list only if it hasn't arrived via WS yet
  278. if (!messages.value.some(m => m.id == response.id)) {
  279. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  280. messages.value.push({
  281. id: response.id,
  282. message: response.message,
  283. is_from_admin: myRole === 'admin',
  284. created_at: new Date().toISOString()
  285. });
  286. scrollToBottom();
  287. }
  288. startCooldown();
  289. } catch (e: any) {
  290. console.error("Failed to upload image:", e);
  291. toast.error(t("errors.upload_failed") || "Failed to upload image");
  292. } finally {
  293. isSending.value = false;
  294. }
  295. }
  296. function getImageUrl(url: string) {
  297. if (!url) return '';
  298. if (url.startsWith('http')) return url;
  299. return `${RESOURCES_BASE_URL}/${url}`;
  300. }
  301. function openImageModal(url: string) {
  302. selectedImage.value = url;
  303. }
  304. function handleKeydown(e: KeyboardEvent) {
  305. if (e.key === 'Escape' && selectedImage.value) {
  306. selectedImage.value = null;
  307. }
  308. }
  309. function scrollToBottom() {
  310. nextTick(() => {
  311. if (messagesContainer.value) {
  312. messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
  313. }
  314. });
  315. }
  316. function autoResize(e: Event) {
  317. const el = e.target as HTMLTextAreaElement;
  318. el.style.height = "auto";
  319. el.style.height = Math.min(el.scrollHeight, 80) + "px";
  320. }
  321. function formatTime(dt: string) {
  322. if (!dt) return "";
  323. const d = new Date(dt);
  324. return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
  325. }
  326. onMounted(async () => {
  327. await loadMessages();
  328. scrollToBottom();
  329. connectWebSocket();
  330. window.addEventListener('keydown', handleKeydown);
  331. });
  332. onUnmounted(() => {
  333. disconnectWebSocket();
  334. window.removeEventListener('keydown', handleKeydown);
  335. });
  336. watch(() => props.orderId, async () => {
  337. disconnectWebSocket();
  338. isLoading.value = true;
  339. await loadMessages();
  340. scrollToBottom();
  341. connectWebSocket();
  342. });
  343. </script>
  344. <style scoped>
  345. .scrollbar-thin::-webkit-scrollbar { width: 4px; }
  346. .scrollbar-thin::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 9999px; }
  347. .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
  348. </style>