feat : update

This commit is contained in:
2026-10-02 17:33:14 +07:00 Unverified
parent 63898fb90d
commit a966d996a9
34 changed files with 4678 additions and 147 deletions
+1 -3
View File
@@ -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>;
}
+22 -9
View File
@@ -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); }
}
+63
View File
@@ -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();
});
});
+59
View File
@@ -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 -2
View File
@@ -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 -3
View File
@@ -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>;
}
+2 -2
View File
@@ -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>}
+27
View File
@@ -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>;
}
+12 -1
View File
@@ -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=="],
+3 -24
View File
@@ -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>
+39
View File
@@ -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>;
}
+77
View File
@@ -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>
-18
View File
@@ -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></>;
}
+130 -41
View File
@@ -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" />}
+8 -16
View File
@@ -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>;
}
+19
View File
@@ -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>;
}
+20 -12
View File
@@ -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>;
}
+2
View File
@@ -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
View File
@@ -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] })]);
+4
View File
@@ -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
+7
View File
@@ -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
}
]
}
+12
View File
@@ -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);
});
});
+7
View File
@@ -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;
}
+9 -3
View File
@@ -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 };
}
+2 -2
View File
@@ -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
View File
@@ -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: {
+3
View File
@@ -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",