feat : big update
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ImagePlus, MessageCircle, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from "@/components/ui/message";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
|
||||
imageObjectKey: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
|
||||
type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
|
||||
status: "sending" | "sent" };
|
||||
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
||||
const emojiChoices = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
||||
const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`;
|
||||
|
||||
function CommissionMessageImage({ src }: { src: string }) {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
if (imageRef.current?.complete)
|
||||
setState(imageRef.current.naturalWidth > 0 ? "loaded" : "error");
|
||||
}, [src]);
|
||||
|
||||
return <Dialog>
|
||||
<DialogTrigger render={<button type="button" disabled={state !== "loaded"}
|
||||
className="block max-w-full cursor-zoom-in overflow-hidden rounded-lg disabled:cursor-default" aria-label="ขยายรูปภาพที่แนบ" />}>
|
||||
{state === "loading" && <Skeleton className="h-40 w-48 max-w-full rounded-lg" aria-label="กำลังโหลดรูปภาพ" />}
|
||||
{state === "error" && <span className="block px-4 py-2.5 text-xs">โหลดรูปภาพไม่สำเร็จ</span>}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img ref={imageRef} src={src} alt="รูปภาพที่แนบในข้อความ"
|
||||
onLoad={() => setState("loaded")} onError={() => setState("error")}
|
||||
className={cn("max-h-72 max-w-full object-contain", state === "loaded" ? "block" : "hidden")} />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-fit max-h-[90svh] max-w-[calc(100%-2rem)] p-2 sm:max-w-[calc(100%-2rem)]">
|
||||
<DialogTitle className="sr-only">รูปภาพที่แนบในข้อความ</DialogTitle>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt="รูปภาพที่แนบในข้อความขนาดใหญ่" className="max-h-[calc(90svh-1rem)] max-w-full object-contain" />
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: {
|
||||
ticketId: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const stayAtBottom = useRef(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const imagePreviewRef = useRef<string | null>(null);
|
||||
const optimisticImageUrls = useRef(new Set<string>());
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
|
||||
const nextOptimisticId = useRef(0);
|
||||
const [reactionFor, setReactionFor] = useState<string | null>(null);
|
||||
const [optimisticReactions, setOptimisticReactions] = useState<Record<string, OptimisticReaction>>({});
|
||||
const pendingReactions = useRef(new Set<string>());
|
||||
const [deleteFor, setDeleteFor] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(() => new Set());
|
||||
const [typing, setTyping] = useState<{ userId: string; name: string } | null>(null);
|
||||
const lastTypingSent = useRef(0);
|
||||
const typingActive = useRef(false);
|
||||
const sendingRef = useRef(false);
|
||||
const deferredRefresh = useRef(false);
|
||||
const visibleOptimisticMessages = optimisticMessages.filter((message) => !message.serverId ||
|
||||
!messages.some((saved) => saved.id === message.serverId));
|
||||
const visibleMessages = messages.filter((message) => !deletedMessageIds.has(message.id));
|
||||
|
||||
useEffect(() => () => {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
for (const message of optimisticMessages) {
|
||||
if (message.serverId && messages.some((saved) => saved.id === message.serverId) &&
|
||||
message.imageUrl && optimisticImageUrls.current.has(message.imageUrl)) {
|
||||
URL.revokeObjectURL(message.imageUrl);
|
||||
optimisticImageUrls.current.delete(message.imageUrl);
|
||||
}
|
||||
}
|
||||
}, [messages, optimisticMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const refresh = () => {
|
||||
if (sendingRef.current) deferredRefresh.current = true;
|
||||
else router.refresh();
|
||||
};
|
||||
const receive = (event: Event) => {
|
||||
const value = (event as MessageEvent<string>).data;
|
||||
if (!value.startsWith("typing:")) { refresh(); return; }
|
||||
try {
|
||||
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
|
||||
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
|
||||
if (typingTimeout) clearTimeout(typingTimeout);
|
||||
if (update.active) {
|
||||
setTyping({ userId: update.userId, name: update.name });
|
||||
typingTimeout = setTimeout(() => setTyping(null), 6000);
|
||||
} else setTyping((current) => current?.userId === update.userId ? null : current);
|
||||
} catch { /* Ignore malformed transient signals. */ }
|
||||
};
|
||||
source.addEventListener("changed", receive);
|
||||
source.addEventListener("open", refresh);
|
||||
return () => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout);
|
||||
source.removeEventListener("changed", receive);
|
||||
source.removeEventListener("open", refresh);
|
||||
source.close();
|
||||
};
|
||||
}, [ticketId, userId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current;
|
||||
if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [messages, optimisticMessages]);
|
||||
|
||||
function sendTyping(active: boolean) {
|
||||
if (status !== "open") return;
|
||||
const now = Date.now();
|
||||
if (active && typingActive.current && now - lastTypingSent.current < 4000) return;
|
||||
if (!active && !typingActive.current) return;
|
||||
typingActive.current = active;
|
||||
if (active) lastTypingSent.current = now;
|
||||
void fetch(`/api/commission/tickets/${ticketId}/typing`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
function setAttachment(file: File | null) {
|
||||
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
|
||||
const preview = file ? URL.createObjectURL(file) : null;
|
||||
imagePreviewRef.current = preview;
|
||||
setImage(file);
|
||||
setImagePreview(preview);
|
||||
}
|
||||
|
||||
function chooseAttachment(file: File | null) {
|
||||
if (file && (file.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))) {
|
||||
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setAttachment(file);
|
||||
}
|
||||
|
||||
function handleComposerPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
||||
const pastedImage = Array.from(event.clipboardData.items)
|
||||
.find((item) => item.kind === "file" && item.type.startsWith("image/"))?.getAsFile();
|
||||
if (!pastedImage) return;
|
||||
event.preventDefault();
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
chooseAttachment(pastedImage);
|
||||
}
|
||||
|
||||
async function send(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy || (!text.trim() && !image)) return;
|
||||
setBusy(true); setError("");
|
||||
sendingRef.current = true;
|
||||
const form = event.currentTarget;
|
||||
const originalText = text;
|
||||
const originalImage = image;
|
||||
const localId = String(++nextOptimisticId.current);
|
||||
const imageUrl = image ? URL.createObjectURL(image) : null;
|
||||
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
|
||||
setOptimisticMessages((current) => [...current.filter((message) => !message.serverId ||
|
||||
!messages.some((saved) => saved.id === message.serverId)), {
|
||||
localId, serverId: null, text: text.trim(), imageUrl, status: "sending",
|
||||
}]);
|
||||
stayAtBottom.current = true;
|
||||
try {
|
||||
const data = new FormData(form);
|
||||
data.set("text", text.trim());
|
||||
if (image) data.set("image", image);
|
||||
else data.delete("image");
|
||||
form.reset(); setText(""); setAttachment(null); sendTyping(false);
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/messages`, { method: "POST", body: data });
|
||||
if (!response.ok) throw new Error("ส่งข้อความไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
const result = await response.json() as { messageId: string };
|
||||
setOptimisticMessages((current) => current.map((message) => message.localId === localId ?
|
||||
{ ...message, serverId: result.messageId, status: "sent" } : message));
|
||||
stayAtBottom.current = true;
|
||||
sendingRef.current = false; deferredRefresh.current = false; router.refresh();
|
||||
} catch (cause) {
|
||||
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
|
||||
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
|
||||
setText(originalText); setAttachment(originalImage);
|
||||
setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ");
|
||||
sendingRef.current = false;
|
||||
if (deferredRefresh.current) { deferredRefresh.current = false; router.refresh(); }
|
||||
}
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function react(messageId: string, emoji: string) {
|
||||
setReactionFor(null);
|
||||
const key = reactionKey(messageId, emoji);
|
||||
if (pendingReactions.current.has(key)) return;
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message) return;
|
||||
const savedActive = message.reactions.some((item) => item.emoji === emoji && item.userId === userId);
|
||||
const previous = optimisticReactions[key];
|
||||
const currentActive = previous?.status === "sent" && previous.active === savedActive
|
||||
? savedActive : previous?.active ?? savedActive;
|
||||
pendingReactions.current.add(key);
|
||||
setError("");
|
||||
setOptimisticReactions((current) => ({ ...current, [key]: { active: !currentActive, status: "sending" } }));
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/reactions`, { method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messageId, emoji }) });
|
||||
if (!response.ok) throw new Error();
|
||||
const result = await response.json() as { active: boolean };
|
||||
setOptimisticReactions((current) => ({ ...current, [key]: { active: result.active, status: "sent" } }));
|
||||
router.refresh();
|
||||
} catch {
|
||||
setOptimisticReactions((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setError("เปลี่ยนรีแอ็กชันไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
} finally { pendingReactions.current.delete(key); }
|
||||
}
|
||||
|
||||
async function deleteMessage(messageId: string) {
|
||||
if (deletingId) return;
|
||||
setDeleteFor(null); setDeletingId(messageId); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/messages/${messageId}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error();
|
||||
setDeletedMessageIds((current) => new Set(current).add(messageId));
|
||||
setReactionFor(null);
|
||||
router.refresh();
|
||||
} catch { setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
finally { setDeletingId(null); }
|
||||
}
|
||||
|
||||
async function toggleStatus() {
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/status`, { method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status === "open" ? "closed" : "open" }) });
|
||||
if (!response.ok) throw new Error();
|
||||
router.refresh();
|
||||
} catch { setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
}
|
||||
|
||||
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return <Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
|
||||
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
||||
<CardAction className="flex items-center gap-2">
|
||||
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
|
||||
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
|
||||
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
|
||||
</Button>}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent ref={scrollRef} role="log" aria-label="ข้อความใน Ticket" aria-live="polite"
|
||||
onScroll={(event) => { const viewport = event.currentTarget;
|
||||
stayAtBottom.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; }}
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-muted/20 px-3 py-5 sm:px-5">
|
||||
{visibleMessages.length === 0 && visibleOptimisticMessages.length === 0 ? <Empty className="h-full">
|
||||
<EmptyHeader><EmptyMedia variant="icon"><MessageCircle /></EmptyMedia>
|
||||
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
|
||||
<EmptyDescription>ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty> : <MessageGroup className="gap-5">
|
||||
{visibleMessages.map((message) => {
|
||||
const mine = message.authorId === userId;
|
||||
const sentAt = new Date(message.createdAt);
|
||||
const counts = emojiChoices.map((emoji) => {
|
||||
const savedCount = message.reactions.filter((reaction) => reaction.emoji === emoji).length;
|
||||
const savedActive = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.userId === userId);
|
||||
const update = optimisticReactions[reactionKey(message.id, emoji)];
|
||||
const visibleUpdate = update && (update.status === "sending" || update.active !== savedActive) ? update : null;
|
||||
const active = visibleUpdate?.active ?? savedActive;
|
||||
const count = savedCount + (visibleUpdate && active !== savedActive ? active ? 1 : -1 : 0);
|
||||
return { emoji, count, active, pending: update?.status === "sending" };
|
||||
}).filter(({ count }) => count > 0);
|
||||
return <Message key={message.id} align={mine ? "end" : "start"}>
|
||||
{!mine && <MessageAvatar><Avatar size="sm"><AvatarFallback>
|
||||
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
|
||||
</AvatarFallback></Avatar></MessageAvatar>}
|
||||
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
|
||||
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
|
||||
<Bubble variant={message.imageObjectKey ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
|
||||
<BubbleContent className={cn("max-w-full", message.imageObjectKey ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm", mine ? "rounded-br-md" : "rounded-bl-md")}>
|
||||
{message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageObjectKey && "pb-2")}>{message.text}</p>}
|
||||
{message.imageObjectKey && <CommissionMessageImage src={`/api/commission/tickets/${ticketId}/images/${message.id}`} />}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}>
|
||||
{counts.map(({ emoji, count, active, pending }) => <Button key={emoji} type="button" size="xs"
|
||||
variant={active ? "secondary" : "outline"} disabled={status !== "open" || pending}
|
||||
aria-label={`${emoji} ${count} คน`} aria-pressed={active}
|
||||
onClick={() => react(message.id, emoji)}><span className={pending ? "text-muted-foreground" : "text-foreground"}>
|
||||
{emoji} {count}
|
||||
</span></Button>)}
|
||||
</div>}
|
||||
<MessageFooter className="gap-1 px-1">
|
||||
<time dateTime={sentAt.toISOString()} title={sentAt.toLocaleString("th-TH")}>
|
||||
{sentAt.toLocaleTimeString("th-TH", { hour: "2-digit", minute: "2-digit" })}
|
||||
</time>
|
||||
{status === "open" && <Popover open={reactionFor === message.id}
|
||||
onOpenChange={(open) => setReactionFor(open ? message.id : null)}>
|
||||
<PopoverTrigger render={<Button type="button" variant="ghost" size="icon-xs" aria-label="เพิ่มรีแอ็กชัน" />}>
|
||||
<SmilePlus />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={mine ? "end" : "start"} className="w-auto">
|
||||
<div className="flex gap-1" aria-label="เลือกรีแอ็กชัน">
|
||||
{emojiChoices.map((emoji) => <Button key={emoji} type="button" variant="ghost" size="icon-sm"
|
||||
aria-label={`ตอบสนอง ${emoji}`} disabled={optimisticReactions[reactionKey(message.id, emoji)]?.status === "sending"}
|
||||
onClick={() => react(message.id, emoji)}>{emoji}</Button>)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>}
|
||||
{mine && <AlertDialog open={deleteFor === message.id}
|
||||
onOpenChange={(open) => setDeleteFor(open ? message.id : null)}>
|
||||
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-xs"
|
||||
aria-label="ลบข้อความ" disabled={Boolean(deletingId)} />}><Trash2 /></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>ลบข้อความนี้?</AlertDialogTitle>
|
||||
<AlertDialogDescription>ข้อความและรูปภาพที่แนบจะถูกลบถาวร</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void deleteMessage(message.id)}>ลบข้อความ</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>}
|
||||
</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>;
|
||||
})}
|
||||
{visibleOptimisticMessages.map((message) => <Message key={message.localId} align="end">
|
||||
<MessageContent className="w-auto max-w-[min(82%,36rem)] items-end gap-1">
|
||||
<MessageHeader className="px-1">คุณ</MessageHeader>
|
||||
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-full">
|
||||
<BubbleContent className={cn("max-w-full", message.imageUrl ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm")}>
|
||||
{message.text && <p className={cn("whitespace-pre-wrap", message.status === "sending" ? "text-muted-foreground" : "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
|
||||
{message.imageUrl && <div className={cn(message.status === "sending" && "opacity-50")}>
|
||||
<CommissionMessageImage src={message.imageUrl} />
|
||||
</div>}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter className="gap-1 px-1">{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>)}
|
||||
</MessageGroup>}
|
||||
</CardContent>
|
||||
<CardFooter className="block bg-card p-3 sm:p-4">
|
||||
{typing && status === "open" && <p role="status" className="mb-2 text-xs text-muted-foreground">{typing.name} typing...</p>}
|
||||
{status === "open" ? <form onSubmit={send}><FieldGroup className="gap-2">
|
||||
{image && <div className="relative w-fit rounded-lg border bg-muted/50 p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{imagePreview && <img src={imagePreview} alt="ตัวอย่างรูปภาพที่จะแนบ" className="size-28 rounded-md bg-muted object-contain" />}
|
||||
<Button type="button" variant="secondary" size="icon-xs" aria-label="นำรูปภาพออก"
|
||||
className="absolute top-1 right-1 shadow-sm"
|
||||
onClick={() => { setAttachment(null); if (fileRef.current) fileRef.current.value = ""; }}><X /></Button>
|
||||
</div>}
|
||||
<Field><FieldLabel htmlFor="commission-message" className="sr-only">ข้อความ</FieldLabel>
|
||||
<InputGroup className="min-h-12 rounded-2xl">
|
||||
<InputGroupAddon align="inline-start" className="self-end pb-1.5">
|
||||
<InputGroupButton type="button" variant="ghost" size="icon-sm" disabled={busy}
|
||||
aria-label="แนบรูปภาพ" onClick={() => fileRef.current?.click()}><ImagePlus /></InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
<InputGroupTextarea id="commission-message" name="text" value={text}
|
||||
onChange={(event) => { const next = event.target.value; setText(next); sendTyping(Boolean(next.trim())); }}
|
||||
onBlur={() => sendTyping(false)} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste}
|
||||
placeholder="พิมพ์ข้อความ..." maxLength={4000} disabled={busy} aria-describedby="commission-message-hint"
|
||||
className="max-h-32 min-h-11 min-w-0 px-1.5 py-2.5" />
|
||||
<InputGroupAddon align="inline-end" className="self-end pb-1.5">
|
||||
<InputGroupButton type="submit" variant="default" size="sm"
|
||||
aria-label={busy ? "กำลังส่งข้อความ" : "ส่งข้อความ"} disabled={busy || (!text.trim() && !image)}>
|
||||
<SendHorizontal data-icon="inline-start" /><span className="hidden sm:inline">{busy ? "กำลังส่ง..." : "ส่ง"}</span>
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<span id="commission-message-hint" className="sr-only">กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่</span>
|
||||
</Field>
|
||||
<input ref={fileRef} id="commission-image" name="image" type="file" aria-label="แนบรูปภาพ"
|
||||
className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
chooseAttachment(selected);
|
||||
if (selected && (selected.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(selected.type))) event.target.value = "";
|
||||
}} />
|
||||
</FieldGroup></form> : <p className="text-center text-sm text-muted-foreground">Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง</p>}
|
||||
{error && <Alert variant="destructive" className="mt-3"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
</CardFooter>
|
||||
</Card>;
|
||||
}
|
||||
Reference in New Issue
Block a user