OrderChat.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. <!-- Messages area -->
  18. <div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-3 scrollbar-thin">
  19. <div v-if="isLoading" class="flex items-center justify-center h-full">
  20. <Loader2 class="w-6 h-6 animate-spin text-primary" />
  21. </div>
  22. <div v-else-if="messages.length === 0" class="flex flex-col items-center justify-center h-full text-center opacity-60">
  23. <MessageCircle class="w-10 h-10 text-muted-foreground mb-3 opacity-40" />
  24. <p class="text-sm text-muted-foreground">{{ t("chat.empty") }}</p>
  25. </div>
  26. <template v-else>
  27. <div
  28. v-for="msg in messages"
  29. :key="msg.id"
  30. class="flex"
  31. :class="msg.is_from_admin ? 'justify-start' : 'justify-end'"
  32. >
  33. <div
  34. class="max-w-[80%] px-3.5 py-2 rounded-2xl text-sm leading-relaxed shadow-sm"
  35. :class="msg.is_from_admin
  36. ? 'bg-secondary/80 text-foreground rounded-bl-md'
  37. : 'bg-primary text-primary-foreground rounded-br-md'"
  38. >
  39. <div v-if="msg.is_from_admin" class="flex items-center gap-1.5 mb-1">
  40. <ShieldCheck class="w-3 h-3 opacity-70" />
  41. <span class="text-[10px] font-bold uppercase tracking-wider opacity-70">{{ t("chat.admin") }}</span>
  42. </div>
  43. <p class="whitespace-pre-wrap break-words">{{ msg.message }}</p>
  44. <p class="text-[10px] mt-1 opacity-50 text-right">{{ formatTime(msg.created_at) }}</p>
  45. </div>
  46. </div>
  47. </template>
  48. <!-- Typing Indicator -->
  49. <div v-if="isOtherPartyTyping" class="flex justify-start mb-2 animate-in fade-in zoom-in duration-300">
  50. <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">
  51. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
  52. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
  53. <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
  54. </div>
  55. </div>
  56. </div>
  57. <!-- Input area -->
  58. <div class="px-3 py-3 border-t border-border/50 bg-card/40 rounded-b-2xl">
  59. <form @submit.prevent="handleSend" class="flex gap-2 items-end">
  60. <textarea
  61. v-model="newMessage"
  62. :placeholder="t('chat.placeholder')"
  63. rows="1"
  64. 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"
  65. @keydown.enter.exact.prevent="handleSend"
  66. @input="onTextareaInput($event); autoResize($event)"
  67. ref="textareaRef"
  68. />
  69. <button
  70. type="submit"
  71. :disabled="!newMessage.trim() || isSending || cooldownLeft > 0"
  72. 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"
  73. >
  74. <Send v-if="!isSending && cooldownLeft === 0" class="w-4 h-4" />
  75. <Loader2 v-else-if="isSending" class="w-4 h-4 animate-spin" />
  76. <span v-else-if="cooldownLeft > 0" class="text-[10px] font-bold">{{ cooldownLeft }}s</span>
  77. </button>
  78. </form>
  79. </div>
  80. </div>
  81. </template>
  82. <script setup lang="ts">
  83. import { ref, onMounted, onUnmounted, nextTick, watch } from "vue";
  84. import { useI18n } from "vue-i18n";
  85. import { MessageCircle, Send, Loader2, ShieldCheck, X, Eye } from "lucide-vue-next";
  86. import { getOrderMessages, sendOrderMessage } from "@/lib/api";
  87. import { useAuthStore } from "@/stores/auth";
  88. const WS_BASE_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8000";
  89. const props = defineProps<{
  90. orderId: number;
  91. compact?: boolean;
  92. closable?: boolean;
  93. }>();
  94. defineEmits<{ (e: 'close'): void }>();
  95. const { t, locale } = useI18n();
  96. const messages = ref<any[]>([]);
  97. const newMessage = ref("");
  98. const isLoading = ref(true);
  99. const isSending = ref(false);
  100. const cooldownLeft = ref(0);
  101. const wsConnected = ref(false);
  102. const messagesContainer = ref<HTMLElement | null>(null);
  103. const textareaRef = ref<HTMLTextAreaElement | null>(null);
  104. const otherPartyOnline = ref(false);
  105. const isOtherPartyTyping = ref(false);
  106. let typingTimeout: ReturnType<typeof setTimeout> | null = null;
  107. let cooldownInterval: ReturnType<typeof setInterval> | null = null;
  108. function startCooldown() {
  109. if (authStore.user?.role === 'admin') return;
  110. cooldownLeft.value = 10;
  111. if (cooldownInterval) clearInterval(cooldownInterval);
  112. cooldownInterval = setInterval(() => {
  113. cooldownLeft.value--;
  114. if (cooldownLeft.value <= 0) {
  115. if (cooldownInterval) clearInterval(cooldownInterval);
  116. }
  117. }, 1000);
  118. }
  119. const authStore = useAuthStore();
  120. let ws: WebSocket | null = null;
  121. let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  122. function connectWebSocket() {
  123. const token = localStorage.getItem("token");
  124. if (!token) return;
  125. ws = new WebSocket(`${WS_BASE_URL}/chat?token=${encodeURIComponent(token)}&order_id=${props.orderId}`);
  126. ws.onopen = () => {
  127. wsConnected.value = true;
  128. };
  129. ws.onmessage = (event) => {
  130. try {
  131. const msg = JSON.parse(event.data);
  132. if (msg.type === "presence") {
  133. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  134. const otherRole = myRole === 'admin' ? 'user' : 'admin';
  135. otherPartyOnline.value = msg.online_roles.includes(otherRole);
  136. return;
  137. }
  138. if (msg.type === "typing" || msg.type === "stop_typing") {
  139. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  140. const isFromOther = (msg.is_admin && myRole === 'user') || (!msg.is_admin && myRole === 'admin');
  141. if (isFromOther) {
  142. isOtherPartyTyping.value = (msg.type === "typing");
  143. scrollToBottom();
  144. }
  145. return;
  146. }
  147. // If it's a message (type 'message' or has essential fields)
  148. if (msg.type === "message" || (msg.message && msg.id)) {
  149. // Use lenient comparison (==) for ID to avoid string/int mismatches
  150. const exists = messages.value.some(m => m.id == msg.id);
  151. if (!exists) {
  152. messages.value.push(msg);
  153. scrollToBottom();
  154. // Audio & Read Receipt for incoming messages
  155. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  156. const isMsgFromMe = (msg.is_from_admin && myRole === 'admin') || (!msg.is_from_admin && myRole === 'user');
  157. if (!isMsgFromMe) {
  158. authStore.playChatSound();
  159. ws?.send("read");
  160. setTimeout(() => authStore.refreshUnreadCount(), 300);
  161. }
  162. }
  163. }
  164. } catch (e) {
  165. console.error("WS parse error:", e);
  166. }
  167. };
  168. ws.onclose = () => {
  169. wsConnected.value = false;
  170. // Auto-reconnect after 3 seconds
  171. reconnectTimer = setTimeout(() => connectWebSocket(), 3000);
  172. };
  173. ws.onerror = () => {
  174. ws?.close();
  175. };
  176. }
  177. function disconnectWebSocket() {
  178. if (reconnectTimer) {
  179. clearTimeout(reconnectTimer);
  180. reconnectTimer = null;
  181. }
  182. if (ws) {
  183. ws.onclose = null; // prevent auto-reconnect
  184. ws.close();
  185. ws = null;
  186. }
  187. wsConnected.value = false;
  188. }
  189. async function loadMessages() {
  190. try {
  191. messages.value = await getOrderMessages(props.orderId, locale.value);
  192. authStore.refreshUnreadCount();
  193. } catch (e) {
  194. console.error("Failed to load messages:", e);
  195. } finally {
  196. isLoading.value = false;
  197. }
  198. }
  199. async function handleSend() {
  200. const text = newMessage.value.trim();
  201. if (!text || isSending.value) return;
  202. isSending.value = true;
  203. try {
  204. const response = await sendOrderMessage(props.orderId, text, locale.value);
  205. newMessage.value = "";
  206. if (textareaRef.value) {
  207. textareaRef.value.style.height = "auto";
  208. }
  209. // Add message to list only if it hasn't arrived via WS yet
  210. if (!messages.value.some(m => m.id == response.id)) {
  211. const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
  212. messages.value.push({
  213. id: response.id,
  214. message: text,
  215. is_from_admin: myRole === 'admin',
  216. created_at: new Date().toISOString()
  217. });
  218. scrollToBottom();
  219. }
  220. // Start flood control cooldown
  221. startCooldown();
  222. } catch (e) {
  223. console.error("Failed to send message:", e);
  224. } finally {
  225. isSending.value = false;
  226. }
  227. }
  228. function scrollToBottom() {
  229. nextTick(() => {
  230. if (messagesContainer.value) {
  231. messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
  232. }
  233. });
  234. }
  235. function autoResize(e: Event) {
  236. const el = e.target as HTMLTextAreaElement;
  237. el.style.height = "auto";
  238. el.style.height = Math.min(el.scrollHeight, 80) + "px";
  239. }
  240. function onTextareaInput(e: Event) {
  241. if (wsConnected.value && ws) {
  242. ws.send("typing");
  243. if (typingTimeout) clearTimeout(typingTimeout);
  244. typingTimeout = setTimeout(() => {
  245. if (ws && ws.readyState === WebSocket.OPEN) {
  246. ws.send("stop_typing");
  247. }
  248. }, 2000);
  249. }
  250. }
  251. function formatTime(dt: string) {
  252. if (!dt) return "";
  253. const d = new Date(dt);
  254. return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
  255. }
  256. onMounted(async () => {
  257. await loadMessages();
  258. scrollToBottom();
  259. connectWebSocket();
  260. });
  261. onUnmounted(() => {
  262. disconnectWebSocket();
  263. });
  264. watch(() => props.orderId, async () => {
  265. disconnectWebSocket();
  266. isLoading.value = true;
  267. await loadMessages();
  268. scrollToBottom();
  269. connectWebSocket();
  270. });
  271. </script>
  272. <style scoped>
  273. .scrollbar-thin::-webkit-scrollbar { width: 4px; }
  274. .scrollbar-thin::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 9999px; }
  275. .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
  276. </style>