From 1ed9ea29ebbc5d98fa5cc2784e6cd13097fd8e58 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 2 Oct 2026 18:28:23 +0700 Subject: [PATCH] feat: update --- .agents/skills/admin-performance/SKILL.md | 2 +- .agents/skills/migrate-dev-db/SKILL.md | 4 +- Dockerfile | 4 +- Plan.md | 4 +- README.md | 6 +- app/layout.tsx | 4 + app/profile/page.tsx | 12 +- components/auth/account-form.tsx | 159 ++- components/auth/profile-form.tsx | 164 ++- components/commission/ticket-chat.tsx | 1211 ++++++++++++++++----- components/site-entry-prompt.tsx | 146 +++ k8s.md | 4 +- lib/auth/server.ts | 2 +- security/audit-exceptions.json | 2 +- 14 files changed, 1356 insertions(+), 368 deletions(-) create mode 100644 components/site-entry-prompt.tsx diff --git a/.agents/skills/admin-performance/SKILL.md b/.agents/skills/admin-performance/SKILL.md index 2c3a9ed..f0e2794 100644 --- a/.agents/skills/admin-performance/SKILL.md +++ b/.agents/skills/admin-performance/SKILL.md @@ -1,6 +1,6 @@ --- name: admin-performance -description: Preserve Buzz Sheet admin performance when adding or changing admin routes, editor data loading, catalog payloads, navigation, or refresh behavior. Diagnose slow admin navigation with measurements and targeted fixes. +description: Preserve Buzz Guide admin performance when adding or changing admin routes, editor data loading, catalog payloads, navigation, or refresh behavior. Diagnose slow admin navigation with measurements and targeted fixes. --- # Admin performance diff --git a/.agents/skills/migrate-dev-db/SKILL.md b/.agents/skills/migrate-dev-db/SKILL.md index 704fdae..6d144ae 100644 --- a/.agents/skills/migrate-dev-db/SKILL.md +++ b/.agents/skills/migrate-dev-db/SKILL.md @@ -1,11 +1,11 @@ --- name: migrate-dev-db -description: Generate and apply Buzz Sheet Drizzle migrations to the development database from .env.local only. Use for requests to migrate, push, or update the dev database; never use it for production migrations. +description: Generate and apply Buzz Guide Drizzle migrations to the development database from .env.local only. Use for requests to migrate, push, or update the dev database; never use it for production migrations. --- # Migrate the development database -- Work from the Buzz Sheet repository root. +- Work from the Buzz Guide repository root. - Use `.env.local` as the only database environment source. Stop if it is missing or does not define `DATABASE_URL`. - Never use ambient production credentials, `.env.production`, deployment tooling, or a production database URL. - When `db/schema.ts` changed and no matching migration exists, run `bun run db:generate` first and inspect the generated SQL. diff --git a/Dockerfile b/Dockerfile index ad25afe..56ea2c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ FROM ${BUN_IMAGE} AS migration RUN apk upgrade --no-cache WORKDIR /app ARG VCS_REF=unknown -LABEL org.opencontainers.image.title="Buzz Sheet migrations" \ +LABEL org.opencontainers.image.title="Buzz Guide migrations" \ org.opencontainers.image.revision=${VCS_REF} ENV NODE_ENV=production COPY --from=worker-builder --chown=bun:bun /app/dist/migrate.js ./migrate.js @@ -52,7 +52,7 @@ FROM ${BUN_IMAGE} AS app RUN apk upgrade --no-cache WORKDIR /app ARG VCS_REF=unknown -LABEL org.opencontainers.image.title="Buzz Sheet" \ +LABEL org.opencontainers.image.title="Buzz Guide" \ org.opencontainers.image.revision=${VCS_REF} ENV HOSTNAME=0.0.0.0 ENV NODE_ENV=production diff --git a/Plan.md b/Plan.md index 9a54203..89999a8 100644 --- a/Plan.md +++ b/Plan.md @@ -1,8 +1,8 @@ -# Buzz Sheet Structured Guide Platform +# Buzz Guide Structured Guide Platform ## Product -Buzz Sheet is a Thai-first, image-first Genshin guide CMS. The public directory +Buzz Guide is a Thai-first, image-first Genshin guide CMS. The public directory shows a required cover and Overview for every guide. Each guide exposes only non-empty Weapons, Artifacts & Stats, Constellations, Team Calc, and optional Extra sections. Use the English terms “Build” and “Team” throughout the UI. diff --git a/README.md b/README.md index c83a9a7..786e8bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Buzz Sheet +# Buzz Guide -Buzz Sheet is a Thai-first guide CMS for turning structured character, build, +Buzz Guide is a Thai-first guide CMS for turning structured character, build, team, rotation, and comparison data into responsive game-guide pages. Production target: `https://guide.sudloh.com` @@ -302,7 +302,7 @@ This project would not be possible without the open-source software and community services it builds on: - [Lunaris](https://lunaris.moe/) — character, talent, material, and game data - used by catalog synchronization and guide tools. Buzz Sheet links back to + used by catalog synchronization and guide tools. Buzz Guide links back to Lunaris for material references where appropriate. - [Next.js](https://nextjs.org/) — application framework. - [React](https://react.dev/) — user interface library. diff --git a/app/layout.tsx b/app/layout.tsx index aefc781..39ad7a9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Anuphan, Geist_Mono } from "next/font/google"; import { DeploymentUpdateNotifier } from "@/components/deployment-update-notifier"; import { PageProgress } from "@/components/page-progress"; +import { SiteEntryPrompt } from "@/components/site-entry-prompt"; import { Toaster } from "@/components/ui/sonner"; import { siteUrl } from "@/lib/site-url"; @@ -27,6 +28,8 @@ export const metadata: Metadata = { }, description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ", keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"], + icons: { apple: "/icon-192.png" }, + appleWebApp: { capable: true, title: "Buzz Guide" }, }; export const viewport: Viewport = { @@ -45,6 +48,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { {children} + ) { await connection(); + const { setup, next, upload } = await searchParams; + const nextPath = safeAuthReturnPath(next); + const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath; const session = await getCustomerSession(); if (!session) redirect("/login?next=%2Fprofile"); const [user] = await getDb().select({ name: users.name, email: users.email, image: users.image }) @@ -21,7 +25,11 @@ export default async function ProfilePage() { return
{isAuthorizedAdmin(session.user) ? : }

โปรไฟล์ของฉัน

- + {setup === "1" &&

+ {upload === "failed" ? "สร้างบัญชีแล้ว แต่บันทึกรูปโปรไฟล์ไม่สำเร็จ กรุณาลองอีกครั้งหรือข้ามไปก่อน" : + "เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"} +

} +
; } diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx index e1cf0c6..6a52ed5 100644 --- a/components/auth/account-form.tsx +++ b/components/auth/account-form.tsx @@ -10,8 +10,14 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth/client"; -export function AccountForm({ mode, siteKey, nextPath }: { - mode: "login" | "register"; siteKey: string; nextPath: string; +export function AccountForm({ + mode, + siteKey, + nextPath, +}: { + mode: "login" | "register"; + siteKey: string; + nextPath: string; }) { const router = useRouter(); const register = mode === "register"; @@ -24,50 +30,149 @@ export function AccountForm({ mode, siteKey, nextPath }: { useEffect(() => { if (!captcha) return; const script = document.createElement("script"); - script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + script.src = + "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; script.async = true; script.onload = () => { - if (container.current && window.turnstile && !widget.current) widget.current = window.turnstile.render(container.current, { - sitekey: siteKey, callback: (value: string) => setToken(value), - "expired-callback": () => setToken(""), "error-callback": () => setToken(""), - }); + if (container.current && window.turnstile && !widget.current) + widget.current = window.turnstile.render(container.current, { + sitekey: siteKey, + callback: (value: string) => setToken(value), + "expired-callback": () => setToken(""), + "error-callback": () => setToken(""), + }); }; document.head.append(script); - return () => { script.remove(); }; + return () => { + script.remove(); + }; }, [captcha, siteKey]); async function submit(event: FormEvent) { event.preventDefault(); if (captcha && !token) return; - setBusy(true); setError(""); + setBusy(true); + setError(""); const form = new FormData(event.currentTarget); const email = String(form.get("email") ?? "").trim(); const password = String(form.get("password") ?? ""); - const fetchOptions = { headers: captcha ? { "x-captcha-response": token } : undefined }; + const image = register ? form.get("image") : null; + const fetchOptions = { + headers: captcha ? { "x-captcha-response": token } : undefined, + }; try { + if (image instanceof File && image.size > 0 && + (image.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(image.type))) { + throw new Error("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB"); + } const result = register - ? await authClient.signUp.email({ name: String(form.get("name") ?? "").trim(), email, password, fetchOptions }) + ? await authClient.signUp.email({ + name: String(form.get("name") ?? "").trim(), + email, + password, + fetchOptions, + }) : await authClient.signIn.email({ email, password, fetchOptions }); - if (result.error) throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ"); - router.push(nextPath); router.refresh(); + if (result.error) + throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ"); + if (register && image instanceof File && image.size > 0) { + const profile = new FormData(); + profile.set("name", String(form.get("name") ?? "").trim()); + profile.set("image", image); + const uploaded = await fetch("/api/profile", { method: "POST", body: profile }) + .then((response) => response.ok).catch(() => false); + if (!uploaded) { + router.push(`/profile?setup=1&upload=failed&next=${encodeURIComponent(nextPath)}`); + router.refresh(); + return; + } + } + router.push(nextPath); + router.refresh(); } catch (cause) { setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง"); setToken(""); if (widget.current) window.turnstile?.reset(widget.current); - } finally { setBusy(false); } + } finally { + setBusy(false); + } } - return {register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"} -
- {register && ชื่อ} - อีเมล - รหัสผ่าน - {captcha &&
} - - - - {error && {error}} - ; + return ( + + + {register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"} + + +
+ + {register && ( + + ชื่อที่แสดง + + + )} + {register && ( + + รูปโปรไฟล์ (ไม่บังคับ) + + + )} + + อีเมล + + + + รหัสผ่าน + + + {captcha &&
} + + + + + {error && ( + + {error} + + )} + + + ); } diff --git a/components/auth/profile-form.tsx b/components/auth/profile-form.tsx index 4e448bf..90675f5 100644 --- a/components/auth/profile-form.tsx +++ b/components/auth/profile-form.tsx @@ -1,15 +1,31 @@ "use client"; import { useEffect, useState, type FormEvent } from "react"; +import Link from "next/link"; 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 { + 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; +export function ProfileForm({ + name: initialName, + email, + image: initialImage, + nextPath, +}: { + name: string; + email: string; + image: string | null; + nextPath?: string; }) { const [name, setName] = useState(initialName); const [file, setFile] = useState(null); @@ -17,11 +33,20 @@ export function ProfileForm({ name: initialName, email, image: initialImage }: { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]); + 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))) { + if ( + next && + (next.size > 6 * 1024 * 1024 || + !["image/png", "image/jpeg", "image/webp"].includes(next.type)) + ) { setError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB"); return; } @@ -32,46 +57,109 @@ export function ProfileForm({ name: initialName, email, image: initialImage }: { async function save(event: FormEvent) { event.preventDefault(); if (busy) return; - setBusy(true); setError(""); + 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(); + const response = await fetch("/api/profile", { + method: "POST", + body: form, + }); + if (!response.ok) + throw new Error("บันทึกโปรไฟล์ไม่สำเร็จ กรุณาลองอีกครั้ง"); + if (nextPath) window.location.assign(nextPath); + else window.location.reload(); } catch (cause) { - setError(cause instanceof Error ? cause.message : "บันทึกโปรไฟล์ไม่สำเร็จ"); + setError( + cause instanceof Error ? cause.message : "บันทึกโปรไฟล์ไม่สำเร็จ", + ); setBusy(false); } } - return - ข้อมูลบัญชี{email} -
- รูปโปรไฟล์ -
- - {(preview || initialImage) && } - {name.trim().charAt(0).toUpperCase() || "?"} - - { - 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} /> -
-
- ชื่อที่แสดง - setName(event.target.value)} - minLength={2} maxLength={80} required disabled={busy} /> - - {error && {error}} -
- -
; + return ( + + + ข้อมูลบัญชี + {email} + + +
+ + + รูปโปรไฟล์ +
+ + {(preview || initialImage) && ( + + )} + + {name.trim().charAt(0).toUpperCase() || "?"} + + + { + 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} + /> +
+
+ + ชื่อที่แสดง + setName(event.target.value)} + minLength={2} + maxLength={80} + required + disabled={busy} + /> + + {error && ( + + {error} + + )} +
+
+
+ + + {nextPath && ( + + )} + +
+ ); } diff --git a/components/commission/ticket-chat.tsx b/components/commission/ticket-chat.tsx index cf3d21e..253b01c 100644 --- a/components/commission/ticket-chat.tsx +++ b/components/commission/ticket-chat.tsx @@ -1,52 +1,141 @@ "use client"; -import { useEffect, useRef, useState, type ClipboardEvent, type FormEvent, type KeyboardEvent } from "react"; +import { + useEffect, + useRef, + useState, + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, +} from "react"; import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; -import { ImagePlus, MessageCircle, ReplyIcon, SendHorizontal, SmilePlus, Trash2, X } from "lucide-react"; +import { + ImagePlus, + MessageCircle, + ReplyIcon, + SendHorizontal, + SmilePlus, + Trash2, + X, +} from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, - AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { Button } from "@/components/ui/button"; -import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; -import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader } from "@/components/ui/message"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@/components/ui/input-group"; +import { + Message, + MessageAvatar, + MessageContent, + MessageFooter, + MessageGroup, + MessageHeader, +} from "@/components/ui/message"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { parseCommissionMessageEvent } from "@/lib/commission/message-event"; import { CommissionPushEntryPrompt } from "./push-entry-prompt"; -type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null; - authorImage: string | null; replyToId: string | null; - imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] }; -type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null; - replyToId: string | null; status: "sending" | "sent" }; +type ChatMessage = { + id: string; + authorId: string; + authorName: string; + text: string | null; + authorImage: string | null; + replyToId: string | null; + imageUrl: string | null; + createdAt: Date; + reactions: { userId: string; emoji: string }[]; +}; +type OptimisticMessage = { + localId: string; + serverId: string | null; + text: string; + imageUrl: string | null; + replyToId: string | null; + status: "sending" | "sent"; +}; type OptimisticReaction = { active: boolean; status: "sending" | "sent" }; const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false }); -const reactionKey = (messageId: string, emoji: string) => `${messageId}:${emoji}`; +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("-"); + const unified = Array.from(emoji, (character) => + character.codePointAt(0)!.toString(16), + ).join("-"); if (failed) return ; // CDN images are already 64 px assets displayed at 16 px. // eslint-disable-next-line @next/next/no-img-element - return setFailed(true)} />; + return ( + setFailed(true)} + /> + ); } function ReplyPreview({ message }: { message: ChatMessage }) { - return
- {message.authorName} -

{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}

-
; + return ( +
+ {message.authorName} +

+ {message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")} +

+
+ ); } function CommissionMessageImage({ src }: { src: string }) { @@ -58,26 +147,67 @@ function CommissionMessageImage({ src }: { src: string }) { setState(imageRef.current.naturalWidth > 0 ? "loaded" : "error"); }, [src]); - return - }> - {state === "loading" && } - {state === "error" && โหลดรูปภาพไม่สำเร็จ} - {/* eslint-disable-next-line @next/next/no-img-element */} - รูปภาพที่แนบในข้อความ setState("loaded")} onError={() => setState("error")} - className={cn("max-h-72 max-w-full object-contain", state === "loaded" ? "block" : "hidden")} /> - - - รูปภาพที่แนบในข้อความ - {/* eslint-disable-next-line @next/next/no-img-element */} - รูปภาพที่แนบในข้อความขนาดใหญ่ - - ; + return ( + + + } + > + {state === "loading" && ( + + )} + {state === "error" && ( + โหลดรูปภาพไม่สำเร็จ + )} + {/* eslint-disable-next-line @next/next/no-img-element */} + รูปภาพที่แนบในข้อความ setState("loaded")} + onError={() => setState("error")} + className={cn( + "max-h-72 max-w-full object-contain", + state === "loaded" ? "block" : "hidden", + )} + /> + + + รูปภาพที่แนบในข้อความ + {/* eslint-disable-next-line @next/next/no-img-element */} + รูปภาพที่แนบในข้อความขนาดใหญ่ + + + ); } -export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, messages, admin }: { - ticketId: string; ticketTitle: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean; +export function CommissionTicketChat({ + ticketId, + ticketTitle, + userId, + status, + messages, + admin, +}: { + ticketId: string; + ticketTitle: string; + userId: string; + status: string; + messages: ChatMessage[]; + admin: boolean; }) { const router = useRouter(); const scrollRef = useRef(null); @@ -90,38 +220,62 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me const [imagePreview, setImagePreview] = useState(null); const imagePreviewRef = useRef(null); const optimisticImageUrls = useRef(new Set()); - const [optimisticMessages, setOptimisticMessages] = useState([]); + const [optimisticMessages, setOptimisticMessages] = useState< + OptimisticMessage[] + >([]); const nextOptimisticId = useRef(0); const [reactionFor, setReactionFor] = useState(null); const [replyToId, setReplyToId] = useState(null); const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [closedAlertOpen, setClosedAlertOpen] = useState(false); const [statusBusy, setStatusBusy] = useState(false); - const [optimisticReactions, setOptimisticReactions] = useState>({}); + const [optimisticReactions, setOptimisticReactions] = useState< + Record + >({}); const pendingReactions = useRef(new Set()); const [deleteFor, setDeleteFor] = useState(null); const [deletingId, setDeletingId] = useState(null); - const [deletedMessageIds, setDeletedMessageIds] = useState>(() => new Set()); - const [typing, setTyping] = useState<{ userId: string; name: string } | null>(null); + const [deletedMessageIds, setDeletedMessageIds] = useState>( + () => new Set(), + ); + const [typing, setTyping] = useState<{ userId: string; name: string } | null>( + null, + ); const lastTypingSent = useRef(0); const typingActive = useRef(false); const sendingRef = useRef(false); const deferredRefresh = useRef(false); - const visibleOptimisticMessages = optimisticMessages.filter((message) => !message.serverId || - !messages.some((saved) => saved.id === message.serverId)); - const visibleMessages = messages.filter((message) => !deletedMessageIds.has(message.id)); - const visibleMessageById = new Map(visibleMessages.map((message) => [message.id, message])); - const replyTarget = replyToId ? visibleMessageById.get(replyToId) ?? null : null; + const visibleOptimisticMessages = optimisticMessages.filter( + (message) => + !message.serverId || + !messages.some((saved) => saved.id === message.serverId), + ); + const visibleMessages = messages.filter( + (message) => !deletedMessageIds.has(message.id), + ); + const visibleMessageById = new Map( + visibleMessages.map((message) => [message.id, message]), + ); + const replyTarget = replyToId + ? (visibleMessageById.get(replyToId) ?? null) + : null; - useEffect(() => () => { - if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current); - for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url); - }, []); + useEffect( + () => () => { + if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current); + for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url); + }, + [], + ); useEffect(() => { for (const message of optimisticMessages) { - if (message.serverId && messages.some((saved) => saved.id === message.serverId) && - message.imageUrl && optimisticImageUrls.current.has(message.imageUrl)) { + if ( + message.serverId && + messages.some((saved) => saved.id === message.serverId) && + message.imageUrl && + optimisticImageUrls.current.has(message.imageUrl) + ) { URL.revokeObjectURL(message.imageUrl); optimisticImageUrls.current.delete(message.imageUrl); } @@ -141,7 +295,9 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me }; document.addEventListener("visibilitychange", markRead); window.addEventListener("focus", markRead); - const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`); + const source = new EventSource( + `/api/commission/events?ticketId=${ticketId}`, + ); let typingTimeout: ReturnType | undefined; const refresh = () => { if (sendingRef.current) deferredRefresh.current = true; @@ -152,22 +308,41 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me if (!value.startsWith("typing:")) { if (value === "status:closed" && !admin) setClosedAlertOpen(true); const message = parseCommissionMessageEvent(value); - if (message && message.authorId !== userId && (document.hidden || !document.hasFocus())) { + if ( + message && + message.authorId !== userId && + (document.hidden || !document.hasFocus()) + ) { unread += 1; managedTitle = `(${unread}) ${ticketTitle}`; document.title = managedTitle; } - refresh(); return; + refresh(); + return; } try { - const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean }; - if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return; + const update = JSON.parse(value.slice(7)) as { + userId: string; + name: string; + active: boolean; + }; + if ( + update.userId === userId || + typeof update.name !== "string" || + typeof update.active !== "boolean" + ) + return; if (typingTimeout) clearTimeout(typingTimeout); if (update.active) { setTyping({ userId: update.userId, name: update.name }); typingTimeout = setTimeout(() => setTyping(null), 6000); - } else setTyping((current) => current?.userId === update.userId ? null : current); - } catch { /* Ignore malformed transient signals. */ } + } else + setTyping((current) => + current?.userId === update.userId ? null : current, + ); + } catch { + /* Ignore malformed transient signals. */ + } }; source.addEventListener("changed", receive); source.addEventListener("open", refresh); @@ -184,7 +359,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me useEffect(() => { const viewport = scrollRef.current; - if (viewport && stayAtBottom.current) viewport.scrollTop = viewport.scrollHeight; + if (viewport && stayAtBottom.current) + viewport.scrollTop = viewport.scrollHeight; }, [messages, optimisticMessages]); useEffect(() => { @@ -201,12 +377,15 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me 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 && now - lastTypingSent.current < 4000) + return; if (!active && !typingActive.current) return; typingActive.current = active; if (active) lastTypingSent.current = now; void fetch(`/api/commission/tickets/${ticketId}/typing`, { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active }), + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ active }), }).catch(() => undefined); } @@ -219,8 +398,11 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me } function chooseAttachment(file: File | null) { - if (file && (file.size > 6 * 1024 * 1024 || - !["image/png", "image/jpeg", "image/webp"].includes(file.type))) { + if ( + file && + (file.size > 6 * 1024 * 1024 || + !["image/png", "image/jpeg", "image/webp"].includes(file.type)) + ) { setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB"); return; } @@ -230,7 +412,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me function handleComposerPaste(event: ClipboardEvent) { const pastedImage = Array.from(event.clipboardData.items) - .find((item) => item.kind === "file" && item.type.startsWith("image/"))?.getAsFile(); + .find((item) => item.kind === "file" && item.type.startsWith("image/")) + ?.getAsFile(); if (!pastedImage) return; event.preventDefault(); if (fileRef.current) fileRef.current.value = ""; @@ -240,7 +423,8 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me async function send(event: FormEvent) { event.preventDefault(); if (busy || (!text.trim() && !image)) return; - setBusy(true); setError(""); + setBusy(true); + setError(""); sendingRef.current = true; const form = event.currentTarget; const originalText = text; @@ -248,10 +432,21 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me const localId = String(++nextOptimisticId.current); const imageUrl = image ? URL.createObjectURL(image) : null; if (imageUrl) optimisticImageUrls.current.add(imageUrl); - setOptimisticMessages((current) => [...current.filter((message) => !message.serverId || - !messages.some((saved) => saved.id === message.serverId)), { - localId, serverId: null, text: text.trim(), imageUrl, replyToId: replyTarget?.id ?? null, status: "sending", - }]); + setOptimisticMessages((current) => [ + ...current.filter( + (message) => + !message.serverId || + !messages.some((saved) => saved.id === message.serverId), + ), + { + localId, + serverId: null, + text: text.trim(), + imageUrl, + replyToId: replyTarget?.id ?? null, + status: "sending", + }, + ]); stayAtBottom.current = true; try { const data = new FormData(form); @@ -260,23 +455,48 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me else data.delete("replyToId"); if (image) data.set("image", image); else data.delete("image"); - form.reset(); setText(""); setAttachment(null); setReplyToId(null); sendTyping(false); - const response = await fetch(`/api/commission/tickets/${ticketId}/messages`, { method: "POST", body: data }); + form.reset(); + setText(""); + setAttachment(null); + setReplyToId(null); + sendTyping(false); + const response = await fetch( + `/api/commission/tickets/${ticketId}/messages`, + { method: "POST", body: data }, + ); if (!response.ok) throw new Error("ส่งข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); - const result = await response.json() as { messageId: string }; - setOptimisticMessages((current) => current.map((message) => message.localId === localId ? - { ...message, serverId: result.messageId, status: "sent" } : message)); + const result = (await response.json()) as { messageId: string }; + setOptimisticMessages((current) => + current.map((message) => + message.localId === localId + ? { ...message, serverId: result.messageId, status: "sent" } + : message, + ), + ); stayAtBottom.current = true; - sendingRef.current = false; deferredRefresh.current = false; router.refresh(); + sendingRef.current = false; + deferredRefresh.current = false; + router.refresh(); } catch (cause) { - if (imageUrl) { URL.revokeObjectURL(imageUrl); optimisticImageUrls.current.delete(imageUrl); } - setOptimisticMessages((current) => current.filter((message) => message.localId !== localId)); - setText(originalText); setAttachment(originalImage); setReplyToId(replyTarget?.id ?? null); + if (imageUrl) { + URL.revokeObjectURL(imageUrl); + optimisticImageUrls.current.delete(imageUrl); + } + setOptimisticMessages((current) => + current.filter((message) => message.localId !== localId), + ); + setText(originalText); + setAttachment(originalImage); + setReplyToId(replyTarget?.id ?? null); setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ"); sendingRef.current = false; - if (deferredRefresh.current) { deferredRefresh.current = false; router.refresh(); } + if (deferredRefresh.current) { + deferredRefresh.current = false; + router.refresh(); + } + } finally { + setBusy(false); } - finally { setBusy(false); } } async function react(messageId: string, emoji: string) { @@ -285,19 +505,35 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me if (pendingReactions.current.has(key)) return; const message = messages.find((item) => item.id === messageId); if (!message) return; - const savedActive = message.reactions.some((item) => item.emoji === emoji && item.userId === userId); + const savedActive = message.reactions.some( + (item) => item.emoji === emoji && item.userId === userId, + ); const previous = optimisticReactions[key]; - const currentActive = previous?.status === "sent" && previous.active === savedActive - ? savedActive : previous?.active ?? savedActive; + const currentActive = + previous?.status === "sent" && previous.active === savedActive + ? savedActive + : (previous?.active ?? savedActive); pendingReactions.current.add(key); setError(""); - setOptimisticReactions((current) => ({ ...current, [key]: { active: !currentActive, status: "sending" } })); + setOptimisticReactions((current) => ({ + ...current, + [key]: { active: !currentActive, status: "sending" }, + })); try { - const response = await fetch(`/api/commission/tickets/${ticketId}/reactions`, { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messageId, emoji }) }); + const response = await fetch( + `/api/commission/tickets/${ticketId}/reactions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messageId, emoji }), + }, + ); if (!response.ok) throw new Error(); - const result = await response.json() as { active: boolean }; - setOptimisticReactions((current) => ({ ...current, [key]: { active: result.active, status: "sent" } })); + const result = (await response.json()) as { active: boolean }; + setOptimisticReactions((current) => ({ + ...current, + [key]: { active: result.active, status: "sent" }, + })); router.refresh(); } catch { setOptimisticReactions((current) => { @@ -306,232 +542,633 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me return next; }); setError("เปลี่ยนรีแอ็กชันไม่สำเร็จ กรุณาลองอีกครั้ง"); - } finally { pendingReactions.current.delete(key); } + } finally { + pendingReactions.current.delete(key); + } } async function deleteMessage(messageId: string) { if (deletingId) return; - setDeleteFor(null); setDeletingId(messageId); setError(""); + setDeleteFor(null); + setDeletingId(messageId); + setError(""); try { - const response = await fetch(`/api/commission/tickets/${ticketId}/messages/${messageId}`, { method: "DELETE" }); + const response = await fetch( + `/api/commission/tickets/${ticketId}/messages/${messageId}`, + { method: "DELETE" }, + ); if (!response.ok) throw new Error(); setDeletedMessageIds((current) => new Set(current).add(messageId)); if (replyToId === messageId) setReplyToId(null); setReactionFor(null); router.refresh(); - } catch { setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); } - finally { setDeletingId(null); } + } catch { + setError("ลบข้อความไม่สำเร็จ กรุณาลองอีกครั้ง"); + } finally { + setDeletingId(null); + } } async function toggleStatus() { if (statusBusy) return; setStatusBusy(true); try { - const response = await fetch(`/api/commission/tickets/${ticketId}/status`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: status === "open" ? "closed" : "open" }) }); + 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); } + } catch { + setError("เปลี่ยนสถานะ Ticket ไม่สำเร็จ กรุณาลองอีกครั้ง"); + } finally { + setStatusBusy(false); + } } function handleComposerKeyDown(event: KeyboardEvent) { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + if ( + event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } } - return <>{!admin && } - - - Ticket นี้ปิดแล้ว - ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้ - - setClosedAlertOpen(false)}>รับทราบ - - - - - แชต{admin ? "กับลูกค้า" : "กับทีมงาน"} - พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้ - - {status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} - {admin && (status === "open" ? - }>ปิด Ticket - - ปิด Ticket นี้? - ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง - - ยกเลิก - void toggleStatus()}>ยืนยันการปิด - - : )} - - - {!admin && status === "closed" && - Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้ - } - { const viewport = event.currentTarget; - stayAtBottom.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; }} - className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-muted/20 px-3 py-5 sm:px-5"> - {visibleMessages.length === 0 && visibleOptimisticMessages.length === 0 ? - - เริ่มบทสนทนา - ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้ - - : - {visibleMessages.map((message) => { - const mine = message.authorId === userId; - const sentAt = new Date(message.createdAt); - const parent = message.replyToId ? visibleMessageById.get(message.replyToId) : null; - const emojiValues = new Set([ - ...message.reactions.map((reaction) => reaction.emoji), - ...Object.keys(optimisticReactions).filter((key) => key.startsWith(`${message.id}:`)) - .map((key) => key.slice(message.id.length + 1)), - ]); - const counts = [...emojiValues].map((emoji) => { - const savedCount = message.reactions.filter((reaction) => reaction.emoji === emoji).length; - const savedActive = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.userId === userId); - const update = optimisticReactions[reactionKey(message.id, emoji)]; - const visibleUpdate = update && (update.status === "sending" || update.active !== savedActive) ? update : null; - const active = visibleUpdate?.active ?? savedActive; - const count = savedCount + (visibleUpdate && active !== savedActive ? active ? 1 : -1 : 0); - return { emoji, count, active, pending: update?.status === "sending" }; - }).filter(({ count }) => count > 0); - return - - {message.authorImage && } - - {message.authorName.trim().charAt(0).toUpperCase() || "?"} - - - {mine ? "คุณ" : message.authorName} -
- - - {parent && } - {message.text &&

{message.text}

} - {message.imageUrl && } -
-
-
- {status === "open" && setReactionFor(open ? message.id : null)}> - }> - - - - void react(message.id, value.emoji)} /> - - } - {status === "open" && } - {mine && setDeleteFor(open ? message.id : null)}> - }> + return ( + <> + {!admin && } + + + + Ticket นี้ปิดแล้ว + + ทีมงานปิด Ticket นี้แล้ว คุณยังอ่านบทสนทนาเดิมได้ + + + + setClosedAlertOpen(false)}> + รับทราบ + + + + + + + แชต{admin ? "กับลูกค้า" : "กับทีมงาน"} + + + {status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} + + {admin && + (status === "open" ? ( + + + } + > + ปิด Ticket + - ลบข้อความนี้? - ข้อความและรูปภาพที่แนบจะถูกลบถาวร + ปิด Ticket นี้? + + ลูกค้าจะได้รับแจ้งว่า Ticket ปิดแล้ว + และจะส่งข้อความเพิ่มไม่ได้จนกว่าจะเปิดอีกครั้ง + ยกเลิก - void deleteMessage(message.id)}>ลบข้อความ + void toggleStatus()}> + ยืนยันการปิด + - } -
-
- {counts.length > 0 &&
- {counts.map(({ emoji, count, active, pending }) => )} -
} - - - -
-
; - })} - {visibleOptimisticMessages.map((message) => - - คุณ - - - {message.replyToId && visibleMessageById.get(message.replyToId) && - } - {message.text &&

{message.text}

} - {message.imageUrl &&
- -
} -
-
- - {message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"} - -
-
)} -
} -
- - {typing && status === "open" &&

{typing.name} typing...

} - {status === "open" ?
- {replyTarget &&
-
- -
} - {image &&
- {/* eslint-disable-next-line @next/next/no-img-element */} - {imagePreview && ตัวอย่างรูปภาพที่จะแนบ} - -
} - ข้อความ - - - fileRef.current?.click()}> - - { const next = event.target.value; setText(next); sendTyping(Boolean(next.trim())); }} - onBlur={() => sendTyping(false)} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste} - placeholder="พิมพ์ข้อความ..." maxLength={4000} disabled={busy} aria-describedby="commission-message-hint" - className="max-h-32 min-h-11 min-w-0 px-1.5 py-2.5" /> - - - {busy ? "กำลังส่ง..." : "ส่ง"} - - - - กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่ - - { - const selected = event.target.files?.[0] ?? null; - chooseAttachment(selected); - if (selected && (selected.size > 6 * 1024 * 1024 || - !["image/png", "image/jpeg", "image/webp"].includes(selected.type))) event.target.value = ""; - }} /> -
:

Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง

} - {error && {error}} -
-
; + + ) : ( + + ))} + + + {!admin && status === "closed" && ( + + + Ticket นี้ปิดแล้ว คุณยังอ่านบทสนทนาเดิมได้ + + + )} + { + const viewport = event.currentTarget; + stayAtBottom.current = + viewport.scrollHeight - + viewport.scrollTop - + viewport.clientHeight < + 80; + }} + className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-muted/20 px-3 py-5 sm:px-5" + > + {visibleMessages.length === 0 && + visibleOptimisticMessages.length === 0 ? ( + + + + + + เริ่มบทสนทนา + + ส่งข้อความหรือรูปภาพเพื่อพูดคุยเกี่ยวกับคำขอนี้ + + + + ) : ( + + {visibleMessages.map((message) => { + const mine = message.authorId === userId; + const sentAt = new Date(message.createdAt); + const parent = message.replyToId + ? visibleMessageById.get(message.replyToId) + : null; + const emojiValues = new Set([ + ...message.reactions.map((reaction) => reaction.emoji), + ...Object.keys(optimisticReactions) + .filter((key) => key.startsWith(`${message.id}:`)) + .map((key) => key.slice(message.id.length + 1)), + ]); + const counts = [...emojiValues] + .map((emoji) => { + const savedCount = message.reactions.filter( + (reaction) => reaction.emoji === emoji, + ).length; + const savedActive = message.reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.userId === userId, + ); + const update = + optimisticReactions[reactionKey(message.id, emoji)]; + const visibleUpdate = + update && + (update.status === "sending" || + update.active !== savedActive) + ? update + : null; + const active = visibleUpdate?.active ?? savedActive; + const count = + savedCount + + (visibleUpdate && active !== savedActive + ? active + ? 1 + : -1 + : 0); + return { + emoji, + count, + active, + pending: update?.status === "sending", + }; + }) + .filter(({ count }) => count > 0); + return ( + + + + {message.authorImage && ( + + )} + + {message.authorName.trim().charAt(0).toUpperCase() || + "?"} + + + + + + {mine ? "คุณ" : message.authorName} + +
+ + + {parent && } + {message.text && ( +

+ {message.text} +

+ )} + {message.imageUrl && ( + + )} +
+
+
+ {status === "open" && ( + + setReactionFor(open ? message.id : null) + } + > + + } + > + + + + + void react(message.id, value.emoji) + } + /> + + + )} + {status === "open" && ( + + )} + {mine && ( + + setDeleteFor(open ? message.id : null) + } + > + + } + > + + + + + + ลบข้อความนี้? + + + ข้อความและรูปภาพที่แนบจะถูกลบถาวร + + + + ยกเลิก + + void deleteMessage(message.id) + } + > + ลบข้อความ + + + + + )} +
+
+ {counts.length > 0 && ( +
+ {counts.map(({ emoji, count, active, pending }) => ( + + ))} +
+ )} + + + +
+
+ ); + })} + {visibleOptimisticMessages.map((message) => ( + + + คุณ + + + {message.replyToId && + visibleMessageById.get(message.replyToId) && ( + + )} + {message.text && ( +

+ {message.text} +

+ )} + {message.imageUrl && ( +
+ +
+ )} +
+
+ + {message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"} + +
+
+ ))} +
+ )} +
+ + {typing && status === "open" && ( +

+ {typing.name} typing... +

+ )} + {status === "open" ? ( +
+ + {replyTarget && ( +
+
+ +
+ +
+ )} + {image && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {imagePreview && ( + ตัวอย่างรูปภาพที่จะแนบ + )} + +
+ )} + + + ข้อความ + + + + fileRef.current?.click()} + > + + + + { + const next = event.target.value; + setText(next); + sendTyping(Boolean(next.trim())); + }} + onBlur={() => sendTyping(false)} + onKeyDown={handleComposerKeyDown} + onPaste={handleComposerPaste} + placeholder="พิมพ์ข้อความ..." + maxLength={4000} + disabled={busy} + aria-describedby="commission-message-hint" + className="max-h-32 min-h-11 min-w-0 px-1.5 py-2.5" + /> + + + + + {busy ? "กำลังส่ง..." : "ส่ง"} + + + + + + กด Enter เพื่อส่ง หรือ Shift+Enter เพื่อขึ้นบรรทัดใหม่ + + + { + const selected = event.target.files?.[0] ?? null; + chooseAttachment(selected); + if ( + selected && + (selected.size > 6 * 1024 * 1024 || + !["image/png", "image/jpeg", "image/webp"].includes( + selected.type, + )) + ) + event.target.value = ""; + }} + /> +
+
+ ) : ( +

+ Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket + อีกครั้ง +

+ )} + {error && ( + + {error} + + )} +
+ + + ); } diff --git a/components/site-entry-prompt.tsx b/components/site-entry-prompt.tsx new file mode 100644 index 0000000..e606259 --- /dev/null +++ b/components/site-entry-prompt.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; + +import { syncCommissionPush } from "@/components/commission/push-client"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +type InstallEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: string }> }; + +const dismissedKey = "site-entry-prompt-dismissed"; + +function isInstalled() { + return window.matchMedia("(display-mode: standalone)").matches || + (navigator as Navigator & { standalone?: boolean }).standalone === true; +} + +export function SiteEntryPrompt() { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const [installed, setInstalled] = useState(false); + const [installEvent, setInstallEvent] = useState(null); + const [instructions, setInstructions] = useState(false); + const [permission, setPermission] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + // Commission pages have their own prompt, tied to the signed-in user's messages. + if (pathname !== "/") { + const frame = requestAnimationFrame(() => setOpen(false)); + return () => cancelAnimationFrame(frame); + } + + const installedNow = isInstalled(); + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const mobile = ios || /Android|Mobile/.test(navigator.userAgent); + const canNotify = window.isSecureContext && "Notification" in window && + "PushManager" in window && "serviceWorker" in navigator; + + const frame = requestAnimationFrame(() => { + setInstalled(installedNow); + setPermission(canNotify ? Notification.permission : null); + if (!sessionStorage.getItem(dismissedKey) && + ((mobile && !installedNow) || (canNotify && Notification.permission === "default"))) { + setOpen(true); + } + }); + + const beforeInstall = (event: Event) => { + event.preventDefault(); + setInstallEvent(event as InstallEvent); + if (!sessionStorage.getItem(dismissedKey)) setOpen(true); + }; + const onInstalled = () => { + setInstalled(true); + setInstallEvent(null); + setInstructions(false); + if (!canNotify || Notification.permission !== "default") setOpen(false); + }; + window.addEventListener("beforeinstallprompt", beforeInstall); + window.addEventListener("appinstalled", onInstalled); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("beforeinstallprompt", beforeInstall); + window.removeEventListener("appinstalled", onInstalled); + }; + }, [pathname]); + + function dismiss() { + sessionStorage.setItem(dismissedKey, "1"); + setOpen(false); + } + + async function install() { + if (!installEvent) { + setInstructions(true); + return; + } + await installEvent.prompt(); + await installEvent.userChoice; + setInstallEvent(null); + } + + async function enableNotifications() { + setBusy(true); + setError(""); + try { + const result = await Notification.requestPermission(); + setPermission(result); + if (result !== "granted") { + if (!showInstall) setOpen(false); + return; + } + + // A signed-in commission user can receive messages immediately. Guests can + // subscribe when they later sign in and visit their commission page. + const response = await fetch("/api/commission/push-subscription", { cache: "no-store" }); + if (response.ok) { + const config = await response.json() as { publicKey: string; canAdmin: boolean }; + await syncCommissionPush(config.canAdmin ? "admin" : "customer", config.publicKey); + } + if (!showInstall) setOpen(false); + } catch { + setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง"); + } finally { + setBusy(false); + } + } + + const ios = typeof navigator !== "undefined" && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)); + const mobile = typeof navigator !== "undefined" && + (ios || /Android|Mobile/.test(navigator.userAgent)); + const showInstall = !installed && (mobile || installEvent !== null); + const canAskNotification = permission === "default" && (!ios || installed); + + return { if (!next) dismiss(); }}> + + + {showInstall ? "ติดตั้ง Buzz Guide และรับการแจ้งเตือน" : "รับการแจ้งเตือนจาก Buzz Guide"} + + {showInstall ? + "เพิ่มเว็บไซต์ลงหน้าจอหลักเพื่อเปิดใช้งานได้สะดวก และรับการแจ้งเตือนเมื่อมีข้อความใหม่ใน Commission หลังเข้าสู่ระบบ" : + "รับการแจ้งเตือนเมื่อมีข้อความใหม่ใน Commission หลังเข้าสู่ระบบ"} + + + {!installed && (ios || instructions) &&

+ {ios ? "บน iPhone/iPad แตะปุ่มแชร์ใน Safari แล้วเลือก ‘เพิ่มไปยังหน้าจอโฮม’ จากนั้นเปิดแอปที่ติดตั้ง" : + "เปิดเมนูของเบราว์เซอร์ แล้วเลือก ‘ติดตั้งแอป’ หรือ ‘เพิ่มไปยังหน้าจอหลัก’"} +

} + {ios && !installed &&

+ ติดตั้งและเปิดจากหน้าจอโฮมก่อน จึงจะเปิดการแจ้งเตือนได้บน iPhone/iPad +

} + {error &&

{error}

} + + + {showInstall && !ios && } + {canAskNotification && } + +
+
; +} diff --git a/k8s.md b/k8s.md index d38a943..1bc29f2 100644 --- a/k8s.md +++ b/k8s.md @@ -1,6 +1,6 @@ # Push `.env` to Kubernetes -Buzz Sheet reads private configuration from the `buzz-sheet-env` Kubernetes +Buzz Guide reads private configuration from the `buzz-sheet-env` Kubernetes Secret in the `buzz-sheet` namespace. Use the local `.env` file as the source. Never commit `.env` or paste its values into a Kubernetes manifest. @@ -54,7 +54,7 @@ updates to the running site. ## 3. Restart the application Environment variables sourced from a Secret are read when a pod starts. -Restart all Buzz Sheet workloads after updating the Secret: +Restart all Buzz Guide workloads after updating the Secret: ```bash kubectl rollout restart deployment/buzz-sheet \ diff --git a/lib/auth/server.ts b/lib/auth/server.ts index a459eed..6f463a7 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -33,7 +33,7 @@ function hasAuthConfiguration(): boolean { function createAuth() { return betterAuth({ - appName: "Buzz Sheet", + appName: "Buzz Guide", database: drizzleAdapter(getDb(), { provider: "pg", schema: { diff --git a/security/audit-exceptions.json b/security/audit-exceptions.json index 16e5e60..590b2fb 100644 --- a/security/audit-exceptions.json +++ b/security/audit-exceptions.json @@ -2,7 +2,7 @@ { "package": "esbuild", "advisory": "GHSA-67mh-4wv8-2f99", - "owner": "Buzz Sheet maintainers", + "owner": "Buzz Guide maintainers", "expires": "2026-10-22", "reason": "drizzle-kit 0.31.11 still includes @esbuild-kit/esm-loader -> core-utils -> esbuild 0.18.20. The advisory affects esbuild's development HTTP server; this project uses the loader for local schema tooling and does not start that server. Production application and bundled migration images must pass the separate image scan. Recheck upstream before expiry." }