113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
|
import type { Ticket, User } from "@/payload-types";
|
|
import { cn } from "@/lib/utils";
|
|
import { HistoryIcon } from "lucide-react";
|
|
|
|
interface TicketTimelineProps {
|
|
readonly ticket: Pick<Ticket, "activity" | "reporter">;
|
|
readonly currentUserId: number;
|
|
}
|
|
|
|
type UserRelation = number | User | null | undefined;
|
|
|
|
function resolveUser(relation: UserRelation): User | null {
|
|
return typeof relation === "object" && relation ? relation : null;
|
|
}
|
|
|
|
function userId(relation: UserRelation): number | null {
|
|
return typeof relation === "object" && relation ? relation.id : (relation ?? null);
|
|
}
|
|
|
|
function displayName(relation: UserRelation, fallback: string): string {
|
|
const user = resolveUser(relation);
|
|
if (user) return user.payloadDisplayName || user.username || user.displayName;
|
|
if (typeof relation === "number") return `User #${relation}`;
|
|
return fallback;
|
|
}
|
|
|
|
function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return "Unknown time";
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(new Date(iso));
|
|
}
|
|
|
|
export function TicketTimeline({ ticket, currentUserId }: TicketTimelineProps) {
|
|
const activities = [...(ticket.activity ?? [])].sort(
|
|
(left, right) =>
|
|
new Date(left.created_at ?? 0).getTime() - new Date(right.created_at ?? 0).getTime(),
|
|
);
|
|
const reporterName = displayName(ticket.reporter, "Reporter");
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{activities.length === 0 && (
|
|
<p className="py-4 text-center text-sm italic text-muted-foreground">No activity yet.</p>
|
|
)}
|
|
{activities.map((activity, index) => {
|
|
const key = activity.id ?? `${activity.type}-${activity.created_at ?? "unknown"}-${index}`;
|
|
const content = convertLexicalToPlaintext({ data: activity.content });
|
|
|
|
if (activity.type === "system") {
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex flex-wrap items-center justify-center gap-2 px-2 py-2 text-center text-xs text-muted-foreground"
|
|
>
|
|
<span aria-hidden="true" className="size-1.5 rounded-full bg-muted-foreground/50" />
|
|
<HistoryIcon className="size-3.5" />
|
|
<span>{content || "Ticket activity updated"}</span>
|
|
<time className="font-mono text-[10px]" dateTime={activity.created_at ?? undefined}>
|
|
{formatDate(activity.created_at)}
|
|
</time>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isClientMessage = activity.type === "client_message";
|
|
const authorId = userId(activity.author);
|
|
const authorName = displayName(
|
|
activity.author,
|
|
isClientMessage ? reporterName : "Staff member",
|
|
);
|
|
const bubbleLabel = isClientMessage
|
|
? authorId === currentUserId
|
|
? "You"
|
|
: reporterName
|
|
: "Staff";
|
|
|
|
return (
|
|
<div key={key} className={cn("flex", isClientMessage ? "justify-end" : "justify-start")}>
|
|
<div
|
|
className={cn(
|
|
"flex max-w-[min(85%,42rem)] flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
|
|
isClientMessage
|
|
? "border-blue-500/30 bg-blue-500/10"
|
|
: "border-amber-500/30 bg-amber-500/5",
|
|
)}
|
|
>
|
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
{bubbleLabel}
|
|
</span>
|
|
<div
|
|
className={cn(
|
|
"prose prose-sm max-w-none prose-invert [&_p]:my-1 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal",
|
|
isClientMessage ? "text-blue-100/90" : "text-amber-100/90",
|
|
)}
|
|
>
|
|
<RichText data={activity.content} />
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{authorName} · {formatDate(activity.created_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|