From 5d195968ecfbeb02508122b402cc622eccb2358e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 7 Oct 2026 17:03:15 +0700 Subject: [PATCH] feat : updae --- app/commission/page.tsx | 19 +-- app/commission/tickets/page.tsx | 9 +- app/donate/page.tsx | 4 +- app/not-found.tsx | 6 +- app/stygian/page.tsx | 5 +- app/thanks/page.tsx | 23 +--- bun.lock | 3 + components/admin/guide-section-editor.tsx | 6 +- components/auth/account-form.tsx | 102 ++++++++++---- components/auth/email-action-form.tsx | 2 +- components/auth/registration-otp-form.tsx | 10 +- components/auth/sign-out-button.tsx | 33 ++++- components/commission/commission-intro.tsx | 6 +- components/commission/income-calendar.tsx | 2 +- components/commission/mobile-slip-form.tsx | 2 +- components/commission/mobile-slip-handoff.tsx | 10 +- components/commission/payment-form.tsx | 4 +- components/commission/request-form.tsx | 13 +- components/commission/ticket-chat.tsx | 5 +- components/public/artifact-image-export.tsx | 4 +- components/public/comparison-context.test.tsx | 35 +++++ components/public/comparison-context.tsx | 23 ++++ components/public/condition-switcher.tsx | 6 +- components/public/guide-image-export.tsx | 14 +- components/public/public-account-menu.tsx | 51 +++++++ components/public/public-navigation.tsx | 2 +- components/public/rotation-help.tsx | 3 +- components/public/scrollable-nav.tsx | 4 +- components/public/site-footer.tsx | 4 +- components/public/site-header.tsx | 4 +- components/public/structured-directory.tsx | 17 ++- components/public/structured-guide.tsx | 54 +++++--- components/public/stygian-guide.tsx | 10 +- components/public/team-search.tsx | 20 ++- components/public/term-detail.tsx | 4 +- components/public/weapon-chart.tsx | 124 ++++++------------ components/public/weapon-display-mode.tsx | 6 +- components/public/weapon-image-export.tsx | 8 +- components/stygian/stygian-boss-display.tsx | 2 +- components/ui/input-otp.tsx | 86 ++++++++++++ lib/guides/image-export.test.tsx | 10 +- package.json | 1 + tests/security-boundaries.test.ts | 2 +- 43 files changed, 515 insertions(+), 243 deletions(-) create mode 100644 components/public/comparison-context.test.tsx create mode 100644 components/public/comparison-context.tsx create mode 100644 components/public/public-account-menu.tsx create mode 100644 components/ui/input-otp.tsx diff --git a/app/commission/page.tsx b/app/commission/page.tsx index 30e58bd..6af8c8b 100644 --- a/app/commission/page.tsx +++ b/app/commission/page.tsx @@ -19,22 +19,23 @@ async function CommissionContent() { if (!availability.isOpen) return

ปิดรับ Commission ชั่วคราว

{availability.closureMessage}

-
; const session = await getCustomerSession(); - if (!session) return
-

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

+ if (!session) return
+

เริ่มคำขอ

+

เข้าสู่ระบบหรือสมัครสมาชิกเพื่อบันทึกคำขอ ติดตามผล และพูดคุยกับทีมงาน

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

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

; return
-
+
; } @@ -42,7 +43,7 @@ async function CommissionContent() { export default function CommissionPage() { return
-

Commission

+

จ้างคำนวณทีม

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

}>
diff --git a/app/commission/tickets/page.tsx b/app/commission/tickets/page.tsx index 8b11369..7d024e7 100644 --- a/app/commission/tickets/page.tsx +++ b/app/commission/tickets/page.tsx @@ -30,21 +30,22 @@ export default async function CommissionTicketsPage() { return
-

Ticket ของฉัน

+

คำขอของฉัน

+

แต่ละคำขอมีแชตสำหรับรับผลและพูดคุยกับทีมงาน (Ticket) สถานะแชตบอกว่ายังส่งข้อความได้หรือไม่ ส่วนความคืบหน้าของงานสอบถามได้ในแชต

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

รอชำระเงิน

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

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

- {tickets.length === 0 &&

ยังไม่มี Ticket

} +

คำขอที่ชำระเงินแล้ว

+ {tickets.length === 0 &&

ยังไม่มีคำขอที่ชำระเงินแล้ว

} {tickets.map(({ ticket, amount }) => {ticket.title || `Ticket #${ticket.id.slice(0, 8)}`} - {ticket.status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} + {ticket.status === "open" ? "แชตเปิดอยู่" : "แชตปิดแล้ว"} ฿{amount} - {ticket.updatedAt.toLocaleString("th-TH")} )} diff --git a/app/donate/page.tsx b/app/donate/page.tsx index 70ed0fa..fc183a2 100644 --- a/app/donate/page.tsx +++ b/app/donate/page.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import Image from "@/components/ui/resilient-image"; import { SiteFooter } from "@/components/public/site-footer"; @@ -33,8 +34,9 @@ export default function DonatePage() { priority />
+

- สามารถสแกน QR Code ได้ทุกแอปธนาคาร + สแกน QR ด้วยแอปธนาคาร หรือบันทึก QR แล้วเลือกรูปในแอปธนาคารบนมือถือเครื่องนี้
ขอบคุณทุกการสนับสนุน เลิฟๆคับ

diff --git a/app/not-found.tsx b/app/not-found.tsx index 92b1ab5..e71aa07 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -20,13 +20,13 @@ export default function NotFound() { - ไม่พบคู่มือนี้ + ไม่พบหน้านี้ - หน้านี้อาจถูกซ่อน เปลี่ยนชื่อ หรือยังไม่มีเนื้อหาที่เผยแพร่ + ลิงก์อาจไม่ถูกต้อง หน้าถูกย้าย หรือคุณไม่มีสิทธิ์เข้าถึง - + diff --git a/app/stygian/page.tsx b/app/stygian/page.tsx index 23bbad2..becda36 100644 --- a/app/stygian/page.tsx +++ b/app/stygian/page.tsx @@ -28,10 +28,11 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom

Stygian Onslaught

ไกด์ Stygian

+

เลือกเวอร์ชัน ระดับบอส และบอสที่ต้องการดู เพื่ออ่านทีมแนะนำและกลไกของบอส

- {schedules.length ?
: null} {schedule && catalog ? : ( ยังไม่มีไกด์ Stygianกำลังเตรียมข้อมูล กลับมาอ่านได้ภายหลัง หรือเลือกไกด์ตัวละครระหว่างรอเลือกไกด์ตัวละคร )} diff --git a/app/thanks/page.tsx b/app/thanks/page.tsx index 3a01847..4595a18 100644 --- a/app/thanks/page.tsx +++ b/app/thanks/page.tsx @@ -8,6 +8,7 @@ import { AvatarFallback, AvatarImage, } from "@/components/ui/avatar"; +import { SiteHeader } from "@/components/public/site-header"; import { SiteFooter } from "@/components/public/site-footer"; import { Button } from "@/components/ui/button"; import { @@ -55,7 +56,7 @@ const specialThanks: ThanksProfile[] = [ ]; export const metadata: Metadata = { - title: "Special Thanks", + title: "ขอบคุณผู้สนับสนุน", description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้", }; @@ -66,28 +67,12 @@ function getInitials(username: string) { export default function ThanksPage() { return (
-
-
- - - ไกด์เกนชินไม่ใช่เกมมือถือ - -
-
+

- Special Thanks + ขอบคุณผู้สนับสนุน

ขอบคุณทุกคนที่มอบแรงสนับสนุนและเวลาให้เว็บไซต์นี้เกิดขึ้นได้ diff --git a/bun.lock b/bun.lock index 49312b9..1a5eaae 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "emoji-picker-react": "^4.22.3", "emoji-regex": "^11.0.0", "entities": "7.0.1", + "input-otp": "^1.5.0", "ioredis": "^6.0.0", "lucide-react": "^1.35.0", "modern-screenshot": "^4.7.0", @@ -1296,6 +1297,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "input-otp": ["input-otp@1.5.0", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 07ac11c..644061d 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -2670,7 +2670,7 @@ function TeamImportPreviewCard({ {team.title} - โรเทชั่น {seconds} วิ{sourceLabel ? ` · ${sourceLabel}` : ""} + โรเทชั่น {seconds} วิ{sourceLabel ? ` - ${sourceLabel}` : ""} onCheckedChange(value === true)} /> @@ -4025,7 +4025,7 @@ function TeamEditor({ <>

- พบ {everywhereTeams.length} Team · เลือกได้ {eligibleEverywhereTeams.length} Team + พบ {everywhereTeams.length} Team - เลือกได้ {eligibleEverywhereTeams.length} Team

-
- + รหัสผ่าน + + + + setShowPassword((current) => !current)}> + {showPassword ? + + {register && ใช้รหัสผ่านอย่างน้อย 6 ตัวอักษร} - {captcha &&
} + {captcha && +

ตรวจสอบความปลอดภัยก่อนดำเนินการ

+
+ {captchaError && {captchaError}} + {captchaError && } + } : <>

ส่งรหัสไปที่ {pendingEmail}

+

หากกรอกอีเมลผิด ให้เริ่มสมัครด้วยอีเมลที่ใช้งานได้ บัญชีเดิมจะยังไม่ยืนยันอีเมล

+ รหัสยืนยัน 6 หลัก - + + + {Array.from({ length: 6 }, (_, index) => )} + + รหัสมีอายุ 5 นาที หากไม่พบอีเมล ลองดูในโฟลเดอร์สแปม diff --git a/components/auth/email-action-form.tsx b/components/auth/email-action-form.tsx index 066a1ad..2ffe3d0 100644 --- a/components/auth/email-action-form.tsx +++ b/components/auth/email-action-form.tsx @@ -73,7 +73,7 @@ export function EmailActionForm({ mode, token = "", invalidToken = false, nextPa } - } diff --git a/components/auth/registration-otp-form.tsx b/components/auth/registration-otp-form.tsx index f5a2c06..01c1219 100644 --- a/components/auth/registration-otp-form.tsx +++ b/components/auth/registration-otp-form.tsx @@ -3,11 +3,13 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useRef, useState, type FormEvent } from "react"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; import { authClient } from "@/lib/auth/client"; export function RegistrationOtpForm({ email, nextPath, setup }: { @@ -66,8 +68,12 @@ export function RegistrationOtpForm({ email, nextPath, setup }: { รหัส OTP - + + + {Array.from({ length: 6 }, (_, index) => )} + + diff --git a/components/auth/sign-out-button.tsx b/components/auth/sign-out-button.tsx index f3924a9..63c5546 100644 --- a/components/auth/sign-out-button.tsx +++ b/components/auth/sign-out-button.tsx @@ -2,11 +2,18 @@ import { useRouter } from "next/navigation"; import { LogOutIcon } from "lucide-react"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth/client"; import { unsubscribeCommissionPush } from "@/components/commission/push-client"; -export function SignOutButton({ admin = false }: { admin?: boolean }) { +export function SignOutButton({ + admin = false, + menuItem = false, +}: { + admin?: boolean; + menuItem?: boolean; +}) { const router = useRouter(); const { data } = authClient.useSession(); if (!admin && !data?.user) return null; @@ -14,11 +21,27 @@ export function SignOutButton({ admin = false }: { admin?: boolean }) { async function signOut() { await unsubscribeCommissionPush().catch(() => undefined); await authClient.signOut(); - router.push(admin ? "/auth/login?next=%2Fadmin" : "/commission"); + router.push(admin ? "/auth/login?next=%2Fadmin" : "/"); router.refresh(); } - return ; + if (menuItem) + return ( + void signOut()}> + + ออกจากระบบ + + ); + + return ( + + ); } diff --git a/components/commission/commission-intro.tsx b/components/commission/commission-intro.tsx index 660b844..eaafd91 100644 --- a/components/commission/commission-intro.tsx +++ b/components/commission/commission-intro.tsx @@ -7,7 +7,7 @@ import { Separator } from "@/components/ui/separator"; const steps = [ { title: "1. เลือกรายการ", description: "เลือกทีม อาวุธ หรือกลุ่มดาวที่ต้องการคำนวณ" }, { title: "2. ชำระเงิน", description: "ตรวจสอบยอด สแกน PromptPay และส่งสลิป" }, - { title: "3. รับผลใน Ticket", description: "ติดตามคำขอและพูดคุยเรื่องผลลัพธ์ใน Ticket ของคุณ" }, + { title: "3. รับผลในแชตคำขอ", description: "ติดตามคำขอและพูดคุยเรื่องผลลัพธ์ในแชตของคำขอ (Ticket)" }, ]; const sampleMembers = [ @@ -21,7 +21,7 @@ function CommissionTeamPreview() { return ตัวอย่างทีม 4 ตัวละคร - โรเทชั่น 20 วิ · ข้อมูลสมมติสำหรับดูรูปแบบผลลัพธ์ + โรเทชั่น 20 วิ - ข้อมูลสมมติสำหรับดูรูปแบบผลลัพธ์
@@ -54,7 +54,7 @@ export function CommissionIntro() {

ขอคำนวณแบบที่ต้องการ

เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน

-

ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ใน Ticket

+

ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)

{steps.map((step) => diff --git a/components/commission/income-calendar.tsx b/components/commission/income-calendar.tsx index 3725ffb..aef4e09 100644 --- a/components/commission/income-calendar.tsx +++ b/components/commission/income-calendar.tsx @@ -15,7 +15,7 @@ const date = new Intl.DateTimeFormat("th-TH", { timeZone: "Asia/Bangkok", day: " function Totals({ amount }: { amount: number }) { const shares = commissionIncomeShares(amount); - return รวม {money(amount * 100)} · Buzz {money(shares.buzzSatang)} · Gunshin {money(shares.gunshinSatang)}; + return รวม {money(amount * 100)} - Buzz {money(shares.buzzSatang)} - Gunshin {money(shares.gunshinSatang)}; } export function CommissionIncomeCalendar({ period, rows }: { period: Period; rows: IncomeRow[] }) { diff --git a/components/commission/mobile-slip-form.tsx b/components/commission/mobile-slip-form.tsx index 0cc90eb..a12d05b 100644 --- a/components/commission/mobile-slip-form.tsx +++ b/components/commission/mobile-slip-form.tsx @@ -49,7 +49,7 @@ export function MobileSlipForm() { {ready && <>

ยอดชำระ ฿{amount}

เลือกรูปสลิปจากแอปธนาคาร - +
} {error && {error}}
; diff --git a/components/commission/mobile-slip-handoff.tsx b/components/commission/mobile-slip-handoff.tsx index 90c4d6e..6b89b0d 100644 --- a/components/commission/mobile-slip-handoff.tsx +++ b/components/commission/mobile-slip-handoff.tsx @@ -70,15 +70,15 @@ export function MobileSlipHandoff({ checkoutId }: { checkoutId: string }) { }, [checkoutId, link, router]); return
- - อัปโหลดสลิปจากมือถือ - สแกน QR ด้วยมือถือ แล้วเลือกรูปสลิปจากแอปธนาคาร + ใช้มือถืออีกเครื่องอัปโหลดสลิป + สแกน QR นี้ด้วยมือถืออีกเครื่อง แล้วเลือกรูปสลิปจากแอปธนาคาร หากใช้มือถือเครื่องนี้ ให้ปิดหน้าต่างแล้วเลือกไฟล์สลิปได้โดยตรง {link &&
{!expired && <> @@ -86,7 +86,7 @@ export function MobileSlipHandoff({ checkoutId }: { checkoutId: string }) { QR สำหรับเปิดหน้าอัปโหลดสลิปบนมือถือ

เวลาที่เหลือ {timeLeft}

} - {expired &&

ลิงก์หมดอายุแล้ว ปิดหน้าต่างนี้แล้วกดอัปโหลดจากมือถืออีกครั้ง

} + {expired &&

ลิงก์หมดอายุแล้ว ปิดหน้าต่างนี้แล้วกดใช้มือถืออีกเครื่องอัปโหลดอีกครั้ง

}
} {error && {error}}
diff --git a/components/commission/payment-form.tsx b/components/commission/payment-form.tsx index 8cd0609..87eb36c 100644 --- a/components/commission/payment-form.tsx +++ b/components/commission/payment-form.tsx @@ -53,7 +53,9 @@ export function CommissionPaymentForm({ checkoutId, amountBaht, qr, expiresAt, s {/* Generated locally from the fixed checkout amount and configured recipient. */} {/* eslint-disable-next-line @next/next/no-img-element */} {`PromptPay + ฿{amountBaht} +

ใช้มือถือเครื่องนี้ชำระเงิน: บันทึก QR แล้วเลือกรูปในแอปธนาคาร

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

เวลาชำระเงินที่เหลือ {String(Math.floor(secondsLeft / 60)).padStart(2, "0")}:{String(secondsLeft % 60).padStart(2, "0")}

@@ -61,6 +63,6 @@ export function CommissionPaymentForm({ checkoutId, amountBaht, qr, expiresAt, s อัปโหลดสลิปที่ชำระเงินแล้ว - + {error && {error}}; } diff --git a/components/commission/request-form.tsx b/components/commission/request-form.tsx index 19a9ddc..ccce414 100644 --- a/components/commission/request-form.tsx +++ b/components/commission/request-form.tsx @@ -171,11 +171,11 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u }} triggerProps={{ size: "sm", className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden" }} />

{character?.name ?? "เลือกตัวละคร"}

- {errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && {errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" · ")}} + {errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && {errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" - ")}}
; })}
- {issues.some((issue) => issue.path === `teams.${teamIndex}`) && {issues.filter((issue) => issue.path === `teams.${teamIndex}`).map((issue) => issue.message).join(" · ")}} + {issues.some((issue) => issue.path === `teams.${teamIndex}`) && {issues.filter((issue) => issue.path === `teams.${teamIndex}`).map((issue) => issue.message).join(" - ")}} )} @@ -234,7 +234,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u {catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}

- {errorsAt(`weapons.${index}`).length > 0 && {errorsAt(`weapons.${index}`).join(" · ")}} + {errorsAt(`weapons.${index}`).length > 0 && {errorsAt(`weapons.${index}`).join(" - ")}} @@ -263,21 +263,22 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u {catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}

- {errorsAt(`constellations.${index}`).length > 0 && {errorsAt(`constellations.${index}`).join(" · ")}} + {errorsAt(`constellations.${index}`).length > 0 && {errorsAt(`constellations.${index}`).join(" - ")}} )} สรุปคำขอ - {request.teams.length} ทีม · {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ · {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว + {request.teams.length} ทีม - {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ - {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว {error && {error}}
รวม ฿{commissionPrice(request)} +

{request.teams.length} ทีม - {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ - {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว

{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 &&

รวมค่าบริการพื้นฐาน ฿100

}
- +
; diff --git a/components/commission/ticket-chat.tsx b/components/commission/ticket-chat.tsx index e38acad..cad7706 100644 --- a/components/commission/ticket-chat.tsx +++ b/components/commission/ticket-chat.tsx @@ -645,10 +645,11 @@ export function CommissionTicketChat({ แชต{admin ? "กับลูกค้า" : "กับทีมงาน"} + {!admin && สถานะนี้บอกว่าส่งข้อความได้หรือไม่ สอบถามความคืบหน้างานได้ในแชต} - {status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} + {admin ? (status === "open" ? "เปิดอยู่" : "ปิดแล้ว") : (status === "open" ? "แชตเปิดอยู่" : "แชตปิดแล้ว")} {admin && (status === "open" ? ( @@ -1080,7 +1081,7 @@ export function CommissionTicketChat({ ))} -

{images.length}/10 รูป · สูงสุด 10 MiB ต่อรูป

+

{images.length}/10 รูป - สูงสุด 10 MiB ต่อรูป

)} diff --git a/components/public/artifact-image-export.tsx b/components/public/artifact-image-export.tsx index 4af2ab8..688f41d 100644 --- a/components/public/artifact-image-export.tsx +++ b/components/public/artifact-image-export.tsx @@ -148,9 +148,9 @@ export function ArtifactImageExport({ return ( - }> + }> - ส่งออกภาพ + บันทึกภาพ diff --git a/components/public/comparison-context.test.tsx b/components/public/comparison-context.test.tsx new file mode 100644 index 0000000..fdf78b4 --- /dev/null +++ b/components/public/comparison-context.test.tsx @@ -0,0 +1,35 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ComparisonContext, ComparisonValue } from "./comparison-context"; + +describe("public comparison explanations", () => { + it("keeps each damage metric's 100% rows separate", () => { + const rows = [ + { weapon: { name: "Team reference" }, refinement: "5", overallPercent: "100", personalPercent: "110" }, + { weapon: { name: "Personal reference" }, refinement: "1", overallPercent: "105", personalPercent: "100" }, + ]; + const team = renderToStaticMarkup(); + const personal = renderToStaticMarkup(); + expect(team).toContain("Team reference R5"); + expect(team).not.toContain("Personal reference"); + expect(personal).toContain("Personal reference R1"); + expect(personal).not.toContain("Team reference"); + }); + + it("does not invent a reference when no row is 100%", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("ดูหมายเหตุ"); + expect(html).not.toContain("Only choice"); + }); + + it("renders a loss relative to the baseline but never treats an unavailable value as a loss", () => { + expect(renderToStaticMarkup()).toContain("-1.5%"); + for (const value of [null, "0", "invalid"]) { + const html = renderToStaticMarkup(); + expect(html).toContain("ไม่มีข้อมูลเปรียบเทียบ"); + expect(html).not.toContain("-100%"); + } + }); +}); diff --git a/components/public/comparison-context.tsx b/components/public/comparison-context.tsx new file mode 100644 index 0000000..e739955 --- /dev/null +++ b/components/public/comparison-context.tsx @@ -0,0 +1,23 @@ +import { comparisonDelta, comparisonReferenceNames } from "@/lib/guides/comparison"; + +type WeaponRows = Parameters[0]; + +export function ComparisonContext({ rows, metric }: { + rows?: WeaponRows; + metric?: "overallPercent" | "personalPercent"; +}) { + const references = rows && metric ? comparisonReferenceNames(rows, metric) : []; + return
+ {!rows &&

100% = ค่าฐานของเงื่อนไขนี้ เช่น 109% หมายถึงสูงกว่าค่าฐาน 9% เปรียบเทียบภายในเงื่อนไขเดียวกัน

} + {rows && metric ?

+ {metric === "overallPercent" ? "ดาเมจทั้งทีม" : "ดาเมจส่วนตัว"} - {references.length + ? `รายการที่แสดง 100%: ${references.join(" / ")}` + : "ดูหมายเหตุของเงื่อนไขสำหรับรายการที่ใช้เป็นค่าฐาน"} +

: null} +
; +} + +export function ComparisonValue({ value }: { value: string | null }) { + const delta = comparisonDelta(value); + return

{delta ?? "ไม่มีข้อมูลเปรียบเทียบ"}

; +} diff --git a/components/public/condition-switcher.tsx b/components/public/condition-switcher.tsx index 0ba773e..0400a40 100644 --- a/components/public/condition-switcher.tsx +++ b/components/public/condition-switcher.tsx @@ -15,7 +15,7 @@ export function ConditionTabIcon({ iconUrl }: { iconUrl?: string | null }) { export function ConditionSwitcher({ children, - label = "เลือก Condition", + label = "เลือกเงื่อนไข", labels, iconUrls, }: { @@ -36,9 +36,9 @@ export function ConditionSwitcher({ {items.map((_, index) => ( {index > 0 ? ))} diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx index 0a0613c..cf47437 100644 --- a/components/public/guide-image-export.tsx +++ b/components/public/guide-image-export.tsx @@ -171,7 +171,7 @@ export function GuideImageExport({ disabled = false, allowWithoutNotes = true, captureClassName, - buttonLabel = "ส่งออกภาพ", + buttonLabel = "บันทึกภาพ", mobileIconOnly = false, language: controlledLanguage, showLanguageSelector = true, @@ -229,7 +229,7 @@ export function GuideImageExport({ type="button" variant="outline" size="sm" - className={mobileIconOnly ? "max-sm:size-7 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : undefined} + className={mobileIconOnly ? "max-sm:size-11 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : "max-sm:min-h-11"} disabled={capturing || disabled} onClick={() => { if (modeSelection === "direct") setCaptureMode("with-notes"); @@ -242,7 +242,7 @@ export function GuideImageExport({ )} - {capturing ? "กำลังส่งออก…" : label} + {capturing ? "กำลังบันทึก…" : label} ); @@ -299,9 +299,9 @@ export function GuideImageExport({ ); const exportAction = ( - ); @@ -465,8 +465,8 @@ export function GuideImageExport({ )} > - ตั้งค่าการส่งออกภาพ - เลือกตัวเลือกที่ต้องการก่อนส่งออกภาพ + ตั้งค่าภาพที่จะบันทึก + เลือกตัวเลือกที่ต้องการก่อนบันทึกภาพ {previewPosition === "right" ? (
diff --git a/components/public/public-account-menu.tsx b/components/public/public-account-menu.tsx new file mode 100644 index 0000000..eb458ac --- /dev/null +++ b/components/public/public-account-menu.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ChevronDownIcon, SettingsIcon, UserRoundIcon, MessageCircle } from "lucide-react"; +import { authClient } from "@/lib/auth/client"; +import { safeAuthReturnPath } from "@/lib/auth/return-path"; +import { SignOutButton } from "@/components/auth/sign-out-button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuGroup, + DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator" + +export function PublicAccountMenu() { + const pathname = usePathname(); + const { data, isPending } = authClient.useSession(); + const [openedPath, setOpenedPath] = useState(null); + const returnPath = safeAuthReturnPath(openedPath ?? pathname); + const suffix = returnPath === "/" ? "" : `?next=${encodeURIComponent(returnPath)}`; + const accountLabel = data?.user ? data.user.name.trim() || "โปรไฟล์" : "บัญชี"; + + return { + if (open) setOpenedPath(window.location.pathname + window.location.search + window.location.hash); + }}> + }> + + {accountLabel} + + + + {data?.user ? <> + }> + }> + + + : <> + }>เข้าสู่ระบบ + }>สมัครสมาชิก + } + + + ; +} diff --git a/components/public/public-navigation.tsx b/components/public/public-navigation.tsx index f91417b..97a855d 100644 --- a/components/public/public-navigation.tsx +++ b/components/public/public-navigation.tsx @@ -36,7 +36,7 @@ export function PublicNavigation() {
- }> + }> diff --git a/components/public/rotation-help.tsx b/components/public/rotation-help.tsx index f9df22d..2f66bfe 100644 --- a/components/public/rotation-help.tsx +++ b/components/public/rotation-help.tsx @@ -11,7 +11,8 @@ export function RotationHelp() {
E / Q
สกิลธาตุ / ท่าไม้ตาย
N / C / P
โจมตีปกติ / ชาร์จโจมตี / โจมตีพุ่งลง
hP / lP
โจมตีพุ่งลงจากที่สูง (High Plunge) / โจมตีพุ่งลงจากที่ต่ำ (Low Plunge)
-
D / J
แดช / กระโดดเพื่อยกเลิกแอนิเมชั่น
+
D / J / W
แดช / กระโดดเพื่อยกเลิกแอนิเมชั่น / เดินเพื่อยกเลิกแอนิเมชั่น
+
s
สกิลพิเศษ (เช่น sE สกิลธาตุพิเศษ sQ อันติพิเศษ)
hE / tE
กดสกิลธาตุค้าง / แตะสกิลธาตุ
C0 / R1
กลุ่มดาวระดับ 0 / อาวุธขัดเกลาระดับ 1
diff --git a/components/public/scrollable-nav.tsx b/components/public/scrollable-nav.tsx index 7fd7644..fc2e965 100644 --- a/components/public/scrollable-nav.tsx +++ b/components/public/scrollable-nav.tsx @@ -62,7 +62,7 @@ export function ScrollableNav({ type="button" variant="ghost" size="icon-sm" - className="shrink-0 sm:hidden" + className="size-11 shrink-0 sm:hidden" disabled={!canScrollLeft} onClick={() => scroll(-1)} aria-label="เลื่อนเมนูไปทางซ้าย" @@ -80,7 +80,7 @@ export function ScrollableNav({ type="button" variant="ghost" size="icon-sm" - className="shrink-0 sm:hidden" + className="size-11 shrink-0 sm:hidden" disabled={!canScrollRight} onClick={() => scroll(1)} aria-label="เลื่อนเมนูไปทางขวา" diff --git a/components/public/site-footer.tsx b/components/public/site-footer.tsx index c7f9b36..c4fefc9 100644 --- a/components/public/site-footer.tsx +++ b/components/public/site-footer.tsx @@ -9,7 +9,7 @@ export function SiteFooter() { href="/thanks" className="text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > - Special Thanks + ขอบคุณผู้สนับสนุน - Donate + สนับสนุนเว็บไซต์

diff --git a/components/public/site-header.tsx b/components/public/site-header.tsx index 1ce8577..2bfcb97 100644 --- a/components/public/site-header.tsx +++ b/components/public/site-header.tsx @@ -6,7 +6,7 @@ import { PencilIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { VisitorPresence } from "@/components/public/visitor-presence"; import { PublicNavigation } from "@/components/public/public-navigation"; -import { SignOutButton } from "@/components/auth/sign-out-button"; +import { PublicAccountMenu } from "@/components/public/public-account-menu"; import { getAdminSession } from "@/lib/auth/server"; import { HttpError } from "@/lib/security/http"; @@ -45,7 +45,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string - +

); diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx index b9b9e3e..c243ffd 100644 --- a/components/public/structured-directory.tsx +++ b/components/public/structured-directory.tsx @@ -170,19 +170,19 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
- + setParam("q", event.target.value)} - placeholder="ค้นหาไกด์…" + placeholder="ค้นหาชื่อตัวละคร…" aria-label="ค้นหาไกด์" /> {teamSearchTargetLabels[searchTarget]} @@ -504,8 +506,8 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { if (order) setDpsOrder(order); }} > - DPS สูงสุด - DPS ต่ำสุด + DPS สูงสุด + DPS ต่ำสุด
+

DPS คือดาเมจรวมของทีมต่อวินาทีในโรเทชั่นที่ระบุ ผลจริงขึ้นอยู่กับอุปกรณ์และการเล่น

{filteredTeams.length ? (
@@ -545,7 +548,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { ) : ( - ไม่พบทีมที่ตรงกับคำค้นหา +
+

{query.trim() ? "ไม่พบทีมที่ตรงกับคำค้นหาในเงื่อนไขนี้" : "ยังไม่มีทีมในเงื่อนไขนี้"}

+ {query && } +
)} diff --git a/components/public/term-detail.tsx b/components/public/term-detail.tsx index df8f687..cec06d7 100644 --- a/components/public/term-detail.tsx +++ b/components/public/term-detail.tsx @@ -43,15 +43,17 @@ export function TermDetail({ title, detail, showIcon = true, + interactive = false, }: { label: ReactNode; title: string; detail: string; showIcon?: boolean; + interactive?: boolean; }) { const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false); - if (finePointer) { + if (finePointer && !interactive) { return ( diff --git a/components/public/weapon-chart.tsx b/components/public/weapon-chart.tsx index 84d360b..88f58c7 100644 --- a/components/public/weapon-chart.tsx +++ b/components/public/weapon-chart.tsx @@ -1,36 +1,27 @@ "use client"; -import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; - -import { Badge } from "@/components/ui/badge"; +import { Bar, BarChart, CartesianGrid, LabelList, ReferenceLine, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, + ChartContainer, ChartLegend, ChartLegendContent, + ChartTooltip, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart"; import type { PublicGuide } from "@/lib/guides/queries"; const chartConfig = { - overall: { label: "Overall - ดาเมจทั้งทีม", color: "var(--chart-1)" }, - personal: { label: "Personal - ดาเมจส่วนตัว", color: "var(--chart-2)" }, + overall: { label: "ดาเมจทั้งทีม", color: "var(--chart-1)" }, + personal: { label: "ดาเมจส่วนตัว", color: "var(--chart-2)" }, } satisfies ChartConfig; function percentage(value: string | null) { if (!value?.trim()) return null; const number = Number(value); - // Zero is displayed as an unavailable metric in the weapon cards. return Number.isFinite(number) && number !== 0 ? number : null; } export function WeaponChart({ rows }: { rows: PublicGuide["weaponGroups"][number]["rows"] }) { const data = rows.map((row, index) => ({ name: `${index + 1}. ${row.weapon.name} R${row.refinement}`, - imageUrl: row.weapon.imageUrl, - refinement: row.refinement, overall: percentage(row.overallPercent), personal: percentage(row.personalPercent), })); @@ -38,73 +29,42 @@ export function WeaponChart({ rows }: { rows: PublicGuide["weaponGroups"][number const hasPersonal = data.some((row) => row.personal !== null); const singleMetric = !hasOverall || !hasPersonal; - if (!hasOverall && !hasPersonal) return null; + if (!hasOverall && !hasPersonal) return

ไม่มีข้อมูลเปรียบเทียบสำหรับกราฟนี้

; - return ( - - - เปรียบเทียบดาเมจอาวุธ - เปอร์เซ็นต์ดาเมจของอาวุธทั้งหมดใน Condition นี้ - - - - - - `${value}%`} /> - { - const weapon = data.find((row) => row.name === payload.value); - if (!weapon) return ; - const size = Math.max(8, Math.min(40, Number(width) / data.length - 4 || 32)); - return ( - - {weapon.name} - - -
- R{weapon.refinement} -
-
-
- ); - }} - /> - ( -
- {chartConfig[name as keyof typeof chartConfig]?.label ?? name} - {value}% -
- )} /> - } /> - } /> - {hasPersonal ? : null} - {hasOverall ? : null} -
-
-
-
- ); + return + + เปรียบเทียบดาเมจอาวุธ + เปอร์เซ็นต์ดาเมจในเงื่อนไขนี้ - เส้น 100% คือค่าฐาน + + + + + + Math.min(0, minimum), (maximum: number) => Math.max(110, maximum)]} + tickFormatter={(value) => `${value}%`} /> + + +
{payload.value}
+
+
} /> + +
+ {chartConfig[name as keyof typeof chartConfig]?.label ?? name} + {value}% +
} />} /> + } /> + {hasPersonal && + typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" /> + } + {hasOverall && + typeof value === "number" ? `${value.toLocaleString("th-TH", { maximumFractionDigits: 2 })}%` : ""} className="fill-foreground text-xs" /> + } +
+
+
+
; } diff --git a/components/public/weapon-display-mode.tsx b/components/public/weapon-display-mode.tsx index fb39ae7..0659aaf 100644 --- a/components/public/weapon-display-mode.tsx +++ b/components/public/weapon-display-mode.tsx @@ -46,15 +46,15 @@ export function WeaponDisplayMode({ return (
{ if (values[0]) setMode(values[0]); }} > - List - Chart + รายการ + กราฟ {mode === "chart" ? : children}
diff --git a/components/public/weapon-image-export.tsx b/components/public/weapon-image-export.tsx index b9bd909..a30a05c 100644 --- a/components/public/weapon-image-export.tsx +++ b/components/public/weapon-image-export.tsx @@ -145,8 +145,8 @@ export function WeaponImageExport({ const conditionLabel = group.name; const baseName = `${guide.name}-Weapon-${conditionLabel || `Condition ${conditionIndex + 1}`}`; const metricOptions = ([ - ["overallPercent", "Overall Damage"], - ["personalPercent", "Personal Damage"], + ["overallPercent", "ดาเมจทั้งทีม"], + ["personalPercent", "ดาเมจส่วนตัว"], ] as const).filter(([metric]) => group.rows.some((row) => row[metric] !== null), ); @@ -177,9 +177,9 @@ export function WeaponImageExport({ return ( - }> + }> - ส่งออกภาพ + บันทึกภาพ diff --git a/components/stygian/stygian-boss-display.tsx b/components/stygian/stygian-boss-display.tsx index 80da6da..9623ab1 100644 --- a/components/stygian/stygian-boss-display.tsx +++ b/components/stygian/stygian-boss-display.tsx @@ -130,7 +130,7 @@ export function StygianBossCard({

{boss.name}

- เลเวล {level} · HP {Math.round(boss.hp).toLocaleString("en-US")} + เลเวล {level} - HP {Math.round(boss.hp).toLocaleString("en-US")}

diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx new file mode 100644 index 0000000..446598b --- /dev/null +++ b/components/ui/input-otp.tsx @@ -0,0 +1,86 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { OTPInput, OTPInputContext } from "input-otp" +import { MinusIcon } from "lucide-react" + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string +}) { + return ( + + ) +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number +}) { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ) +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/lib/guides/image-export.test.tsx b/lib/guides/image-export.test.tsx index 9299cf2..bcbdcf0 100644 --- a/lib/guides/image-export.test.tsx +++ b/lib/guides/image-export.test.tsx @@ -80,13 +80,13 @@ describe("image export controls", () => { it("shows one export trigger and keeps settings in its dialog", () => { const html = renderToStaticMarkup(No notes); - expect(html).toContain("ส่งออกภาพ"); + expect(html).toContain("บันทึกภาพ"); expect(html.match(/ { const html = renderToStaticMarkup(Content); - expect(html).toContain("ส่งออกภาพ"); + expect(html).toContain("บันทึกภาพ"); expect(html.match(/ { Poster , ); - expect(html).toContain("ส่งออกภาพ"); - expect(html).not.toContain("ตั้งค่าการส่งออกภาพ"); + expect(html).toContain("บันทึกภาพ"); + expect(html).not.toContain("ตั้งค่าภาพที่จะบันทึก"); expect(html.match(/ { expect(html).toContain("h-[clamp(8rem,50svh,32rem)]"); expect(html).toContain('data-slot="dialog-footer"'); expect(html).toContain("sticky bottom-0"); - expect(html.match(/>ส่งออกภาพ<\/button>/gu)).toHaveLength(1); + expect(html.match(/>บันทึกภาพ<\/button>/gu)).toHaveLength(1); }); it("hides the persistent language chooser when language is prompted on export", () => { diff --git a/package.json b/package.json index 66b7ec8..0d73c2f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "emoji-picker-react": "^4.22.3", "emoji-regex": "^11.0.0", "entities": "7.0.1", + "input-otp": "^1.5.0", "ioredis": "^6.0.0", "lucide-react": "^1.35.0", "modern-screenshot": "^4.7.0", diff --git a/tests/security-boundaries.test.ts b/tests/security-boundaries.test.ts index cbe1106..730ad75 100644 --- a/tests/security-boundaries.test.ts +++ b/tests/security-boundaries.test.ts @@ -62,7 +62,7 @@ describe("administrative security boundaries", () => { ); expect(source).toContain('"x-captcha-response": token'); expect(source).toContain("disabled={busy || (captcha && !token && !pendingEmail)}"); - expect(source).toContain('"expired-callback": () => setToken("")'); + expect(source).toMatch(/"expired-callback":\s*\(\)\s*=>\s*\{[^}]*setToken\(""\)/u); expect(source).toContain("window.turnstile?.reset(widget.current)"); }); });