1175 lines
45 KiB
TypeScript
1175 lines
45 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ClipboardEvent,
|
|
type FormEvent,
|
|
type KeyboardEvent,
|
|
} from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
ImagePlus,
|
|
MessageCircle,
|
|
ReplyIcon,
|
|
SendHorizontal,
|
|
SmilePlus,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { Avatar, AvatarFallback, AvatarImage } 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";
|
|
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
|
|
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
|
|
|
|
type ChatMessage = {
|
|
id: string;
|
|
authorId: string;
|
|
authorName: string;
|
|
text: string | null;
|
|
authorImage: string | null;
|
|
replyToId: string | null;
|
|
imageUrl: string | null;
|
|
createdAt: Date;
|
|
reactions: { userId: string; emoji: string }[];
|
|
};
|
|
type OptimisticMessage = {
|
|
localId: string;
|
|
serverId: string | null;
|
|
text: string;
|
|
imageUrl: string | null;
|
|
replyToId: string | null;
|
|
status: "sending" | "sent";
|
|
};
|
|
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
|
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
|
|
const reactionKey = (messageId: string, emoji: string) =>
|
|
`${messageId}:${emoji}`;
|
|
|
|
function ReactionEmoji({ emoji }: { emoji: string }) {
|
|
const [failed, setFailed] = useState(false);
|
|
const unified = Array.from(emoji, (character) =>
|
|
character.codePointAt(0)!.toString(16),
|
|
).join("-");
|
|
if (failed) return <span aria-hidden="true">{emoji}</span>;
|
|
// CDN images are already 64 px assets displayed at 16 px.
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
return (
|
|
<img
|
|
src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${unified}.png`}
|
|
alt=""
|
|
width={16}
|
|
height={16}
|
|
loading="lazy"
|
|
decoding="async"
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ReplyPreview({ message }: { message: ChatMessage }) {
|
|
return (
|
|
<div className="max-w-full rounded-md bg-muted/50 px-2 py-1 text-xs">
|
|
<span className="font-medium">{message.authorName}</span>
|
|
<p className="truncate text-muted-foreground">
|
|
{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
ticketTitle,
|
|
userId,
|
|
status,
|
|
messages,
|
|
admin,
|
|
}: {
|
|
ticketId: string;
|
|
ticketTitle: 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 [replyToId, setReplyToId] = useState<string | null>(null);
|
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
|
const [closedAlertOpen, setClosedAlertOpen] = useState(false);
|
|
const [statusBusy, setStatusBusy] = useState(false);
|
|
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),
|
|
);
|
|
const visibleMessageById = new Map(
|
|
visibleMessages.map((message) => [message.id, message]),
|
|
);
|
|
const replyTarget = replyToId
|
|
? (visibleMessageById.get(replyToId) ?? null)
|
|
: null;
|
|
|
|
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 originalTitle = document.title;
|
|
let managedTitle = ticketTitle;
|
|
let unread = 0;
|
|
document.title = ticketTitle;
|
|
const markRead = () => {
|
|
if (document.hidden || !document.hasFocus()) return;
|
|
unread = 0;
|
|
managedTitle = ticketTitle;
|
|
document.title = ticketTitle;
|
|
};
|
|
document.addEventListener("visibilitychange", markRead);
|
|
window.addEventListener("focus", markRead);
|
|
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:")) {
|
|
if (value === "status:closed" && !admin) setClosedAlertOpen(true);
|
|
const message = parseCommissionMessageEvent(value);
|
|
if (
|
|
message &&
|
|
message.authorId !== userId &&
|
|
(document.hidden || !document.hasFocus())
|
|
) {
|
|
unread += 1;
|
|
managedTitle = `(${unread}) ${ticketTitle}`;
|
|
document.title = managedTitle;
|
|
}
|
|
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();
|
|
document.removeEventListener("visibilitychange", markRead);
|
|
window.removeEventListener("focus", markRead);
|
|
if (document.title === managedTitle) document.title = originalTitle;
|
|
};
|
|
}, [ticketId, ticketTitle, userId, admin, router]);
|
|
|
|
useEffect(() => {
|
|
const viewport = scrollRef.current;
|
|
if (viewport && stayAtBottom.current)
|
|
viewport.scrollTop = viewport.scrollHeight;
|
|
}, [messages, optimisticMessages]);
|
|
|
|
useEffect(() => {
|
|
const viewport = scrollRef.current;
|
|
if (!viewport) return;
|
|
const observer = new ResizeObserver(() => {
|
|
if (stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight;
|
|
});
|
|
observer.observe(viewport);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
function sendTyping(active: boolean) {
|
|
if (status !== "open") return;
|
|
// eslint-disable-next-line react-hooks/purity -- This function runs only from input event handlers.
|
|
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,
|
|
replyToId: replyTarget?.id ?? null,
|
|
status: "sending",
|
|
},
|
|
]);
|
|
stayAtBottom.current = true;
|
|
try {
|
|
const data = new FormData(form);
|
|
data.set("text", text.trim());
|
|
if (replyTarget) data.set("replyToId", replyTarget.id);
|
|
else data.delete("replyToId");
|
|
if (image) data.set("image", image);
|
|
else data.delete("image");
|
|
form.reset();
|
|
setText("");
|
|
setAttachment(null);
|
|
setReplyToId(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);
|
|
setReplyToId(replyTarget?.id ?? null);
|
|
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));
|
|
if (replyToId === messageId) setReplyToId(null);
|
|
setReactionFor(null);
|
|
router.refresh();
|
|
} catch {
|
|
setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง");
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
}
|
|
|
|
async function toggleStatus() {
|
|
if (statusBusy) return;
|
|
setStatusBusy(true);
|
|
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();
|
|
setCloseConfirmOpen(false);
|
|
router.refresh();
|
|
} catch {
|
|
setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง");
|
|
} finally {
|
|
setStatusBusy(false);
|
|
}
|
|
}
|
|
|
|
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (
|
|
event.key === "Enter" &&
|
|
!event.shiftKey &&
|
|
!event.nativeEvent.isComposing
|
|
) {
|
|
event.preventDefault();
|
|
event.currentTarget.form?.requestSubmit();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{!admin && <CommissionPushEntryPrompt scope="customer" />}
|
|
<AlertDialog open={closedAlertOpen} onOpenChange={setClosedAlertOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Ticket นี้ปิดแล้ว</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogAction onClick={() => setClosedAlertOpen(false)}>
|
|
รับทราบ
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
<Card className="h-full min-h-0 min-w-0 gap-0 py-0">
|
|
<CardHeader className="shrink-0 border-b py-3">
|
|
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
|
|
<CardAction className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={status === "open" ? "outline" : "destructive"}
|
|
className={status === "open" ? "border-success/30 bg-success/10 text-success" : undefined}>
|
|
{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}
|
|
</Badge>
|
|
{admin &&
|
|
(status === "open" ? (
|
|
<AlertDialog
|
|
open={closeConfirmOpen}
|
|
onOpenChange={setCloseConfirmOpen}
|
|
>
|
|
<AlertDialogTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={statusBusy}
|
|
/>
|
|
}
|
|
>
|
|
ปิด Ticket
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>ปิด Ticket นี้?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว
|
|
และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => void toggleStatus()}>
|
|
ยืนยันการปิด
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
) : (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={statusBusy}
|
|
onClick={() => void toggleStatus()}
|
|
>
|
|
เปิดอีกครั้ง
|
|
</Button>
|
|
))}
|
|
</CardAction>
|
|
</CardHeader>
|
|
{!admin && status === "closed" && (
|
|
<Alert className="mx-3 mt-3 w-auto sm:mx-5">
|
|
<AlertDescription>
|
|
Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<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-3">
|
|
{visibleMessages.map((message) => {
|
|
const mine = message.authorId === userId;
|
|
const sentAt = new Date(message.createdAt);
|
|
const parent = message.replyToId
|
|
? visibleMessageById.get(message.replyToId)
|
|
: null;
|
|
const emojiValues = new Set([
|
|
...message.reactions.map((reaction) => reaction.emoji),
|
|
...Object.keys(optimisticReactions)
|
|
.filter((key) => key.startsWith(`${message.id}:`))
|
|
.map((key) => key.slice(message.id.length + 1)),
|
|
]);
|
|
const counts = [...emojiValues]
|
|
.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"}>
|
|
<MessageAvatar>
|
|
<Avatar size="sm">
|
|
{message.authorImage && (
|
|
<AvatarImage src={message.authorImage} alt="" />
|
|
)}
|
|
<AvatarFallback>
|
|
{message.authorName.trim().charAt(0).toUpperCase() ||
|
|
"?"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</MessageAvatar>
|
|
<MessageContent
|
|
className={cn(
|
|
"w-full gap-0.5",
|
|
mine ? "items-end" : "items-start",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
|
|
mine ? "translate-x-5" : "-translate-x-5",
|
|
)}
|
|
>
|
|
<time
|
|
dateTime={sentAt.toISOString()}
|
|
title={sentAt.toLocaleString("th-TH")}
|
|
>
|
|
{sentAt.toLocaleTimeString("th-TH", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</time>
|
|
</div>
|
|
<MessageHeader className="px-1">
|
|
{message.authorName}
|
|
</MessageHeader>
|
|
<div
|
|
className={cn(
|
|
"flex max-w-[min(82%,36rem)] items-center gap-1",
|
|
mine && "flex-row-reverse",
|
|
)}
|
|
>
|
|
<Bubble
|
|
variant={
|
|
message.imageUrl
|
|
? "ghost"
|
|
: mine
|
|
? "muted"
|
|
: "secondary"
|
|
}
|
|
className="min-w-0 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",
|
|
mine ? "rounded-br-md" : "rounded-bl-md",
|
|
)}
|
|
>
|
|
{parent && <ReplyPreview message={parent} />}
|
|
{message.text && (
|
|
<p
|
|
className={cn(
|
|
"whitespace-pre-wrap",
|
|
mine && "text-foreground",
|
|
message.imageUrl && "pb-2",
|
|
)}
|
|
>
|
|
{message.text}
|
|
</p>
|
|
)}
|
|
{message.imageUrl && (
|
|
<CommissionMessageImage src={message.imageUrl} />
|
|
)}
|
|
</BubbleContent>
|
|
</Bubble>
|
|
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-card/90 opacity-0 transition-opacity pointer-events-none group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
|
|
{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 p-0"
|
|
>
|
|
<EmojiPicker
|
|
lazyLoadEmojis
|
|
onEmojiClick={(value) =>
|
|
void react(message.id, value.emoji)
|
|
}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
{status === "open" && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label="ตอบกลับข้อความ"
|
|
onClick={() => {
|
|
setReplyToId(message.id);
|
|
document
|
|
.getElementById("commission-message")
|
|
?.focus();
|
|
}}
|
|
>
|
|
<ReplyIcon />
|
|
</Button>
|
|
)}
|
|
{mine && status === "open" && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{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={cn(
|
|
"inline-flex items-center gap-1",
|
|
pending
|
|
? "text-muted-foreground"
|
|
: "text-foreground",
|
|
)}
|
|
>
|
|
<ReactionEmoji emoji={emoji} /> {count}
|
|
</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</MessageContent>
|
|
</Message>
|
|
);
|
|
})}
|
|
{visibleOptimisticMessages.map((message) => (
|
|
<Message key={message.localId} align="end">
|
|
<MessageContent className="w-full items-end gap-1">
|
|
<Bubble
|
|
variant={message.imageUrl ? "ghost" : "muted"}
|
|
className="max-w-[min(82%,36rem)]"
|
|
>
|
|
<BubbleContent
|
|
className={cn(
|
|
"max-w-full",
|
|
message.imageUrl
|
|
? "border-0 bg-transparent p-0 shadow-none"
|
|
: "px-4 py-2.5 shadow-sm",
|
|
)}
|
|
>
|
|
{message.replyToId &&
|
|
visibleMessageById.get(message.replyToId) && (
|
|
<ReplyPreview
|
|
message={
|
|
visibleMessageById.get(message.replyToId)!
|
|
}
|
|
/>
|
|
)}
|
|
{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="w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center">
|
|
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
|
|
</MessageFooter>
|
|
</MessageContent>
|
|
</Message>
|
|
))}
|
|
</MessageGroup>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter className="block max-h-[50%] shrink-0 overflow-y-auto 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">
|
|
{replyTarget && (
|
|
<div className="flex items-start gap-2 rounded-lg border bg-muted/30 p-2">
|
|
<div className="min-w-0 flex-1">
|
|
<ReplyPreview message={replyTarget} />
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label="ยกเลิกการตอบกลับ"
|
|
onClick={() => setReplyToId(null)}
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{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>
|
|
</>
|
|
);
|
|
}
|