feat : update
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
@@ -10,8 +9,7 @@ export default async function AdminCommissionTicketPage({ params }: PageProps<"/
|
||||
await connection();
|
||||
if (!(await getAdminSession())) notFound();
|
||||
const { id } = await params;
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex max-w-6xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<Link href="/admin/commission" className="text-sm underline">กลับไปหน้า Ticket ทั้งหมด</Link>
|
||||
return <div className="flex h-svh flex-col overflow-hidden"><AdminHeader /><main className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col overflow-hidden p-3 sm:p-4">
|
||||
<CommissionTicketDetail id={id} admin />
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { desc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionTickets, users } from "@/db/schema";
|
||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
||||
import { CommissionTicketRename } from "@/components/commission/ticket-rename";
|
||||
import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { getCommissionAvailability } from "@/lib/commission/availability";
|
||||
|
||||
@@ -20,10 +23,8 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/
|
||||
const query = await searchParams;
|
||||
const rawPage = Number(query.page ?? 1);
|
||||
const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1;
|
||||
const [tickets, availability] = await Promise.all([getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht,
|
||||
customer: users.name }).from(commissionTickets)
|
||||
const [tickets, availability] = await Promise.all([getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht }).from(commissionTickets)
|
||||
.innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id))
|
||||
.innerJoin(users, eq(commissionTickets.userId, users.id))
|
||||
.orderBy(desc(commissionTickets.status), desc(commissionTickets.updatedAt), desc(commissionTickets.id))
|
||||
.limit(51).offset((page - 1) * 50), getCommissionAvailability()]);
|
||||
return <div className="min-h-svh"><AdminHeader /><CommissionLiveRefresh endpoint="/api/commission/events?scope=admin" userId={session.user.id} />
|
||||
@@ -34,14 +35,26 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/
|
||||
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
|
||||
{...availability} />
|
||||
{tickets.length === 0 && <p>ยังไม่มี Ticket</p>}
|
||||
{tickets.slice(0, 50).map(({ ticket, amount, customer }) => <Link key={ticket.id} href={`/admin/commission/${ticket.id}`}>
|
||||
<Card><CardHeader><CardTitle>{ticket.title || `#${ticket.id.slice(0, 8)}`} - {customer}</CardTitle>
|
||||
<CardAction><Badge variant={ticket.status === "open" ? "outline" : "destructive"}
|
||||
<CommissionTicketRename>
|
||||
{tickets.slice(0, 50).map(({ ticket, amount }) =>
|
||||
<Card key={ticket.id} className="relative"><CardHeader><CardTitle>
|
||||
<Link href={`/admin/commission/${ticket.id}`} className="after:absolute after:inset-0 after:rounded-xl">
|
||||
{ticket.title || `#${ticket.id.slice(0, 8)}`}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardAction className="relative z-10 flex flex-wrap items-center justify-end gap-2">
|
||||
<Badge variant={ticket.status === "open" ? "outline" : "destructive"}
|
||||
className={ticket.status === "open" ? "border-success/30 bg-success/10 text-success" : undefined}>
|
||||
{ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}
|
||||
</Badge></CardAction></CardHeader>
|
||||
</Badge>
|
||||
<Button type="button" variant="outline" size="sm" aria-label="เปลี่ยนชื่อ Ticket"
|
||||
data-ticket-rename-id={ticket.id} data-ticket-rename-title={ticket.title ?? ""}>
|
||||
<Pencil data-icon="inline-start" /> เปลี่ยนชื่อ
|
||||
</Button>
|
||||
</CardAction></CardHeader>
|
||||
<CardContent>฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")}</CardContent></Card>
|
||||
</Link>)}
|
||||
)}
|
||||
</CommissionTicketRename>
|
||||
<nav className="flex gap-4 text-sm" aria-label="Ticket pages">
|
||||
{page > 1 && <Link href={`/admin/commission?page=${page - 1}`} className="underline">ก่อนหน้า</Link>}
|
||||
{tickets.length > 50 && <Link href={`/admin/commission?page=${page + 1}`} className="underline">ถัดไป</Link>}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authorizeTicket = vi.fn();
|
||||
const selectLimit = vi.fn();
|
||||
const selectWhere = vi.fn((condition: SQL) => { void condition; return { limit: selectLimit }; });
|
||||
const insertReturning = vi.fn();
|
||||
const insertValues = vi.fn(() => ({ returning: insertReturning }));
|
||||
const updateWhere = vi.fn();
|
||||
const tx = { insert: vi.fn(() => ({ values: insertValues })), update: vi.fn(() => ({ set: () => ({ where: updateWhere }) })) };
|
||||
const notifyCommission = vi.fn();
|
||||
|
||||
vi.mock("@/lib/commission/tickets", () => ({ authorizeTicket }));
|
||||
vi.mock("@/lib/commission/server", () => ({ notifyCommission }));
|
||||
vi.mock("@/lib/commission/push", () => ({ sendCommissionMessagePush: vi.fn() }));
|
||||
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined }));
|
||||
vi.mock("next/server", () => ({ after: vi.fn() }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({
|
||||
select: () => ({ from: () => ({ where: selectWhere }) }),
|
||||
transaction: async (run: (value: typeof tx) => Promise<unknown>) => run(tx),
|
||||
}) }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const ticketId = "11111111-1111-4111-8111-111111111111";
|
||||
const parentId = "22222222-2222-4222-8222-222222222222";
|
||||
const messageId = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
function request(replyToId: string) {
|
||||
const body = new FormData();
|
||||
body.set("text", "A reply");
|
||||
body.set("replyToId", replyToId);
|
||||
return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages`, {
|
||||
method: "POST", headers: { Origin: "https://guide.sudloh.com" }, body,
|
||||
});
|
||||
}
|
||||
|
||||
function context() {
|
||||
return { params: Promise.resolve({ id: ticketId }) } as RouteContext<"/api/commission/tickets/[id]/messages">;
|
||||
}
|
||||
|
||||
describe("commission message replies", () => {
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
vi.clearAllMocks();
|
||||
authorizeTicket.mockResolvedValue({ ticket: { status: "open", userId: "user-1", title: "A ticket" },
|
||||
user: { id: "user-1", name: "User" } });
|
||||
selectLimit.mockResolvedValue([{ id: parentId }]);
|
||||
insertReturning.mockResolvedValue([{ id: messageId }]);
|
||||
});
|
||||
|
||||
it("accepts a reply only after looking up its parent in the same ticket", async () => {
|
||||
const response = await POST(request(parentId), context());
|
||||
expect(response.status).toBe(201);
|
||||
const condition = new PgDialect().sqlToQuery(selectWhere.mock.calls[0][0]);
|
||||
expect(condition.sql).toContain('"ticket_id"');
|
||||
expect(condition.params).toContain(ticketId);
|
||||
expect(condition.params).toContain(parentId);
|
||||
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ replyToId: parentId }));
|
||||
});
|
||||
|
||||
it("rejects a parent that is absent from this ticket", async () => {
|
||||
selectLimit.mockResolvedValue([]);
|
||||
const response = await POST(request(parentId), context());
|
||||
expect(response.status).toBe(404);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { after } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionMessages, commissionTickets } from "@/db/schema";
|
||||
@@ -10,6 +10,7 @@ import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
import * as z from "zod";
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/messages">) {
|
||||
try {
|
||||
@@ -25,8 +26,17 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
|
||||
const value = form.get("text");
|
||||
const body = typeof value === "string" ? value.trim() : "";
|
||||
const image = form.get("image");
|
||||
const replyValue = form.get("replyToId");
|
||||
const replyToId = replyValue === null || replyValue === "" ? null : replyValue;
|
||||
if (body.length > 4000 || (image && !(image instanceof File)))
|
||||
throw new HttpError(400, "invalid-message");
|
||||
if (replyToId !== null) {
|
||||
if (typeof replyToId !== "string" || !z.uuid().safeParse(replyToId).success)
|
||||
throw new HttpError(400, "invalid-reply");
|
||||
const [parent] = await getDb().select({ id: commissionMessages.id }).from(commissionMessages)
|
||||
.where(and(eq(commissionMessages.id, replyToId), eq(commissionMessages.ticketId, id))).limit(1);
|
||||
if (!parent) throw new HttpError(404, "reply-not-found");
|
||||
}
|
||||
if (!body && !image) throw new HttpError(400, "empty-message");
|
||||
let imageObjectKey: string | null = null;
|
||||
if (image instanceof File) {
|
||||
@@ -41,7 +51,7 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
|
||||
let messageId: string;
|
||||
try {
|
||||
messageId = await getDb().transaction(async (tx) => {
|
||||
const [message] = await tx.insert(commissionMessages).values({ ticketId: id, authorId: user.id,
|
||||
const [message] = await tx.insert(commissionMessages).values({ ticketId: id, authorId: user.id, replyToId,
|
||||
text: body || null, imageObjectKey, imageMimeType: image instanceof File ? image.type : null })
|
||||
.returning({ id: commissionMessages.id });
|
||||
await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id));
|
||||
|
||||
@@ -5,9 +5,10 @@ import { notifyCommission } from "@/lib/commission/server";
|
||||
import { authorizeTicket } from "@/lib/commission/tickets";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
import { isSingleEmoji } from "@/lib/commission/reaction";
|
||||
import * as z from "zod";
|
||||
|
||||
const inputSchema = z.object({ messageId: z.uuid(), emoji: z.enum(["👍", "❤️", "😂", "😮", "😢", "🎉"]) });
|
||||
const inputSchema = z.object({ messageId: z.uuid(), emoji: z.string().refine(isSingleEmoji) });
|
||||
|
||||
export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/reactions">) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
|
||||
const requireAdmin = vi.fn();
|
||||
const notifyCommission = vi.fn();
|
||||
const returning = vi.fn();
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where }));
|
||||
|
||||
vi.mock("@/lib/auth/server", () => ({ requireAdmin }));
|
||||
vi.mock("@/lib/commission/server", () => ({ notifyCommission }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ update: () => ({ set }) }) }));
|
||||
|
||||
const { PATCH } = await import("./route");
|
||||
const ticketId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function statusRequest(status: string, origin = "https://guide.sudloh.com") {
|
||||
return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/status`, {
|
||||
method: "PATCH", headers: { Origin: origin, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
function context() {
|
||||
return { params: Promise.resolve({ id: ticketId }) } as RouteContext<"/api/commission/tickets/[id]/status">;
|
||||
}
|
||||
|
||||
describe("commission ticket status", () => {
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
vi.clearAllMocks();
|
||||
requireAdmin.mockResolvedValue({ user: { id: "admin-1" } });
|
||||
returning.mockResolvedValue([{ userId: "customer-1" }]);
|
||||
});
|
||||
|
||||
it("publishes a distinct closure event for the customer", async () => {
|
||||
const response = await PATCH(statusRequest("closed"), context());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ status: "closed" });
|
||||
expect(notifyCommission).toHaveBeenCalledWith(ticketId, "customer-1", "status:closed");
|
||||
});
|
||||
|
||||
it("rejects non-admin and cross-origin attempts", async () => {
|
||||
expect((await PATCH(statusRequest("closed", "https://elsewhere.test"), context())).status).toBe(403);
|
||||
expect(requireAdmin).not.toHaveBeenCalled();
|
||||
requireAdmin.mockRejectedValueOnce(new HttpError(401, "unauthorized"));
|
||||
expect((await PATCH(statusRequest("closed"), context())).status).toBe(401);
|
||||
expect(notifyCommission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export async function PATCH(request: Request, context: RouteContext<"/api/commis
|
||||
.set({ status: parsed.data.status, updatedAt: new Date() })
|
||||
.where(eq(commissionTickets.id, id)).returning({ userId: commissionTickets.userId });
|
||||
if (!ticket) throw new HttpError(404, "ticket-not-found");
|
||||
await notifyCommission(id, ticket.userId);
|
||||
await notifyCommission(id, ticket.userId, `status:${parsed.data.status}`);
|
||||
return Response.json({ status: parsed.data.status });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
|
||||
const requireCommissionUser = vi.fn();
|
||||
const returning = vi.fn();
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where }));
|
||||
const write = vi.fn();
|
||||
|
||||
vi.mock("@/lib/commission/server", () => ({ requireCommissionUser }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ update: () => ({ set }) }) }));
|
||||
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write }), publicMediaUrl: (key: string) => `https://cdn.test/${key}` }));
|
||||
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
function profileRequest(name: string, image?: File, origin = "https://guide.sudloh.com") {
|
||||
const body = new FormData();
|
||||
body.set("name", name);
|
||||
if (image) body.set("image", image);
|
||||
return new Request("https://guide.sudloh.com/api/profile", { method: "POST", headers: { Origin: origin }, body });
|
||||
}
|
||||
|
||||
describe("profile update", () => {
|
||||
beforeEach(() => {
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
vi.clearAllMocks();
|
||||
requireCommissionUser.mockResolvedValue({ id: "user-1", image: null });
|
||||
returning.mockResolvedValue([{ name: "New Name", image: null }]);
|
||||
});
|
||||
|
||||
it("updates the signed-in user's display name", async () => {
|
||||
const response = await POST(profileRequest(" New Name "));
|
||||
expect(response.status).toBe(200);
|
||||
expect(set).toHaveBeenCalledWith({ name: "New Name" });
|
||||
expect(await response.json()).toEqual({ name: "New Name", image: null });
|
||||
});
|
||||
|
||||
it("rejects invalid names, image types, and cross-origin submissions", async () => {
|
||||
expect((await POST(profileRequest("x"))).status).toBe(400);
|
||||
expect((await POST(profileRequest("New Name", new File(["x"], "avatar.svg", { type: "image/svg+xml" })))).status).toBe(415);
|
||||
expect((await POST(profileRequest("New Name", undefined, "https://elsewhere.test"))).status).toBe(403);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates and stores an uploaded image", async () => {
|
||||
const bytes = await sharp({ create: { width: 8, height: 8, channels: 3, background: "red" } }).png().toBuffer();
|
||||
const response = await POST(profileRequest("New Name", new File([bytes], "avatar.png", { type: "image/png" })));
|
||||
expect(response.status).toBe(200);
|
||||
expect(write).toHaveBeenCalledWith(expect.stringMatching(/^profiles\/.+\.webp$/),
|
||||
expect.any(Uint8Array), { type: "image/webp", acl: "public-read" });
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ name: "New Name",
|
||||
image: expect.stringMatching(/^https:\/\/cdn\.test\/profiles\/.+\.webp$/) }));
|
||||
});
|
||||
|
||||
it("requires an authenticated user", async () => {
|
||||
requireCommissionUser.mockRejectedValueOnce(new HttpError(401, "unauthorized"));
|
||||
expect((await POST(profileRequest("New Name"))).status).toBe(401);
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { getDb } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { requireCommissionUser } from "@/lib/commission/server";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http";
|
||||
import { limitRequest } from "@/lib/security/rate-limit";
|
||||
|
||||
const MAX_PROFILE_IMAGE_BYTES = 6 * 1024 * 1024;
|
||||
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const user = await requireCommissionUser();
|
||||
await limitRequest("profile-update", user.id, 20);
|
||||
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
|
||||
throw new HttpError(415, "expected-multipart");
|
||||
return await withUploadSlot(async () => {
|
||||
const form = await boundedBody(request, MAX_PROFILE_IMAGE_BYTES + 64 * 1024).formData();
|
||||
const rawName = form.get("name");
|
||||
const name = typeof rawName === "string" ? rawName.trim() : "";
|
||||
if (name.length < 2 || name.length > 80) throw new HttpError(400, "invalid-display-name");
|
||||
const image = form.get("image");
|
||||
if (image !== null && !(image instanceof File)) throw new HttpError(400, "invalid-profile-image");
|
||||
let objectKey: string | null = null;
|
||||
if (image instanceof File) {
|
||||
if (image.size === 0 || image.size > MAX_PROFILE_IMAGE_BYTES ||
|
||||
!IMAGE_TYPES.includes(image.type as typeof IMAGE_TYPES[number]))
|
||||
throw new HttpError(415, "invalid-profile-image");
|
||||
const bytes = new Uint8Array(await image.arrayBuffer());
|
||||
await inspectImage(bytes, image.type as typeof IMAGE_TYPES[number]);
|
||||
const output = await sharp(bytes).rotate().resize(256, 256, { fit: "cover" }).webp({ quality: 82 }).toBuffer();
|
||||
objectKey = `profiles/${crypto.randomUUID()}.webp`;
|
||||
await (await getMediaStorage()).write(objectKey, output, { type: "image/webp", acl: "public-read" });
|
||||
}
|
||||
try {
|
||||
const [updated] = await getDb().update(users).set({ name,
|
||||
...(objectKey ? { image: publicMediaUrl(objectKey) } : {}) })
|
||||
.where(eq(users.id, user.id)).returning({ name: users.name, image: users.image });
|
||||
if (!updated) throw new HttpError(401, "unauthorized");
|
||||
if (objectKey && user.image) {
|
||||
const prefix = publicMediaUrl("profiles/");
|
||||
if (user.image.startsWith(prefix)) {
|
||||
const previousKey = `profiles/${user.image.slice(prefix.length)}`;
|
||||
if (/^profiles\/[0-9a-f-]{36}\.webp$/u.test(previousKey))
|
||||
await (await getMediaStorage()).delete(previousKey).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return Response.json(updated);
|
||||
} catch (cause) {
|
||||
if (objectKey) await (await getMediaStorage()).delete(objectKey).catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { connection } from "next/server";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { CommissionRequestForm } from "@/components/commission/request-form";
|
||||
import { CommissionSignOut } from "@/components/commission/sign-out";
|
||||
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getCustomerSession } from "@/lib/auth/server";
|
||||
import { getCatalogOptions } from "@/lib/guides/queries";
|
||||
@@ -32,7 +32,8 @@ async function CommissionContent() {
|
||||
const catalog = await getCatalogOptions();
|
||||
if (!catalog) return <p>ยังไม่มีข้อมูลตัวละครสำหรับสร้างคำขอ</p>;
|
||||
return <div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3"><Button variant="outline" nativeButton={false} render={<Link href="/commission/tickets" />}>ดู Ticket ของฉัน</Button><CommissionSignOut /></div>
|
||||
<CommissionPushEntryPrompt scope="customer" />
|
||||
<div className="flex items-center gap-3"><Button variant="outline" nativeButton={false} render={<Link href="/commission/tickets" />}>ดู Ticket ของฉัน</Button></div>
|
||||
<CommissionRequestForm catalog={catalog} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "next/link";
|
||||
import { connection } from "next/server";
|
||||
import { CommissionTicketDetail } from "@/components/commission/ticket-detail";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
@@ -7,8 +6,7 @@ export const instant = false;
|
||||
export default async function CommissionTicketPage({ params }: PageProps<"/commission/tickets/[id]">) {
|
||||
await connection();
|
||||
const { id } = await params;
|
||||
return <div className="min-h-svh"><SiteHeader /><main className="mx-auto flex max-w-6xl flex-col gap-6 p-4 py-10 sm:p-8">
|
||||
<Link href="/commission/tickets" className="text-sm underline">กลับไปหน้า Ticket ของฉัน</Link>
|
||||
return <div className="flex h-svh flex-col overflow-hidden"><SiteHeader /><main className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col overflow-hidden p-3 sm:p-4">
|
||||
<CommissionTicketDetail id={id} admin={false} />
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { connection } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionTickets } from "@/db/schema";
|
||||
import { CommissionLiveRefresh } from "@/components/commission/live-refresh";
|
||||
import { CommissionSignOut } from "@/components/commission/sign-out";
|
||||
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -29,10 +29,10 @@ export default async function CommissionTicketsPage() {
|
||||
.orderBy(desc(commissionCheckouts.createdAt)).limit(20),
|
||||
]);
|
||||
return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" userId={session.user.id} />
|
||||
<CommissionPushEntryPrompt scope="customer" />
|
||||
<main className="mx-auto flex max-w-4xl flex-col gap-5 p-4 py-10 sm:p-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="font-heading text-3xl font-semibold">Ticket ของฉัน</h1>
|
||||
<CommissionSignOut />
|
||||
</div>
|
||||
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
|
||||
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { getDb } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { SiteHeader } from "@/components/public/site-header";
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { ProfileForm } from "@/components/auth/profile-form";
|
||||
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
|
||||
import { getCustomerSession } from "@/lib/auth/server";
|
||||
|
||||
export const instant = false;
|
||||
|
||||
export default async function ProfilePage() {
|
||||
await connection();
|
||||
const session = await getCustomerSession();
|
||||
if (!session) redirect("/login?next=%2Fprofile");
|
||||
const [user] = await getDb().select({ name: users.name, email: users.email, image: users.image })
|
||||
.from(users).where(eq(users.id, session.user.id)).limit(1);
|
||||
if (!user) redirect("/login?next=%2Fprofile");
|
||||
return <div className="min-h-svh">{isAuthorizedAdmin(session.user) ? <AdminHeader /> : <SiteHeader />}
|
||||
<main className="mx-auto max-w-xl p-4 py-10 sm:p-8">
|
||||
<h1 className="mb-6 font-heading text-3xl font-semibold">โปรไฟล์ของฉัน</h1>
|
||||
<ProfileForm {...user} />
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
"discord.js": "^14.27.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-picker-react": "^4.22.3",
|
||||
"emoji-regex": "^11.0.0",
|
||||
"entities": "7.0.1",
|
||||
"ioredis": "^6.0.0",
|
||||
"lucide-react": "^1.35.0",
|
||||
@@ -54,6 +56,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/emoji-regex": "^9.2.2",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
@@ -689,6 +692,8 @@
|
||||
|
||||
"@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/emoji-regex": ["@types/[email protected]", "", { "dependencies": { "emoji-regex": "*" } }, "sha512-kg9a3lbO8WyO3LxZAhUtcS7X+u2FwptNcfsMCTsISiv0gV1BqLZlHgkL11XsG9n18t5uSPYtcQk6Zw8g7MbNRQ=="],
|
||||
|
||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -1067,7 +1072,9 @@
|
||||
|
||||
"embla-carousel-reactive-utils": ["[email protected]", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||
|
||||
"emoji-regex": ["[email protected]", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
"emoji-picker-react": ["[email protected]", "", { "dependencies": { "shipstyles": "1.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-jrAkkoN69l5lA2Xp6P5axqoK1YdHNS1+MKMlLxwltnnnaXrojUjZaolgI/U8vdx8vFWqG42yYqpLSR83f6pwiw=="],
|
||||
|
||||
"emoji-regex": ["[email protected]", "", {}, "sha512-2qQtc7syPcJmvF7BfKxBvwgvhzDth6u3q23+Jr4JOQUcmMGgmj7B4/ePZ2DmOhrrb77S8kMNHq2kilVASysfqA=="],
|
||||
|
||||
"encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
@@ -1779,6 +1786,8 @@
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shipstyles": ["[email protected]", "", {}, "sha512-1AcihfXo/mSxqeOdoKqq/5TNv+vSH0dBYPFq88PUX5FBkK5pRmY5TP9SuMNio85PQ+euUI67+KpSVk1cUVDCgA=="],
|
||||
|
||||
"side-channel": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
@@ -2093,6 +2102,8 @@
|
||||
|
||||
"eslint-plugin-import/tsconfig-paths": ["[email protected]", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y/emoji-regex": ["[email protected]", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"eslint-plugin-react/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
ImageIcon,
|
||||
@@ -10,30 +10,16 @@ import {
|
||||
BookOpenIcon,
|
||||
ClipboardListIcon,
|
||||
MessagesSquareIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
SwordsIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { unsubscribeCommissionPush } from "@/components/commission/push-client";
|
||||
import { AccountMenu } from "@/components/auth/account-menu";
|
||||
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
|
||||
|
||||
export function AdminHeader() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const signOut = async () => {
|
||||
await unsubscribeCommissionPush().catch(() => undefined);
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/admin/login");
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (<>
|
||||
<CommissionPushEntryPrompt scope="admin" />
|
||||
@@ -123,14 +109,7 @@ export function AdminHeader() {
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ออกจากระบบ"
|
||||
onClick={signOut}
|
||||
>
|
||||
<LogOutIcon />
|
||||
</Button>
|
||||
<AccountMenu admin />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOutIcon, UserRoundIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { unsubscribeCommissionPush } from "@/components/commission/push-client";
|
||||
|
||||
export function AccountMenu({ admin = false }: { admin?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { data } = authClient.useSession();
|
||||
if (!admin && !data?.user) return null;
|
||||
|
||||
async function signOut() {
|
||||
await unsubscribeCommissionPush().catch(() => undefined);
|
||||
await authClient.signOut();
|
||||
router.push(admin ? "/admin/login" : "/commission");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return <DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label="บัญชีของฉัน" />}>
|
||||
<UserRoundIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="truncate">{data?.user?.name || "บัญชีของฉัน"}</DropdownMenuLabel>
|
||||
<DropdownMenuItem render={<Link href="/profile" />}><UserRoundIcon />โปรไฟล์ของฉัน</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => void signOut()}><LogOutIcon />ออกจากระบบ</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function ProfileForm({ name: initialName, email, image: initialImage }: {
|
||||
name: string; email: string; image: string | null;
|
||||
}) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
|
||||
|
||||
function chooseFile(next: File | null) {
|
||||
setError("");
|
||||
if (next && (next.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(next.type))) {
|
||||
setError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
return;
|
||||
}
|
||||
setFile(next);
|
||||
setPreview(next ? URL.createObjectURL(next) : null);
|
||||
}
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("name", name.trim());
|
||||
if (file) form.set("image", file);
|
||||
const response = await fetch("/api/profile", { method: "POST", body: form });
|
||||
if (!response.ok) throw new Error("บันทึกโปรไฟล์ไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
window.location.reload();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "บันทึกโปรไฟล์ไม่สำเร็จ");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<CardHeader><CardTitle>ข้อมูลบัญชี</CardTitle><CardDescription>{email}</CardDescription></CardHeader>
|
||||
<CardContent><form id="profile-form" onSubmit={save}><FieldGroup>
|
||||
<Field><FieldLabel htmlFor="profile-image">รูปโปรไฟล์</FieldLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar size="lg" className="size-16">
|
||||
{(preview || initialImage) && <AvatarImage src={preview || initialImage || ""} alt="รูปโปรไฟล์" />}
|
||||
<AvatarFallback>{name.trim().charAt(0).toUpperCase() || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Input id="profile-image" type="file" accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
chooseFile(selected);
|
||||
if (selected && (selected.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(selected.type))) event.target.value = "";
|
||||
}} disabled={busy} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field><FieldLabel htmlFor="profile-name">ชื่อที่แสดง</FieldLabel>
|
||||
<Input id="profile-name" value={name} onChange={(event) => setName(event.target.value)}
|
||||
minLength={2} maxLength={80} required disabled={busy} />
|
||||
</Field>
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
</FieldGroup></form></CardContent>
|
||||
<CardFooter><Button type="submit" form="profile-form" disabled={busy || name.trim().length < 2 || name.trim().length > 80}>
|
||||
{busy ? "กำลังบันทึก..." : "บันทึกโปรไฟล์"}
|
||||
</Button></CardFooter>
|
||||
</Card>;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { setCommissionAvailability } from "@/app/admin/commission/actions";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -31,9 +30,8 @@ export function AdminCommissionAvailabilityControl({ isOpen, closureMessage }: {
|
||||
});
|
||||
}
|
||||
|
||||
return <Card><CardHeader><div className="flex items-center gap-3">
|
||||
<CardTitle>การรับ Commission</CardTitle><Badge variant={isOpen ? "default" : "secondary"}>
|
||||
{isOpen ? "เปิดรับ" : "ปิดรับ"}</Badge></div>
|
||||
return <Card><CardHeader>
|
||||
<CardTitle>การรับ Commission</CardTitle>
|
||||
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้</CardDescription>
|
||||
</CardHeader><CardContent className="flex flex-col gap-3">
|
||||
<label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { unsubscribeCommissionPush } from "./push-client";
|
||||
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
|
||||
|
||||
export function CommissionSignOut() {
|
||||
const router = useRouter();
|
||||
return <><CommissionPushEntryPrompt scope="customer" /><Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
|
||||
await unsubscribeCommissionPush().catch(() => undefined);
|
||||
await authClient.signOut();
|
||||
router.push("/commission");
|
||||
router.refresh();
|
||||
}}><LogOutIcon /></Button></>;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
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 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";
|
||||
@@ -23,13 +24,31 @@ 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;
|
||||
status: "sending" | "sent" };
|
||||
replyToId: string | null; status: "sending" | "sent" };
|
||||
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
|
||||
const emojiChoices = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
|
||||
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");
|
||||
@@ -74,6 +93,10 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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);
|
||||
@@ -87,6 +110,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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);
|
||||
@@ -125,6 +150,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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;
|
||||
@@ -154,15 +180,26 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
window.removeEventListener("focus", markRead);
|
||||
if (document.title === managedTitle) document.title = originalTitle;
|
||||
};
|
||||
}, [ticketId, ticketTitle, userId, router]);
|
||||
}, [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;
|
||||
@@ -213,15 +250,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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",
|
||||
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); sendTyping(false);
|
||||
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 };
|
||||
@@ -232,7 +271,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
} catch (cause) {
|
||||
if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); }
|
||||
setOptimisticMessages((current) => current.filter((message) => message.localId !== localId));
|
||||
setText(originalText); setAttachment(originalImage);
|
||||
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(); }
|
||||
@@ -277,6 +316,7 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
@@ -284,12 +324,16 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -299,17 +343,38 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
}
|
||||
}
|
||||
|
||||
return <>{!admin && <CommissionPushEntryPrompt scope="customer" />}<Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
|
||||
<CardHeader className="border-b py-3">
|
||||
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>
|
||||
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
|
||||
<CardAction className="flex flex-wrap 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>}
|
||||
{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; }}
|
||||
@@ -319,11 +384,17 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
<EmptyTitle>เริ่มบทสนทนา</EmptyTitle>
|
||||
<EmptyDescription>ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty> : <MessageGroup className="gap-5">
|
||||
</Empty> : <MessageGroup className="gap-3">
|
||||
{visibleMessages.map((message) => {
|
||||
const mine = message.authorId === userId;
|
||||
const sentAt = new Date(message.createdAt);
|
||||
const counts = emojiChoices.map((emoji) => {
|
||||
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)];
|
||||
@@ -333,42 +404,35 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
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>
|
||||
<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-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
|
||||
</AvatarFallback></Avatar></MessageAvatar>
|
||||
<MessageContent className={cn("w-full gap-0.5", mine ? "items-end" : "items-start")}>
|
||||
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
|
||||
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
|
||||
<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>
|
||||
{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>
|
||||
<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">
|
||||
<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 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 && <AlertDialog open={deleteFor === message.id}
|
||||
onOpenChange={(open) => setDeleteFor(open ? message.id : null)}>
|
||||
<AlertDialogTrigger render={<Button type="button" variant="ghost" size="icon-xs"
|
||||
@@ -384,29 +448,54 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
|
||||
</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>}
|
||||
<MessageFooter className={cn("w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center",
|
||||
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>
|
||||
</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">
|
||||
<MessageContent className="w-full items-end gap-1">
|
||||
<MessageHeader className="px-1">คุณ</MessageHeader>
|
||||
<Bubble variant={message.imageUrl ? "ghost" : "muted"} className="max-w-full">
|
||||
<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="gap-1 px-1">{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}</MessageFooter>
|
||||
<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 bg-card p-3 sm:p-4">
|
||||
<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" />}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { publicMediaUrl } from "@/lib/media/storage";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
import { CommissionRequestSummary } from "./request-summary";
|
||||
import { CommissionTicketChat } from "./ticket-chat";
|
||||
import { CommissionTicketRename } from "./ticket-rename";
|
||||
import { CommissionSlipDialog } from "./slip-dialog";
|
||||
import { CommissionTicketPanels } from "./ticket-panels";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export async function CommissionTicketDetail({ id, admin }: { id: string; admin: boolean }) {
|
||||
@@ -19,25 +19,17 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
|
||||
throw cause;
|
||||
}
|
||||
const catalog = await getCommissionLabels(data.checkout.request);
|
||||
return <div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0"><h1 className="wrap-anywhere font-heading text-3xl font-semibold">
|
||||
{data.ticket.title || `Ticket #${data.ticket.id.slice(0, 8)}`}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{data.ticket.title ? `#${data.ticket.id.slice(0, 8)} - ` : ""}{data.customerName} - {data.ticket.status === "open" ? "เปิด" : "ปิด"}</p>
|
||||
</div>
|
||||
{admin && <CommissionTicketRename ticketId={id} title={data.ticket.title} />}
|
||||
</header>
|
||||
<div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)]">
|
||||
<Card className="h-fit lg:max-h-[78svh] lg:overflow-y-auto"><CardHeader><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<CommissionTicketPanels summary={
|
||||
<Card className="h-full min-h-0"><CardHeader className="shrink-0"><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
|
||||
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
|
||||
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
|
||||
</CardContent></Card>
|
||||
<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
|
||||
} chat={<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
|
||||
userId={data.currentUserId} status={data.ticket.status}
|
||||
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
|
||||
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
|
||||
</div>
|
||||
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CommissionTicketPanels({ summary, chat }: { summary: ReactNode; chat: ReactNode }) {
|
||||
const [active, setActive] = useState<"chat" | "summary">("chat");
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-2 lg:grid lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)] lg:grid-rows-[minmax(0,1fr)] lg:gap-4">
|
||||
<ToggleGroup aria-label="ส่วนของ Ticket" variant="outline" size="sm" value={[active]}
|
||||
onValueChange={(values) => { if (values[0] === "chat" || values[0] === "summary") setActive(values[0]); }}
|
||||
className="shrink-0 lg:hidden">
|
||||
<ToggleGroupItem value="chat">แชต</ToggleGroupItem>
|
||||
<ToggleGroupItem value="summary">รายละเอียดคำขอ</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className={cn("min-h-0 min-w-0 flex-1", active !== "summary" && "hidden lg:block")}>{summary}</div>
|
||||
<div className={cn("min-h-0 min-w-0 flex-1", active !== "chat" && "hidden lg:block")}>{chat}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,29 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useState, type FormEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function CommissionTicketRename({ ticketId, title }: { ticketId: string; title: string | null }) {
|
||||
export function CommissionTicketRename({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(title ?? "");
|
||||
const [ticketId, setTicketId] = useState<string | null>(null);
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
if (busy) return;
|
||||
setOpen(next);
|
||||
if (next) { setValue(title ?? ""); setError(""); }
|
||||
}
|
||||
|
||||
function openFromClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
const trigger = event.target.closest<HTMLButtonElement>("[data-ticket-rename-id]");
|
||||
if (!trigger) return;
|
||||
setTicketId(trigger.dataset.ticketRenameId ?? null);
|
||||
setValue(trigger.dataset.ticketRenameTitle ?? "");
|
||||
setError("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (busy || !ticketId) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const response = await fetch(`/api/commission/tickets/${ticketId}/title`, {
|
||||
@@ -36,10 +45,8 @@ export function CommissionTicketRename({ ticketId, title }: { ticketId: string;
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" aria-label="เปลี่ยนชื่อ Ticket" />}>
|
||||
<Pencil data-icon="inline-start" /> เปลี่ยนชื่อ
|
||||
</DialogTrigger>
|
||||
return <div className="flex flex-col gap-5" onClick={openFromClick}>{children}
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={save} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -58,5 +65,6 @@ export function CommissionTicketRename({ ticketId, title }: { ticketId: string;
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
</Dialog>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PencilIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VisitorPresence } from "@/components/public/visitor-presence";
|
||||
import { AccountMenu } from "@/components/auth/account-menu";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
|
||||
async function AdminEditLink({ editHref }: { editHref: string }) {
|
||||
@@ -52,6 +53,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
|
||||
<div className="hidden lg:block">
|
||||
<VisitorPresence guideId={guideId} />
|
||||
</div>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
foreignKey,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -214,16 +215,21 @@ export const commissionMessages = commissionSchema.table("message", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
ticketId: uuid("ticket_id").notNull().references(() => commissionTickets.id),
|
||||
authorId: text("author_id").notNull().references(() => users.id),
|
||||
replyToId: uuid("reply_to_id"),
|
||||
text: text("text"),
|
||||
imageObjectKey: text("image_object_key"),
|
||||
imageMimeType: varchar("image_mime_type", { length: 32 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
}, (table) => [index("commission_message_ticket_time_idx").on(table.ticketId, table.createdAt)]);
|
||||
}, (table) => [
|
||||
index("commission_message_ticket_time_idx").on(table.ticketId, table.createdAt),
|
||||
index("commission_message_reply_idx").on(table.replyToId),
|
||||
foreignKey({ columns: [table.replyToId], foreignColumns: [table.id] }).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const commissionReactions = commissionSchema.table("reaction", {
|
||||
messageId: uuid("message_id").notNull().references(() => commissionMessages.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
emoji: varchar("emoji", { length: 16 }).notNull(),
|
||||
emoji: text("emoji").notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.messageId, table.userId, table.emoji] })]);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "commission"."reaction" ALTER COLUMN "emoji" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "commission"."message" ADD COLUMN "reply_to_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "commission"."message" ADD CONSTRAINT "message_reply_to_id_message_id_fk" FOREIGN KEY ("reply_to_id") REFERENCES "commission"."message"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "commission_message_reply_idx" ON "commission"."message" USING btree ("reply_to_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,13 @@
|
||||
"when": 1790915901646,
|
||||
"tag": "0068_concerned_union_jack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1790933217844,
|
||||
"tag": "0069_useful_living_mummy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSingleEmoji } from "./reaction";
|
||||
|
||||
describe("commission reactions", () => {
|
||||
it("accepts emoji outside the old six choices, including sequences", () => {
|
||||
for (const emoji of ["🔥", "🇹🇭", "👨👩👧👦", "1️⃣"]) expect(isSingleEmoji(emoji)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects text and multiple reactions", () => {
|
||||
for (const value of ["", "hello", "1", "🔥🔥", "🔥 hello"]) expect(isSingleEmoji(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
export function isSingleEmoji(value: string): boolean {
|
||||
if (!value || value.length > 64) return false;
|
||||
const matches = [...value.matchAll(emojiRegex())];
|
||||
return matches.length === 1 && matches[0][0] === value;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export async function getCommissionTicket(id: string) {
|
||||
if (!row || (user.role !== "admin" || !user.emailVerified) && row.ticket.userId !== user.id)
|
||||
throw new HttpError(404, "ticket-not-found");
|
||||
const [messages, reactions] = await Promise.all([
|
||||
getDb().select({ message: commissionMessages, authorName: users.name }).from(commissionMessages)
|
||||
getDb().select({ message: commissionMessages, authorName: users.name, authorImage: users.image }).from(commissionMessages)
|
||||
.innerJoin(users, eq(commissionMessages.authorId, users.id))
|
||||
.where(eq(commissionMessages.ticketId, id)).orderBy(asc(commissionMessages.createdAt)),
|
||||
getDb().select({ reaction: commissionReactions, messageId: commissionMessages.id })
|
||||
@@ -26,8 +26,14 @@ export async function getCommissionTicket(id: string) {
|
||||
.innerJoin(commissionMessages, eq(commissionReactions.messageId, commissionMessages.id))
|
||||
.where(eq(commissionMessages.ticketId, id)),
|
||||
]);
|
||||
return { ...row, messages: messages.map(({ message, authorName }) => ({ ...message, authorName,
|
||||
reactions: reactions.filter(({ messageId }) => messageId === message.id).map(({ reaction }) => reaction) })),
|
||||
const reactionsByMessage = new Map<string, typeof commissionReactions.$inferSelect[]>();
|
||||
for (const { reaction, messageId } of reactions) {
|
||||
const group = reactionsByMessage.get(messageId) ?? [];
|
||||
group.push(reaction);
|
||||
reactionsByMessage.set(messageId, group);
|
||||
}
|
||||
return { ...row, messages: messages.map(({ message, authorName, authorImage }) => ({ ...message, authorName,
|
||||
authorImage, reactions: reactionsByMessage.get(message.id) ?? [] })),
|
||||
currentUserId: user.id };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function securityHeaders(production: boolean) {
|
||||
export function securityHeaders(production: boolean, emojiImageCdn: string) {
|
||||
const ads = "https://*.googlesyndication.com https://*.doubleclick.net https://*.googleadservices.com https://*.googletagservices.com https://www.google.com";
|
||||
const cloudflareAnalytics = "https://static.cloudflareinsights.com";
|
||||
const adConnections = "https://*.adtrafficquality.google";
|
||||
@@ -6,7 +6,7 @@ export function securityHeaders(production: boolean) {
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline' ${production ? "" : "'unsafe-eval'"} https://challenges.cloudflare.com ${cloudflareAnalytics} ${ads}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`img-src 'self' data: blob: https://buzz-cdn.astrxl.dev ${ads}`,
|
||||
`img-src 'self' data: blob: https://buzz-cdn.astrxl.dev ${emojiImageCdn} ${ads}`,
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' https://buzz-cdn.astrxl.dev https://challenges.cloudflare.com ${adConnections} ${ads}${production ? "" : " ws: wss:"}`,
|
||||
`frame-src https://challenges.cloudflare.com https://www.youtube-nocookie.com ${ads}`,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { securityHeaders } from "./lib/security/headers";
|
||||
const nextConfig: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [{ source: "/:path*", headers: securityHeaders(process.env.NODE_ENV === "production") }];
|
||||
return [{ source: "/:path*", headers: securityHeaders(process.env.NODE_ENV === "production", "https://cdn.jsdelivr.net") }];
|
||||
},
|
||||
cacheComponents: true,
|
||||
cacheHandlers: {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"discord.js": "^14.27.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-picker-react": "^4.22.3",
|
||||
"emoji-regex": "^11.0.0",
|
||||
"entities": "7.0.1",
|
||||
"ioredis": "^6.0.0",
|
||||
"lucide-react": "^1.35.0",
|
||||
@@ -70,6 +72,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bun": "^1.4.0",
|
||||
"@types/emoji-regex": "^9.2.2",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user