From 0022625edb2ac4b82261dfcbef283d95c362683c Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 1 Oct 2026 18:37:15 +0700 Subject: [PATCH] feat : big update --- .env.example | 13 +- README.md | 23 +- app/admin/commission/[id]/page.tsx | 17 + app/admin/commission/page.tsx | 44 + .../commission/checkouts/[id]/verify/route.ts | 66 + app/api/commission/checkouts/route.ts | 24 + app/api/commission/events/route.ts | 25 + .../tickets/[id]/images/[messageId]/route.ts | 21 + .../[id]/messages/[messageId]/route.test.ts | 76 + .../[id]/messages/[messageId]/route.ts | 32 + .../commission/tickets/[id]/messages/route.ts | 55 + .../tickets/[id]/reactions/route.ts | 34 + app/api/commission/tickets/[id]/slip/route.ts | 20 + .../commission/tickets/[id]/status/route.ts | 23 + .../tickets/[id]/title/route.test.ts | 65 + .../commission/tickets/[id]/title/route.ts | 25 + .../commission/tickets/[id]/typing/route.ts | 20 + app/commission/login/page.tsx | 5 + app/commission/page.tsx | 39 + app/commission/pay/[id]/page.tsx | 47 + app/commission/tickets/[id]/page.tsx | 14 + app/commission/tickets/page.tsx | 53 + app/login/page.tsx | 10 + app/register/page.tsx | 11 +- app/robots.ts | 2 +- bun.lock | 71 + components/admin/admin-header.tsx | 5 + components/admin/editor-presence.tsx | 2 +- components/admin/guide-overview-form.tsx | 4 +- components/admin/structured-dashboard.tsx | 2 +- components/admin/ui/catalog-select.tsx | 10 +- components/auth/account-form.tsx | 73 + components/auth/account-page.tsx | 15 + components/commission/live-refresh.tsx | 16 + components/commission/payment-form.tsx | 56 + components/commission/request-form.tsx | 211 + components/commission/request-summary.tsx | 95 + components/commission/sign-out.tsx | 15 + components/commission/slip-dialog.tsx | 25 + components/commission/ticket-chat.tsx | 423 ++ components/commission/ticket-detail.tsx | 39 + components/commission/ticket-rename.tsx | 62 + components/public/site-header.tsx | 4 + components/public/weapon-chart.tsx | 4 +- components/ui/bubble.tsx | 127 + components/ui/message.tsx | 91 + db/schema.ts | 63 +- drizzle/0063_overconfident_george_stacy.sql | 66 + drizzle/0064_great_dorian_gray.sql | 11 + drizzle/0065_clumsy_misty_knight.sql | 29 + drizzle/0066_burly_william_stryker.sql | 1 + drizzle/meta/0063_snapshot.json | 3835 ++++++++++++++++ drizzle/meta/0064_snapshot.json | 3840 ++++++++++++++++ drizzle/meta/0065_snapshot.json | 3840 ++++++++++++++++ drizzle/meta/0066_snapshot.json | 3846 +++++++++++++++++ drizzle/meta/_journal.json | 28 + k8s/base/deployment.yaml | 31 + lib/auth/return-path.test.ts | 13 + lib/auth/return-path.ts | 12 + lib/auth/server.test.ts | 2 +- lib/auth/server.ts | 11 +- lib/commission/catalog.ts | 62 + lib/commission/discord.test.ts | 57 + lib/commission/discord.ts | 29 + lib/commission/payment.test.ts | 68 + lib/commission/payment.ts | 75 + lib/commission/promptpay.ts | 483 +++ lib/commission/request.test.ts | 57 + lib/commission/request.ts | 38 + lib/commission/server.ts | 30 + lib/commission/tickets.ts | 40 + lib/guides/validation-messages.ts | 8 +- package.json | 7 +- public/icon/commission.webp | Bin 0 -> 41300 bytes tests/public-performance.test.ts | 2 +- tests/security-boundaries.test.ts | 2 +- 76 files changed, 18671 insertions(+), 29 deletions(-) create mode 100644 app/admin/commission/[id]/page.tsx create mode 100644 app/admin/commission/page.tsx create mode 100644 app/api/commission/checkouts/[id]/verify/route.ts create mode 100644 app/api/commission/checkouts/route.ts create mode 100644 app/api/commission/events/route.ts create mode 100644 app/api/commission/tickets/[id]/images/[messageId]/route.ts create mode 100644 app/api/commission/tickets/[id]/messages/[messageId]/route.test.ts create mode 100644 app/api/commission/tickets/[id]/messages/[messageId]/route.ts create mode 100644 app/api/commission/tickets/[id]/messages/route.ts create mode 100644 app/api/commission/tickets/[id]/reactions/route.ts create mode 100644 app/api/commission/tickets/[id]/slip/route.ts create mode 100644 app/api/commission/tickets/[id]/status/route.ts create mode 100644 app/api/commission/tickets/[id]/title/route.test.ts create mode 100644 app/api/commission/tickets/[id]/title/route.ts create mode 100644 app/api/commission/tickets/[id]/typing/route.ts create mode 100644 app/commission/login/page.tsx create mode 100644 app/commission/page.tsx create mode 100644 app/commission/pay/[id]/page.tsx create mode 100644 app/commission/tickets/[id]/page.tsx create mode 100644 app/commission/tickets/page.tsx create mode 100644 app/login/page.tsx create mode 100644 components/auth/account-form.tsx create mode 100644 components/auth/account-page.tsx create mode 100644 components/commission/live-refresh.tsx create mode 100644 components/commission/payment-form.tsx create mode 100644 components/commission/request-form.tsx create mode 100644 components/commission/request-summary.tsx create mode 100644 components/commission/sign-out.tsx create mode 100644 components/commission/slip-dialog.tsx create mode 100644 components/commission/ticket-chat.tsx create mode 100644 components/commission/ticket-detail.tsx create mode 100644 components/commission/ticket-rename.tsx create mode 100644 components/ui/bubble.tsx create mode 100644 components/ui/message.tsx create mode 100644 drizzle/0063_overconfident_george_stacy.sql create mode 100644 drizzle/0064_great_dorian_gray.sql create mode 100644 drizzle/0065_clumsy_misty_knight.sql create mode 100644 drizzle/0066_burly_william_stryker.sql create mode 100644 drizzle/meta/0063_snapshot.json create mode 100644 drizzle/meta/0064_snapshot.json create mode 100644 drizzle/meta/0065_snapshot.json create mode 100644 drizzle/meta/0066_snapshot.json create mode 100644 lib/auth/return-path.test.ts create mode 100644 lib/auth/return-path.ts create mode 100644 lib/commission/catalog.ts create mode 100644 lib/commission/discord.test.ts create mode 100644 lib/commission/discord.ts create mode 100644 lib/commission/payment.test.ts create mode 100644 lib/commission/payment.ts create mode 100644 lib/commission/promptpay.ts create mode 100644 lib/commission/request.test.ts create mode 100644 lib/commission/request.ts create mode 100644 lib/commission/server.ts create mode 100644 lib/commission/tickets.ts create mode 100644 public/icon/commission.webp diff --git a/.env.example b/.env.example index 396dc1e..0b07d0e 100644 --- a/.env.example +++ b/.env.example @@ -8,13 +8,13 @@ NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597 DATABASE_URL=postgresql://buzz_sheet:replace-me@postgres.example.internal:5432/buzz_sheet?sslmode=require DATABASE_POOL_SIZE=10 -# Better Auth email/password administrator login +# Better Auth email/password administrator and customer login BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 -# Cloudflare Turnstile administrator login protection +# Cloudflare Turnstile login and public registration protection TURNSTILE_SITE_KEY=replace-with-turnstile-site-key TURNSTILE_SECRET_KEY=replace-with-turnstile-secret-key @@ -34,6 +34,7 @@ CATALOG_SYNC_CONCURRENCY=12 DISCORD_BOT_TOKEN=replace-with-discord-bot-token DISCORD_CHANNEL_ID=replace-with-private-channel-id DISCORD_LOG_CHANNEL_ID=1547193755772125184 +COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url # S3-compatible media storage; direct guide uploads use public CDN objects. S3_ENDPOINT=https://s3.example.internal @@ -44,6 +45,14 @@ S3_ACCESS_KEY_ID=replace-with-access-key S3_SECRET_ACCESS_KEY=replace-with-secret-key S3_VIRTUAL_HOSTED_STYLE=false +# Commission payment. Use the recipient number format accepted by Slip2Go. +COMMISSION_PROMPTPAY_TYPE=mobile +COMMISSION_PROMPTPAY_VALUE=0812345678 +COMMISSION_RECEIVER_ACCOUNT_NUMBER=0812345678 +# Use the complete verification URL supplied by your Slip2Go API Connect account. +SLIP2GO_VERIFY_URL=https://your-slip2go-api-host/api/verify-slip/qr-image/info +SLIP2GO_API_SECRET=replace-with-slip2go-secret + # Stable across replicas. Generate once and store only in the external secret. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace-with-a-stable-32-byte-base64-key diff --git a/README.md b/README.md index fcd0043..c69dff3 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ formula fixtures. Their guide text and media are not imported or published. | `/` | Searchable public character directory | | `/[character]` | Redirect to the first visible page | | `/[character]/[page]` | Render a public guide page | +| `/login` | Public account sign-in | +| `/register` | Public account registration | | `/admin/login` | Administrator email/password sign-in | | `/admin/register` | Create and remove administrator accounts | | `/admin` | Character and page overview | @@ -63,7 +65,7 @@ cp .env.example .env ignored by Git and must never be committed. `BETTER_AUTH_URL` must exactly match the application origin. Set `BASE_URL` to the public site origin for SEO metadata, sitemap, and robots.txt. Existing administrators can create additional -credential accounts from `/admin/register`; public registration is disabled. +credential accounts from `/admin/register`. Visitors can create public accounts at `/register`. Prepare the database and start the application: @@ -207,6 +209,11 @@ S3_SECRET_ACCESS_KEY NEXT_SERVER_ACTIONS_ENCRYPTION_KEY TURNSTILE_SITE_KEY TURNSTILE_SECRET_KEY +COMMISSION_PROMPTPAY_TYPE +COMMISSION_PROMPTPAY_VALUE +COMMISSION_RECEIVER_ACCOUNT_NUMBER +SLIP2GO_VERIFY_URL +SLIP2GO_API_SECRET DISCORD_BOT_TOKEN DISCORD_CHANNEL_ID ``` @@ -215,6 +222,20 @@ DISCORD_CHANNEL_ID across replicas and rolling deployments. Do not place secret values in the ConfigMap or commit them to this repository. +The commission page accepts PromptPay mobile, national ID, or e-wallet identifiers. +For `COMMISSION_PROMPTPAY_TYPE=mobile`, use a real 10-digit Thai mobile number +registered with PromptPay for receiving transfers in `COMMISSION_PROMPTPAY_VALUE`. +For `nationalId` use the registered 13-digit ID; for `ewallet` use its 15-digit ID. +An ordinary bank account number cannot be used as the mobile identifier. +Set `COMMISSION_RECEIVER_ACCOUNT_NUMBER` to the recipient value in the format +Slip2Go expects for its receiver check. Set `SLIP2GO_VERIFY_URL` to the full +image-verification endpoint from your Slip2Go API Connect account. Set +`SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization +prefix when calling Slip2Go. Private +payment slips and ticket images use the configured S3 bucket with private ACL; +the bucket/CDN must honor private object access. Apply the commission database +migration before enabling checkout. + Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be reachable only through Traefik before enabling the configured client IP based rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the diff --git a/app/admin/commission/[id]/page.tsx b/app/admin/commission/[id]/page.tsx new file mode 100644 index 0000000..e5aae54 --- /dev/null +++ b/app/admin/commission/[id]/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { AdminHeader } from "@/components/admin/admin-header"; +import { CommissionTicketDetail } from "@/components/commission/ticket-detail"; +import { getAdminSession } from "@/lib/auth/server"; + +export const instant = false; +export default async function AdminCommissionTicketPage({ params }: PageProps<"/admin/commission/[id]">) { + await connection(); + if (!(await getAdminSession())) notFound(); + const { id } = await params; + return
+ กลับไปหน้า Ticket ทั้งหมด + +
; +} diff --git a/app/admin/commission/page.tsx b/app/admin/commission/page.tsx new file mode 100644 index 0000000..a2f556d --- /dev/null +++ b/app/admin/commission/page.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +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 { AdminHeader } from "@/components/admin/admin-header"; +import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getAdminSession } from "@/lib/auth/server"; + +export const instant = false; +export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const query = await searchParams; + const rawPage = Number(query.page ?? 1); + const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1; + const tickets = await getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht, + customer: users.name }).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); + return
+
+

Commission tickets

+ {tickets.length === 0 &&

ยังไม่มี Ticket

} + {tickets.slice(0, 50).map(({ ticket, amount, customer }) => + {ticket.title || `#${ticket.id.slice(0, 8)}`} - {customer} + + {ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} + + ฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")} + )} + +
+
; +} diff --git a/app/api/commission/checkouts/[id]/verify/route.ts b/app/api/commission/checkouts/[id]/verify/route.ts new file mode 100644 index 0000000..09d95ab --- /dev/null +++ b/app/api/commission/checkouts/[id]/verify/route.ts @@ -0,0 +1,66 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db/schema"; +import { notifyCommission, requireCommissionUser } from "@/lib/commission/server"; +import { notifyPaidTicketDiscord } from "@/lib/commission/discord"; +import { verifyCommissionSlip } from "@/lib/commission/payment"; +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"; + +const MAX_SLIP_BYTES = 6 * 1024 * 1024; + +export async function POST(request: Request, context: RouteContext<"/api/commission/checkouts/[id]/verify">) { + try { + requireSameOrigin(request); + const user = await requireCommissionUser(); + await limitRequest("commission-slip", user.id, 10); + const { id } = await context.params; + const [checkout] = await getDb().select().from(commissionCheckouts) + .where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, user.id))).limit(1); + if (!checkout) throw new HttpError(404, "checkout-not-found"); + const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, id)).limit(1); + if (existing) return Response.json({ ticketId: existing.id }); + 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_SLIP_BYTES + 64 * 1024).formData(); + const file = form.get("file"); + if (!(file instanceof File) || file.size === 0 || file.size > MAX_SLIP_BYTES || + !["image/png", "image/jpeg", "image/webp"].includes(file.type)) + throw new HttpError(415, "invalid-slip-image"); + const bytes = new Uint8Array(await file.arrayBuffer()); + await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp"); + const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt); + const objectKey = `commission/slips/${crypto.randomUUID()}`; + const storage = await getMediaStorage(); + await storage.write(objectKey, bytes, { type: file.type, acl: "private" }); + let ticketId: string; + try { + ticketId = await getDb().transaction(async (tx) => { + const [ticket] = await tx.insert(commissionTickets).values({ + checkoutId: checkout.id, userId: user.id, + }).returning({ id: commissionTickets.id }); + await tx.insert(commissionPayments).values({ + checkoutId: checkout.id, ticketId: ticket.id, slipObjectKey: objectKey, + slipMimeType: file.type, ...verified, + }); + return ticket.id; + }); + } catch (cause) { + await storage.delete(objectKey).catch(() => undefined); + const [paid] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); + if (paid) return Response.json({ ticketId: paid.id }); + if (cause && typeof cause === "object" && "code" in cause && cause.code === "23505") + throw new HttpError(409, "payment-already-used"); + throw cause; + } + await notifyCommission(ticketId, user.id); + await notifyPaidTicketDiscord(ticketId, checkout.amountBaht); + return Response.json({ ticketId }, { status: 201, headers: { "Cache-Control": "no-store" } }); + }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/checkouts/route.ts b/app/api/commission/checkouts/route.ts new file mode 100644 index 0000000..a61e9e1 --- /dev/null +++ b/app/api/commission/checkouts/route.ts @@ -0,0 +1,24 @@ +import { getDb } from "@/db"; +import { commissionCheckouts } from "@/db/schema"; +import { validateCommissionCatalog } from "@/lib/commission/catalog"; +import { promptPayConfig } from "@/lib/commission/payment"; +import { commissionPrice, commissionRequestSchema } from "@/lib/commission/request"; +import { requireCommissionUser } from "@/lib/commission/server"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const user = await requireCommissionUser(); + await limitRequest("commission-checkout", user.id, 15); + const parsed = commissionRequestSchema.safeParse(await readJson(request, 32 * 1024)); + if (!parsed.success || !(await validateCommissionCatalog(parsed.data))) + throw new HttpError(400, "invalid-commission-request"); + promptPayConfig(); + const [checkout] = await getDb().insert(commissionCheckouts).values({ + userId: user.id, request: parsed.data, amountBaht: commissionPrice(parsed.data), + }).returning({ id: commissionCheckouts.id }); + return Response.json({ checkoutId: checkout.id }, { status: 201, headers: { "Cache-Control": "no-store" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/events/route.ts b/app/api/commission/events/route.ts new file mode 100644 index 0000000..cd316d3 --- /dev/null +++ b/app/api/commission/events/route.ts @@ -0,0 +1,25 @@ +import { createRedisNamedEventResponse } from "@/lib/events/redis-stream"; +import { authorizeTicket } from "@/lib/commission/tickets"; +import { requireCommissionUser } from "@/lib/commission/server"; +import { errorResponse, HttpError } from "@/lib/security/http"; +import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit"; + +export async function GET(request: Request) { + try { + const user = await requireCommissionUser(); + await limitRequest("stream-open", trustedClientAddress(request.headers), 60); + const url = new URL(request.url); + const ticketId = url.searchParams.get("ticketId"); + const scope = url.searchParams.get("scope"); + let topic: string; + if (ticketId) { + if (!/^[a-f0-9-]{36}$/i.test(ticketId)) throw new HttpError(400, "invalid-ticket-id"); + await authorizeTicket(ticketId); + topic = `commission:ticket:${ticketId}`; + } else if (scope === "admin") { + if (user.role !== "admin" || !user.emailVerified) throw new HttpError(403, "forbidden"); + topic = "commission:admin"; + } else topic = `commission:user:${user.id}`; + return await createRedisNamedEventResponse(topic, "changed", request.signal); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/images/[messageId]/route.ts b/app/api/commission/tickets/[id]/images/[messageId]/route.ts new file mode 100644 index 0000000..9268fe3 --- /dev/null +++ b/app/api/commission/tickets/[id]/images/[messageId]/route.ts @@ -0,0 +1,21 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionMessages } from "@/db/schema"; +import { authorizeTicket } from "@/lib/commission/tickets"; +import { getMediaStorage } from "@/lib/media/storage"; +import { errorResponse, HttpError } from "@/lib/security/http"; + +export async function GET(_request: Request, + context: RouteContext<"/api/commission/tickets/[id]/images/[messageId]">) { + try { + const { id, messageId } = await context.params; + await authorizeTicket(id); + const [message] = await getDb().select({ key: commissionMessages.imageObjectKey, + type: commissionMessages.imageMimeType }).from(commissionMessages) + .where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id))).limit(1); + if (!message?.key || !message.type) throw new HttpError(404, "image-not-found"); + const file = (await getMediaStorage()).file(message.key); + return new Response(await file.bytes(), { headers: { "Content-Type": message.type, + "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/messages/[messageId]/route.test.ts b/app/api/commission/tickets/[id]/messages/[messageId]/route.test.ts new file mode 100644 index 0000000..0863776 --- /dev/null +++ b/app/api/commission/tickets/[id]/messages/[messageId]/route.test.ts @@ -0,0 +1,76 @@ +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authorizeTicket = vi.fn(); +const notifyCommission = vi.fn(); +const deleteObject = vi.fn(); +const deleteReturning = vi.fn(); +const deleteWhere = vi.fn(); +const updateWhere = vi.fn(); + +const tx = { + delete: vi.fn(() => ({ where: deleteWhere })), + update: vi.fn(() => ({ set: () => ({ where: updateWhere }) })), +}; + +vi.mock("@/db", () => ({ getDb: () => ({ transaction: async (run: (value: typeof tx) => Promise) => run(tx) }) })); +vi.mock("@/lib/commission/tickets", () => ({ authorizeTicket })); +vi.mock("@/lib/commission/server", () => ({ notifyCommission })); +vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ delete: deleteObject }) })); +vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined })); + +const { DELETE } = await import("./route"); +const ticketId = "11111111-1111-4111-8111-111111111111"; +const messageId = "22222222-2222-4222-8222-222222222222"; +const ownerId = "owner-1"; + +function deletionRequest(origin = "https://guide.sudloh.com") { + return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages/${messageId}`, + { method: "DELETE", headers: { Origin: origin } }); +} + +function context() { + return { params: Promise.resolve({ id: ticketId, messageId }) } as RouteContext< + "/api/commission/tickets/[id]/messages/[messageId]">; +} + +describe("commission message deletion", () => { + beforeEach(() => { + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + vi.clearAllMocks(); + authorizeTicket.mockResolvedValue({ ticket: { userId: ownerId }, user: { id: ownerId } }); + deleteWhere.mockImplementation(() => ({ returning: deleteReturning })); + deleteReturning.mockResolvedValue([{ imageObjectKey: "commission/messages/image-1" }]); + updateWhere.mockResolvedValue(undefined); + deleteObject.mockResolvedValue(undefined); + notifyCommission.mockResolvedValue(undefined); + }); + + it("rejects cross-origin requests before ticket authorization", async () => { + const response = await DELETE(deletionRequest("https://example.com"), context()); + expect(response.status).toBe(403); + expect(authorizeTicket).not.toHaveBeenCalled(); + }); + + it("deletes only a message owned by the current user in this ticket", async () => { + const response = await DELETE(deletionRequest(), context()); + expect(response.status).toBe(204); + const condition = deleteWhere.mock.calls[0][0]; + const query = new PgDialect().sqlToQuery(condition); + expect(query.sql).toContain('"ticket_id"'); + expect(query.sql).toContain('"author_id"'); + expect(query.params).toContain(ticketId); + expect(query.params).toContain(messageId); + expect(query.params).toContain(ownerId); + expect(deleteObject).toHaveBeenCalledWith("commission/messages/image-1"); + expect(notifyCommission).toHaveBeenCalledWith(ticketId, ownerId); + }); + + it("leaves storage and notifications untouched when no owned message exists", async () => { + deleteReturning.mockResolvedValue([]); + const response = await DELETE(deletionRequest(), context()); + expect(response.status).toBe(404); + expect(deleteObject).not.toHaveBeenCalled(); + expect(notifyCommission).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/commission/tickets/[id]/messages/[messageId]/route.ts b/app/api/commission/tickets/[id]/messages/[messageId]/route.ts new file mode 100644 index 0000000..b36bfd3 --- /dev/null +++ b/app/api/commission/tickets/[id]/messages/[messageId]/route.ts @@ -0,0 +1,32 @@ +import { and, eq } from "drizzle-orm"; +import * as z from "zod"; +import { getDb } from "@/db"; +import { commissionMessages, commissionTickets } from "@/db/schema"; +import { notifyCommission } from "@/lib/commission/server"; +import { authorizeTicket } from "@/lib/commission/tickets"; +import { getMediaStorage } from "@/lib/media/storage"; +import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function DELETE(request: Request, + context: RouteContext<"/api/commission/tickets/[id]/messages/[messageId]">) { + try { + requireSameOrigin(request); + const { id, messageId } = await context.params; + if (!z.uuid().safeParse(messageId).success) throw new HttpError(404, "message-not-found"); + const { ticket, user } = await authorizeTicket(id); + await limitRequest("commission-message-delete", user.id, 30); + const imageObjectKey = await getDb().transaction(async (tx) => { + const [message] = await tx.delete(commissionMessages) + .where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id), + eq(commissionMessages.authorId, user.id))) + .returning({ imageObjectKey: commissionMessages.imageObjectKey }); + if (!message) throw new HttpError(404, "message-not-found"); + await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id)); + return message.imageObjectKey; + }); + if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined); + await notifyCommission(id, ticket.userId); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/messages/route.ts b/app/api/commission/tickets/[id]/messages/route.ts new file mode 100644 index 0000000..c24e8b7 --- /dev/null +++ b/app/api/commission/tickets/[id]/messages/route.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionMessages, commissionTickets } from "@/db/schema"; +import { notifyCommission } from "@/lib/commission/server"; +import { authorizeTicket } from "@/lib/commission/tickets"; +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"; + +export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/messages">) { + try { + requireSameOrigin(request); + const { id } = await context.params; + const { ticket, user } = await authorizeTicket(id); + if (ticket.status !== "open") throw new HttpError(409, "ticket-closed"); + await limitRequest("commission-message", user.id, 60); + 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, 6 * 1024 * 1024 + 64 * 1024).formData(); + const value = form.get("text"); + const body = typeof value === "string" ? value.trim() : ""; + const image = form.get("image"); + if (body.length > 4000 || (image && !(image instanceof File))) + throw new HttpError(400, "invalid-message"); + if (!body && !image) throw new HttpError(400, "empty-message"); + let imageObjectKey: string | null = null; + if (image instanceof File) { + if (image.size === 0 || image.size > 6 * 1024 * 1024 || + !["image/png", "image/jpeg", "image/webp"].includes(image.type)) + throw new HttpError(415, "invalid-message-image"); + const bytes = new Uint8Array(await image.arrayBuffer()); + await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp"); + imageObjectKey = `commission/messages/${crypto.randomUUID()}`; + await (await getMediaStorage()).write(imageObjectKey, bytes, { type: image.type, acl: "private" }); + } + let messageId: string; + try { + messageId = await getDb().transaction(async (tx) => { + const [message] = await tx.insert(commissionMessages).values({ ticketId: id, authorId: user.id, + 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)); + return message.id; + }); + } catch (cause) { + if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined); + throw cause; + } + await notifyCommission(id, ticket.userId); + return Response.json({ ok: true, messageId }, { status: 201 }); + }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/reactions/route.ts b/app/api/commission/tickets/[id]/reactions/route.ts new file mode 100644 index 0000000..9179282 --- /dev/null +++ b/app/api/commission/tickets/[id]/reactions/route.ts @@ -0,0 +1,34 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionMessages, commissionReactions } from "@/db/schema"; +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 * as z from "zod"; + +const inputSchema = z.object({ messageId: z.uuid(), emoji: z.enum(["👍", "❤️", "😂", "😮", "😢", "🎉"]) }); + +export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/reactions">) { + try { + requireSameOrigin(request); + const { id } = await context.params; + const { ticket, user } = await authorizeTicket(id); + if (ticket.status !== "open") throw new HttpError(409, "ticket-closed"); + await limitRequest("commission-reaction", user.id, 60); + const parsed = inputSchema.safeParse(await readJson(request)); + if (!parsed.success) throw new HttpError(400, "invalid-reaction"); + const [message] = await getDb().select({ id: commissionMessages.id }).from(commissionMessages) + .where(and(eq(commissionMessages.id, parsed.data.messageId), eq(commissionMessages.ticketId, id))).limit(1); + if (!message) throw new HttpError(404, "message-not-found"); + const where = and(eq(commissionReactions.messageId, message.id), + eq(commissionReactions.userId, user.id), eq(commissionReactions.emoji, parsed.data.emoji)); + const [existing] = await getDb().select().from(commissionReactions).where(where).limit(1); + if (existing) await getDb().delete(commissionReactions).where(where); + else await getDb().insert(commissionReactions).values({ + messageId: message.id, userId: user.id, emoji: parsed.data.emoji, + }).onConflictDoNothing(); + await notifyCommission(id, ticket.userId); + return Response.json({ active: !existing }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/slip/route.ts b/app/api/commission/tickets/[id]/slip/route.ts new file mode 100644 index 0000000..a04cd35 --- /dev/null +++ b/app/api/commission/tickets/[id]/slip/route.ts @@ -0,0 +1,20 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionPayments } from "@/db/schema"; +import { authorizeTicket } from "@/lib/commission/tickets"; +import { getMediaStorage } from "@/lib/media/storage"; +import { errorResponse, HttpError } from "@/lib/security/http"; + +export async function GET(_request: Request, context: RouteContext<"/api/commission/tickets/[id]/slip">) { + try { + const { id } = await context.params; + await authorizeTicket(id); + const [payment] = await getDb().select({ key: commissionPayments.slipObjectKey, + type: commissionPayments.slipMimeType }).from(commissionPayments) + .where(eq(commissionPayments.ticketId, id)).limit(1); + if (!payment) throw new HttpError(404, "slip-not-found"); + const file = (await getMediaStorage()).file(payment.key); + return new Response(await file.bytes(), { headers: { "Content-Type": payment.type, + "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/status/route.ts b/app/api/commission/tickets/[id]/status/route.ts new file mode 100644 index 0000000..2a999d6 --- /dev/null +++ b/app/api/commission/tickets/[id]/status/route.ts @@ -0,0 +1,23 @@ +import { eq } from "drizzle-orm"; +import * as z from "zod"; +import { getDb } from "@/db"; +import { commissionTickets } from "@/db/schema"; +import { requireAdmin } from "@/lib/auth/server"; +import { notifyCommission } from "@/lib/commission/server"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; + +export async function PATCH(request: Request, context: RouteContext<"/api/commission/tickets/[id]/status">) { + try { + requireSameOrigin(request); + await requireAdmin(); + const { id } = await context.params; + const parsed = z.object({ status: z.enum(["open", "closed"]) }).safeParse(await readJson(request)); + if (!parsed.success) throw new HttpError(400, "invalid-status"); + const [ticket] = await getDb().update(commissionTickets) + .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); + return Response.json({ status: parsed.data.status }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/title/route.test.ts b/app/api/commission/tickets/[id]/title/route.test.ts new file mode 100644 index 0000000..eef47df --- /dev/null +++ b/app/api/commission/tickets/[id]/title/route.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { HttpError } from "@/lib/security/http"; + +const requireAdmin = vi.fn(); +const update = vi.fn(); +const set = vi.fn(); +const where = vi.fn(); +const returning = vi.fn(); +const notifyCommission = vi.fn(); + +vi.mock("@/lib/auth/server", () => ({ requireAdmin })); +vi.mock("@/db", () => ({ getDb: () => ({ update }) })); +vi.mock("@/lib/commission/server", () => ({ notifyCommission })); + +const { PATCH } = await import("./route"); +const ticketId = "11111111-1111-4111-8111-111111111111"; +const userId = "customer-1"; + +function request(title: unknown, origin = "https://guide.sudloh.com") { + return new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/title`, { + method: "PATCH", headers: { Origin: origin, "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); +} + +function context() { + return { params: Promise.resolve({ id: ticketId }) } as RouteContext<"/api/commission/tickets/[id]/title">; +} + +describe("commission ticket rename", () => { + beforeEach(() => { + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + vi.clearAllMocks(); + requireAdmin.mockResolvedValue({ user: { id: "admin-1" } }); + update.mockReturnValue({ set }); + set.mockReturnValue({ where }); + where.mockReturnValue({ returning }); + returning.mockResolvedValue([{ title: "New name", userId }]); + notifyCommission.mockResolvedValue(undefined); + }); + + it("rejects cross-origin and non-admin requests", async () => { + expect((await PATCH(request("New name", "https://example.com"), context())).status).toBe(403); + expect(requireAdmin).not.toHaveBeenCalled(); + requireAdmin.mockRejectedValueOnce(new HttpError(401, "unauthorized")); + expect((await PATCH(request("New name"), context())).status).toBe(401); + expect(update).not.toHaveBeenCalled(); + }); + + it("normalizes and saves a name, then notifies ticket viewers", async () => { + const response = await PATCH(request(" New name "), context()); + expect(response.status).toBe(200); + expect(set).toHaveBeenCalledWith({ title: "New name", updatedAt: expect.any(Date) }); + expect(notifyCommission).toHaveBeenCalledWith(ticketId, userId); + }); + + it("allows clearing the name and rejects names over 80 characters", async () => { + returning.mockResolvedValueOnce([{ title: null, userId }]); + expect((await PATCH(request(" "), context())).status).toBe(200); + expect(set).toHaveBeenCalledWith({ title: null, updatedAt: expect.any(Date) }); + vi.clearAllMocks(); + expect((await PATCH(request("x".repeat(81)), context())).status).toBe(400); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/commission/tickets/[id]/title/route.ts b/app/api/commission/tickets/[id]/title/route.ts new file mode 100644 index 0000000..3f09880 --- /dev/null +++ b/app/api/commission/tickets/[id]/title/route.ts @@ -0,0 +1,25 @@ +import { eq } from "drizzle-orm"; +import * as z from "zod"; +import { getDb } from "@/db"; +import { commissionTickets } from "@/db/schema"; +import { requireAdmin } from "@/lib/auth/server"; +import { notifyCommission } from "@/lib/commission/server"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; + +const inputSchema = z.object({ title: z.string().trim().max(80) }); + +export async function PATCH(request: Request, context: RouteContext<"/api/commission/tickets/[id]/title">) { + try { + requireSameOrigin(request); + await requireAdmin(); + const { id } = await context.params; + const parsed = inputSchema.safeParse(await readJson(request, 512)); + if (!parsed.success) throw new HttpError(400, "invalid-ticket-title"); + const title = parsed.data.title.replace(/\s+/gu, " ") || null; + const [ticket] = await getDb().update(commissionTickets).set({ title, updatedAt: new Date() }) + .where(eq(commissionTickets.id, id)).returning({ title: commissionTickets.title, userId: commissionTickets.userId }); + if (!ticket) throw new HttpError(404, "ticket-not-found"); + await notifyCommission(id, ticket.userId); + return Response.json({ title: ticket.title }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/tickets/[id]/typing/route.ts b/app/api/commission/tickets/[id]/typing/route.ts new file mode 100644 index 0000000..30458c3 --- /dev/null +++ b/app/api/commission/tickets/[id]/typing/route.ts @@ -0,0 +1,20 @@ +import { authorizeTicket } from "@/lib/commission/tickets"; +import { getRedisClient, redisEventChannel } from "@/lib/redis/client"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function POST(request: Request, context: RouteContext<"/api/commission/tickets/[id]/typing">) { + try { + requireSameOrigin(request); + const { id } = await context.params; + const { ticket, user } = await authorizeTicket(id); + if (ticket.status !== "open") throw new HttpError(409, "ticket-closed"); + await limitRequest("commission-typing", user.id, 30); + const body = await readJson(request, 128); + if (!body || typeof body !== "object" || !("active" in body) || typeof body.active !== "boolean") + throw new HttpError(400, "invalid-typing-state"); + await (await getRedisClient()).publish(redisEventChannel(`commission:ticket:${id}`), + `typing:${JSON.stringify({ userId: user.id, name: user.name, active: body.active })}`); + return new Response(null, { status: 204 }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/commission/login/page.tsx b/app/commission/login/page.tsx new file mode 100644 index 0000000..d3c7e11 --- /dev/null +++ b/app/commission/login/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function LegacyCommissionLoginPage() { + redirect("/login?next=%2Fcommission"); +} diff --git a/app/commission/page.tsx b/app/commission/page.tsx new file mode 100644 index 0000000..db51961 --- /dev/null +++ b/app/commission/page.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +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 { Button } from "@/components/ui/button"; +import { getCustomerSession } from "@/lib/auth/server"; +import { getCatalogOptions } from "@/lib/guides/queries"; + +export const instant = false; + +async function CommissionContent() { + await connection(); + const session = await getCustomerSession(); + if (!session) return
+

เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอคอมมิชชัน

+
+ + +
+
; + const catalog = await getCatalogOptions(); + if (!catalog) return

ยังไม่มีข้อมูลตัวละครสำหรับสร้างคำขอ

; + return
+
+ +
; +} + +export default function CommissionPage() { + return
+
+

Commission

+

ขอทีม เปรียบเทียบอาวุธหรือกลุ่มดาวตามที่ต้องการ

+ กำลังโหลด...

}>
+
+
; +} diff --git a/app/commission/pay/[id]/page.tsx b/app/commission/pay/[id]/page.tsx new file mode 100644 index 0000000..7b8c155 --- /dev/null +++ b/app/commission/pay/[id]/page.tsx @@ -0,0 +1,47 @@ +import QRCode from "qrcode"; +import Link from "next/link"; +import { and, eq } from "drizzle-orm"; +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; +import { getDb } from "@/db"; +import { commissionCheckouts, commissionTickets } from "@/db/schema"; +import { CommissionPaymentForm } from "@/components/commission/payment-form"; +import { CommissionRequestSummary } from "@/components/commission/request-summary"; +import { SiteHeader } from "@/components/public/site-header"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getCustomerSession } from "@/lib/auth/server"; +import { promptPayConfig } from "@/lib/commission/payment"; +import { generatePromptPayPayload } from "@/lib/commission/promptpay"; +import { getCommissionLabels } from "@/lib/commission/catalog"; + +export const instant = false; + +export default async function CommissionPayPage({ params }: PageProps<"/commission/pay/[id]">) { + await connection(); + const { id } = await params; + const session = await getCustomerSession(); + if (!session) redirect(`/login?next=${encodeURIComponent(`/commission/pay/${id}`)}`); + const [checkout] = await getDb().select().from(commissionCheckouts) + .where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, session.user.id))).limit(1); + if (!checkout) notFound(); + const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, id)).limit(1); + if (ticket) redirect(`/commission/tickets/${ticket.id}`); + const { identifier } = promptPayConfig(); + const payload = generatePromptPayPayload({ identifier, amount: checkout.amountBaht }); + const qr = await QRCode.toDataURL(payload, { margin: 2, width: 300 }); + const catalog = await getCommissionLabels(checkout.request); + return
+ กลับไป Commission +

ชำระเงิน

+ รายการคำขอ - ฿{checkout.amountBaht} + {catalog && } + สแกน PromptPay + {/* Generated locally from the fixed checkout amount and configured recipient. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`PromptPay + ฿{checkout.amountBaht}

โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน

+
+ +
; +} diff --git a/app/commission/tickets/[id]/page.tsx b/app/commission/tickets/[id]/page.tsx new file mode 100644 index 0000000..c080aa9 --- /dev/null +++ b/app/commission/tickets/[id]/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; +import { connection } from "next/server"; +import { CommissionTicketDetail } from "@/components/commission/ticket-detail"; +import { SiteHeader } from "@/components/public/site-header"; + +export const instant = false; +export default async function CommissionTicketPage({ params }: PageProps<"/commission/tickets/[id]">) { + await connection(); + const { id } = await params; + return
+ กลับไปหน้า Ticket ของฉัน + +
; +} diff --git a/app/commission/tickets/page.tsx b/app/commission/tickets/page.tsx new file mode 100644 index 0000000..1248860 --- /dev/null +++ b/app/commission/tickets/page.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { redirect } from "next/navigation"; +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 { SiteHeader } from "@/components/public/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { getCustomerSession } from "@/lib/auth/server"; + +export const instant = false; +export default async function CommissionTicketsPage() { + await connection(); + const session = await getCustomerSession(); + if (!session) redirect("/login?next=%2Fcommission%2Ftickets"); + const [tickets, pending] = await Promise.all([ + getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht }) + .from(commissionTickets).innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id)) + .where(eq(commissionTickets.userId, session.user.id)).orderBy(desc(commissionTickets.updatedAt)).limit(50), + getDb().select({ id: commissionCheckouts.id, amount: commissionCheckouts.amountBaht, + createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts) + .leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id)) + .where(and(eq(commissionCheckouts.userId, session.user.id), isNull(commissionTickets.id))) + .orderBy(desc(commissionCheckouts.createdAt)).limit(20), + ]); + return
+
+
+

Ticket ของฉัน

+ +
+ สร้างคำขอเพิ่ม + {pending.length > 0 &&

รอชำระเงิน

} + {pending.map((checkout) => + รอชำระเงิน - ฿{checkout.amount} + {checkout.createdAt.toLocaleString("th-TH")} + )} +

Ticket ที่ชำระเงินแล้ว

+ {tickets.length === 0 &&

ยังไม่มี Ticket

} + {tickets.map(({ ticket, amount }) => + {ticket.title || `Ticket #${ticket.id.slice(0, 8)}`} + + {ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} + + ฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")} + )} +
+
; +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..cbb73fb --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,10 @@ +import { connection } from "next/server"; +import { AccountPage } from "@/components/auth/account-page"; + +export const instant = false; + +export default async function LoginPage({ searchParams }: PageProps<"/login">) { + await connection(); + const { next } = await searchParams; + return ; +} diff --git a/app/register/page.tsx b/app/register/page.tsx index 176a33f..3984f2a 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,5 +1,10 @@ -import { redirect } from "next/navigation"; +import { connection } from "next/server"; +import { AccountPage } from "@/components/auth/account-page"; -export default async function RegisterPage() { - redirect("/admin/register"); +export const instant = false; + +export default async function RegisterPage({ searchParams }: PageProps<"/register">) { + await connection(); + const { next } = await searchParams; + return ; } diff --git a/app/robots.ts b/app/robots.ts index 96aa6f5..7fc54b5 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots { rules: { userAgent: "*", allow: "/", - disallow: ["/admin", "/api", "/register"], + disallow: ["/admin", "/api", "/login", "/register"], }, sitemap: `${siteUrl}/sitemap.xml`, }; diff --git a/bun.lock b/bun.lock index 2faf33d..95c889d 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cn": "^0.4.0", "color": "^5.0.3", "discord.js": "^14.27.0", "drizzle-orm": "^0.45.2", @@ -33,6 +34,7 @@ "modern-screenshot": "^4.7.0", "next": "^16.3.7", "postgres": "^3.4.9", + "qrcode": "^1.5.4", "radix-ui": "^1.6.7", "react": "19.2.8", "react-dom": "19.2.8", @@ -52,6 +54,7 @@ "@tailwindcss/postcss": "^4", "@types/bun": "^1.4.0", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "@types/sanitize-html": "^2.16.1", @@ -692,6 +695,8 @@ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react-dom": ["@types/react-dom@19.2.5", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg=="], @@ -878,6 +883,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -896,12 +903,16 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "cn": ["cn@0.4.0", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg=="], + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], @@ -974,6 +985,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], @@ -1004,6 +1017,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "discord-api-types": ["discord-api-types@0.38.55", "", {}, "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA=="], "discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="], @@ -1188,6 +1203,8 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1292,6 +1309,8 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1568,6 +1587,8 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], @@ -1616,6 +1637,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1660,8 +1683,12 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], "resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], @@ -1706,6 +1733,8 @@ "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1894,20 +1923,30 @@ "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], @@ -1998,6 +2037,10 @@ "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "conf/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], @@ -2070,10 +2113,18 @@ "vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -2148,6 +2199,10 @@ "ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -2238,6 +2293,16 @@ "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2248,6 +2313,12 @@ "sanitize-html/htmlparser2/domutils/dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 0270f01..8550242 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -9,6 +9,7 @@ import { LayoutDashboardIcon, BookOpenIcon, ClipboardListIcon, + MessagesSquareIcon, LogOutIcon, PlusIcon, UserPlusIcon, @@ -33,6 +34,10 @@ export function AdminHeader() { ไกด์เกนชินไม่ใช่เกมมือถือ