diff --git a/app/commission/page.tsx b/app/commission/page.tsx index fdb6040..30e58bd 100644 --- a/app/commission/page.tsx +++ b/app/commission/page.tsx @@ -4,7 +4,6 @@ import { connection } from "next/server"; import { SiteHeader } from "@/components/public/site-header"; import { CommissionRequestForm } from "@/components/commission/request-form"; import { CommissionIntro } from "@/components/commission/commission-intro"; -import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt"; import { Button } from "@/components/ui/button"; import { Card, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -35,9 +34,8 @@ async function CommissionContent() { const catalog = await getCatalogOptions(); if (!catalog) return

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

; return
-
- +
; } diff --git a/app/commission/tickets/page.tsx b/app/commission/tickets/page.tsx index db477c0..8b11369 100644 --- a/app/commission/tickets/page.tsx +++ b/app/commission/tickets/page.tsx @@ -5,7 +5,6 @@ import { connection } from "next/server"; import { getDb } from "@/db"; import { commissionCheckouts, commissionTickets } from "@/db/schema"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; -import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt"; import { SiteHeader } from "@/components/public/site-header"; import { Badge } from "@/components/ui/badge"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -29,7 +28,6 @@ export default async function CommissionTicketsPage() { .orderBy(desc(commissionCheckouts.createdAt)).limit(20), ]); return
-

Ticket ของฉัน

diff --git a/app/layout.tsx b/app/layout.tsx index b39c0d9..7d43870 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,6 @@ 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"; @@ -49,7 +48,6 @@ export default function RootLayout({ children }: LayoutProps<"/">) { {children} - โปรไฟล์ของฉัน {setup === "1" &&

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

}
diff --git a/app/stygian/page.tsx b/app/stygian/page.tsx index a2e89d7..23bbad2 100644 --- a/app/stygian/page.tsx +++ b/app/stygian/page.tsx @@ -6,7 +6,7 @@ import { SiteHeader } from "@/components/public/site-header"; import { StygianGuide } from "@/components/public/stygian-guide"; import { LiveRefresh } from "@/components/live-refresh"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; -import { Button } from "@/components/ui/button"; +import { buttonVariants } from "@/components/ui/button"; import { getCachedStygianSchedule, getCachedStygianSchedules } from "@/lib/cache/stygian"; import { getActiveCatalog } from "@/lib/guides/queries"; @@ -27,13 +27,13 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom

Stygian Onslaught

-

Stygian Guide

+

ไกด์ Stygian

- {schedules.length ? : null} + {schedules.length ? : null} {schedule && catalog ? : ( - No Stygian versions yetAn administrator must sync the Lunaris archive first. + ยังไม่มีไกด์ Stygianกำลังเตรียมข้อมูล กลับมาอ่านได้ภายหลัง หรือเลือกไกด์ตัวละครระหว่างรอเลือกไกด์ตัวละคร )}
diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 0c317d8..988ec69 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -10,7 +10,6 @@ import { BookOpenIcon, ClipboardListIcon, MessagesSquareIcon, - PlusIcon, SwordsIcon, } from "lucide-react"; @@ -22,7 +21,7 @@ export function AdminHeader() { const pathname = usePathname(); return (<> - + {pathname.startsWith("/admin/commission") && }
ไกด์เกนชินไม่ใช่เกมมือถือ -
@@ -135,7 +135,7 @@ export function AutosaveStatus({ return ( - Saved + บันทึกแล้ว ); } diff --git a/components/admin/guide-section-nav.tsx b/components/admin/guide-section-nav.tsx index a177ddd..7013440 100644 --- a/components/admin/guide-section-nav.tsx +++ b/components/admin/guide-section-nav.tsx @@ -10,10 +10,10 @@ import { navigationIconPath } from "@/lib/navigation"; import { cn } from "@/lib/utils"; const sections = [ - { slug: "weapon", label: "Weapons" }, - { slug: "artifact", label: "Artifacts & Stats" }, - { slug: "constellations", label: "Constellations" }, - { slug: "team", label: "Team Calc" }, + { slug: "weapon", label: "อาวุธ" }, + { slug: "artifact", label: "อาร์ติแฟกต์และค่าสถานะ" }, + { slug: "constellations", label: "กลุ่มดาว" }, + { slug: "team", label: "คำนวณทีม" }, ] as const; export function GuideSectionNav({ @@ -37,19 +37,21 @@ export function GuideSectionNav({ return ( - Overview + ภาพรวม {sections.map((section) => { const iconPath = navigationIconPath(section.slug); @@ -59,6 +61,7 @@ export function GuideSectionNav({ href={`/admin/${characterKey}/${section.slug}`} prefetch={false} onClick={confirmNavigation} + aria-current={current === section.slug ? "page" : undefined} className={cn( "flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", current === section.slug ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground", @@ -75,6 +78,7 @@ export function GuideSectionNav({ href={`/admin/${characterKey}/${extra.slug}`} prefetch={false} onClick={confirmNavigation} + aria-current={current === extra.slug ? "page" : undefined} className={cn( "flex min-h-8 shrink-0 items-center gap-2 rounded-md px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", current === extra.slug ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground", diff --git a/components/admin/guide-visibility-control.tsx b/components/admin/guide-visibility-control.tsx index 3c53daf..e26ad95 100644 --- a/components/admin/guide-visibility-control.tsx +++ b/components/admin/guide-visibility-control.tsx @@ -52,7 +52,7 @@ export function GuideVisibilityControl({ toast.error(message); return; } - toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว"); + toast.success(nextPublic ? "เผยแพร่ไกด์แล้ว" : "ซ่อนไกด์จากเว็บไซต์แล้ว"); setOpen(false); router.refresh(); }); @@ -70,13 +70,13 @@ export function GuideVisibilityControl({ onClick={() => setOpen(true)} > {pending ? : isPublic ? : } - {isPublic ? "Make private" : "Make Publish"} + {isPublic ? "ซ่อนจากเว็บไซต์" : "เผยแพร่ไกด์"} - {isPublic ? `เปลี่ยน ${guideName} เป็น Private?` : `เผยแพร่ ${guideName} เป็น Public?`} + {isPublic ? `ซ่อน ${guideName} จากเว็บไซต์?` : `เผยแพร่ ${guideName} ให้ทุกคนดู?`} {isPublic @@ -101,7 +101,7 @@ export function GuideVisibilityControl({ }} > {pending && } - {isPublic ? "ยืนยัน Private" : "ยืนยัน Public"} + {isPublic ? "ยืนยันการซ่อน" : "ยืนยันการเผยแพร่"} diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx index e991526..15d3d3c 100644 --- a/components/auth/account-form.tsx +++ b/components/auth/account-form.tsx @@ -4,13 +4,11 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, type FormEvent } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth/client"; -import { ProfileImageInput } from "./profile-image-input"; declare global { interface Window { @@ -44,20 +42,21 @@ export function AccountForm({ const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [pendingEmail, setPendingEmail] = useState(""); - const [pendingName, setPendingName] = useState(""); const [notice, setNotice] = useState(""); const [token, setToken] = useState(""); - const [profileImage, setProfileImage] = useState(null); - const [profilePreview, setProfilePreview] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [resendSeconds, setResendSeconds] = useState(0); const formRef = useRef(null); const container = useRef(null); const widget = useRef(null); const captcha = process.env.NODE_ENV !== "development"; - useEffect(() => () => { - if (profilePreview) URL.revokeObjectURL(profilePreview); - }, [profilePreview]); useEffect(() => { - if (!captcha) return; + if (!resendSeconds) return; + const timer = window.setTimeout(() => setResendSeconds((seconds) => seconds - 1), 1000); + return () => window.clearTimeout(timer); + }, [resendSeconds]); + useEffect(() => { + if (!captcha || pendingEmail) return; const script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; @@ -75,10 +74,10 @@ export function AccountForm({ return () => { script.remove(); }; - }, [captcha, siteKey]); + }, [captcha, siteKey, pendingEmail]); async function sendRegistrationOtp() { - if (!register || busy) return; + if (!register || busy || (pendingEmail && resendSeconds > 0)) return; if (pendingEmail) { setBusy(true); setError(""); @@ -89,6 +88,7 @@ export function AccountForm({ }); if (result.error) throw new Error("ส่งรหัสใหม่ไม่สำเร็จ กรุณาลองอีกครั้ง"); setNotice("ส่งรหัสใหม่แล้ว กรุณาตรวจสอบอีเมลของคุณ"); + setResendSeconds(60); } catch (cause) { setError(cause instanceof Error ? cause.message : "ส่งรหัสใหม่ไม่สำเร็จ"); } finally { setBusy(false); } @@ -111,7 +111,7 @@ export function AccountForm({ }); if (result.error) throw new Error(result.error.message ?? "ส่งรหัสไม่สำเร็จ กรุณาลองอีกครั้ง"); setPendingEmail(email); - setPendingName(name); + setResendSeconds(60); setNotice("ส่งรหัส 6 หลักแล้ว กรุณาตรวจสอบอีเมลและกรอกรหัสภายใน 5 นาที"); } catch (cause) { setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง"); @@ -135,18 +135,7 @@ export function AccountForm({ email: pendingEmail, otp: String(form.get("otp") ?? "").trim(), }); if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง"); - if (profileImage) { - const profile = new FormData(); - profile.set("name", pendingName); - profile.set("image", profileImage); - const response = await fetch("/api/profile", { method: "POST", body: profile }); - if (!response.ok) { - router.replace(`/profile?setup=1&upload=failed&next=${encodeURIComponent(nextPath)}`); - router.refresh(); - return; - } - } - router.replace(nextPath); + router.replace(`/profile?setup=1&next=${encodeURIComponent(nextPath)}`); router.refresh(); return; } @@ -175,89 +164,57 @@ export function AccountForm({ return ( - {register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"} + {register ? pendingEmail ? "ยืนยันอีเมล" : "สร้างบัญชี Buzz Guide" : "เข้าสู่ระบบ"} + {register && ขั้นตอน {pendingEmail ? "2 จาก 2 · ยืนยันอีเมล" : "1 จาก 2 · ข้อมูลบัญชี"}} {verified && ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ} {verificationError && ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่} - {notice && {notice}} - {register && !pendingEmail &&

- กรอกชื่อ อีเมล และรหัสผ่าน แล้วกดส่ง OTP -

} + {notice && {notice}}
- {register && ( - + {!pendingEmail ? <> + {register && ชื่อที่แสดง - + + } + + อีเมล + - )} - {register && - รูปโปรไฟล์ (ไม่บังคับ) -
- - {profilePreview && } - ? - - { - setProfileImage(cropped); - setProfilePreview(URL.createObjectURL(cropped)); - setError(""); - }} onError={setError} /> -
-
} - - อีเมล -
- - {register && } -
-
- - รหัสผ่าน - - - {register && - รหัส OTP - - } - {captcha && !pendingEmail &&
} - +
+ + {register && ใช้รหัสผ่านอย่างน้อย 6 ตัวอักษร} +
+ {captcha &&
} + : <> +

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

+ + รหัสยืนยัน 6 หลัก + + รหัสมีอายุ 5 นาที หากไม่พบอีเมล ลองดูในโฟลเดอร์สแปม + + + } + {error && {error}} + @@ -274,11 +231,6 @@ export function AccountForm({ ลืมรหัสผ่าน?
} - {error && ( - - {error} - - )}
); diff --git a/components/auth/profile-form.tsx b/components/auth/profile-form.tsx index 79a1b86..c21c8bd 100644 --- a/components/auth/profile-form.tsx +++ b/components/auth/profile-form.tsx @@ -1,9 +1,10 @@ "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 { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, @@ -128,6 +129,7 @@ export function ProfileForm({ > {busy ? "กำลังบันทึก..." : "บันทึกโปรไฟล์"} + {nextPath && ข้ามและเริ่มใช้งาน} ); diff --git a/components/commission/commission-intro.tsx b/components/commission/commission-intro.tsx index df9e88c..660b844 100644 --- a/components/commission/commission-intro.tsx +++ b/components/commission/commission-intro.tsx @@ -54,6 +54,7 @@ export function CommissionIntro() {

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

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

+

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

{steps.map((step) => diff --git a/components/commission/request-form.tsx b/components/commission/request-form.tsx index c9ae40c..19a9ddc 100644 --- a/components/commission/request-form.tsx +++ b/components/commission/request-form.tsx @@ -1,18 +1,19 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Image from "@/components/ui/resilient-image"; import { UserRoundIcon, XIcon } from "lucide-react"; -import { toast } from "sonner"; import { ArtifactSelect, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select"; import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; -import { FieldGroup } from "@/components/ui/field"; +import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { commissionPrice, commissionRequestSchema, type CommissionRequest } from "@/lib/commission/request"; +import { commissionPrice, type CommissionRequest } from "@/lib/commission/request"; +import { commissionDraftKey, restoreCommissionDraft, serializeCommissionDraft } from "@/lib/commission/draft"; +import { commissionRequestIssues } from "@/lib/commission/request-validation"; import { elementBackgroundColor } from "@/lib/catalog/element"; type Item = { key: string; name: string; aliases: string[]; imageUrl: string; rarity?: number | null; @@ -23,23 +24,6 @@ const blankMember = (): CommissionRequest["teams"][number]["members"][number] => }); const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) }); -function requestError(request: CommissionRequest): string | null { - const parsed = commissionRequestSchema.safeParse(request); - if (parsed.success) return null; - const path = parsed.error.issues[0]?.path; - if (path?.[0] === "teams") { - const teamNumber = Number(path[1]) + 1; - const memberNumber = typeof path[3] === "number" ? path[3] + 1 : null; - if (memberNumber && path[4] === "characterKey") return `ทีม ${teamNumber}: เลือกตัวละครที่ ${memberNumber}`; - if (memberNumber && path[4] === "weaponKey") return `ทีม ${teamNumber}: เลือกอาวุธให้ตัวละครที่ ${memberNumber}`; - if (memberNumber && path[4] === "artifactKey") return `ทีม ${teamNumber}: เลือกอาร์ติแฟกต์ให้ตัวละครที่ ${memberNumber}`; - return `ทีม ${teamNumber}: เลือกตัวละคร 4 ตัวที่ไม่ซ้ำกัน พร้อมอาวุธและอาร์ติแฟกต์`; - } - if (path?.[0] === "weapons") return `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`; - if (path?.[0] === "constellations") return `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`; - return "เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ"; -} - function checkoutError(code: string | undefined, status: number): string { if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง"; if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง"; @@ -52,9 +36,47 @@ function checkoutError(code: string | undefined, status: number): string { return "สร้างคำขอไม่สำเร็จ กรุณาลองอีกครั้ง"; } -export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { +export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; userId: string }) { const router = useRouter(); const [request, setRequest] = useState({ teams: [blankTeam()], weapons: [], constellations: [] }); + const root = useRef(null); + const restoredKey = useRef(null); + const submitted = useRef(false); + const submitting = useRef(false); + const [hydrated, setHydrated] = useState(false); + const [draftNotice, setDraftNotice] = useState(""); + const [attempted, setAttempted] = useState(false); + const storageKey = commissionDraftKey(userId); + const issues = attempted ? commissionRequestIssues(request) : []; + const invalid = (path: string) => issues.some((issue) => issue.path === path || path.startsWith(`${issue.path}.`)); + const errorsAt = (path: string) => [...new Set(issues.filter((issue) => issue.path === path || issue.path.startsWith(`${path}.`)).map((issue) => issue.message))]; + + useEffect(() => { + if (restoredKey.current === storageKey) return; + let restored: CommissionRequest | null = null; + let notice = "แบบร่างจะบันทึกในอุปกรณ์นี้อัตโนมัติ"; + try { + restored = restoreCommissionDraft(localStorage.getItem(storageKey), catalog); + if (restored) notice = "กู้คืนแบบร่างแล้ว กรุณาตรวจสอบรายการก่อนชำระเงิน"; + } catch { notice = "เบราว์เซอร์ไม่อนุญาตให้บันทึกแบบร่าง กรุณาเปิดหน้านี้ไว้จนสร้างคำขอสำเร็จ"; } + const frame = requestAnimationFrame(() => { + if (restored) setRequest(restored); + restoredKey.current = storageKey; + setDraftNotice(notice); + setHydrated(true); + }); + return () => cancelAnimationFrame(frame); + }, [storageKey, catalog]); + + useEffect(() => { + if (!hydrated || submitted.current) return; + try { localStorage.setItem(storageKey, serializeCommissionDraft(request)); } + catch { + const frame = requestAnimationFrame(() => setDraftNotice("บันทึกแบบร่างไม่สำเร็จ กรุณาเปิดหน้านี้ไว้จนสร้างคำขอสำเร็จ")); + return () => cancelAnimationFrame(frame); + } + }, [request, hydrated, storageKey]); + const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const patch = (next: Partial) => setRequest((old) => ({ ...old, ...next })); @@ -64,8 +86,19 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { }; async function submit() { - const validationError = requestError(request); - if (validationError) { setError(validationError); toast.error(validationError); return; } + if (!hydrated || submitting.current || submitted.current) return; + setAttempted(true); + const validationIssues = commissionRequestIssues(request); + if (validationIssues.length) { + setError(""); + requestAnimationFrame(() => { + const field = root.current?.querySelector('[aria-invalid="true"], [data-commission-errors]'); + field?.scrollIntoView({ block: "center", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + field?.focus({ preventScroll: true }); + }); + return; + } + submitting.current = true; setBusy(true); setError(""); try { const response = await fetch("/api/commission/checkouts", { method: "POST", @@ -75,16 +108,21 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { throw new Error(checkoutError(body?.error, response.status)); } const { checkoutId } = await response.json(); + submitted.current = true; + try { localStorage.removeItem(storageKey); } catch { /* Navigation remains available when storage is blocked. */ } router.push(`/commission/pay/${checkoutId}`); } catch (cause) { const message = cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ"; setError(message); - toast.error(message); } - finally { setBusy(false); } + finally { submitting.current = false; setBusy(false); } } - return
+ return
+ คำขอคำนวณทีม อาวุธ และกลุ่มดาว +

{draftNotice}

+

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

+ {attempted && issues.some((issue) => !issue.path) && เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ} ทีม - ฿100 ต่อทีมเลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว {request.teams.map((team, teamIndex) => ทีม {teamIndex + 1} @@ -94,21 +132,21 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { return
#{memberIndex + 1}
-
; })}
+ {issues.some((issue) => issue.path === `teams.${teamIndex}`) && {issues.filter((issue) => issue.path === `teams.${teamIndex}`).map((issue) => issue.message).join(" · ")}}
)} @@ -149,7 +189,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) {
!set.weaponKeys.includes(item.key) || item.key === weaponKey)} - value={weaponKey} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`} + value={weaponKey} invalid={invalid(`weapons.${index}.weaponKeys.${weaponIndex}`)} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`} placeholder={`อาวุธชิ้นที่ ${weaponIndex + 1}`} emptyLabel={set.characterKey ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"} onValueChange={(next) => { const weapons = structuredClone(request.weapons); @@ -179,7 +219,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { })}
-
+ {errorsAt(`weapons.${index}`).length > 0 && {errorsAt(`weapons.${index}`).join(" · ")}} @@ -205,14 +246,14 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { {request.constellations.map((set, index) =>
{Array.from({ length: 7 }, (_, level) => )}
-
+ {errorsAt(`constellations.${index}`).length > 0 && {errorsAt(`constellations.${index}`).join(" · ")}}
)}
-
รวม ฿{commissionPrice(request)} - {request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 ? -

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

: null}
- -
- {error && {error}} -
; + + สรุปคำขอ + {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 === 0 && request.weapons.length + request.constellations.length > 0 &&

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

} +
+ +
+
+ ; } diff --git a/components/public/public-navigation.tsx b/components/public/public-navigation.tsx index 62a4d3a..f74cd83 100644 --- a/components/public/public-navigation.tsx +++ b/components/public/public-navigation.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import { BookOpenIcon, MenuIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SiteInstallButton } from "@/components/site-install-button"; import { Drawer, DrawerClose, @@ -18,8 +19,8 @@ import { const destinations = [ { href: "/", label: "หน้ารวม", icon: null }, - { href: "/commission", label: "Commission", icon: "/icon/commission.webp" }, - { href: "/stygian", label: "Stygian Guide", icon: "/icon/stygian.webp" }, + { href: "/commission", label: "ขอคำนวณทีม", icon: "/icon/commission.webp" }, + { href: "/stygian", label: "ไกด์ Stygian", icon: "/icon/stygian.webp" }, ] as const; export function PublicNavigation() { @@ -34,6 +35,7 @@ export function PublicNavigation() { {icon ? : null}{label} )} +
}> diff --git a/components/public/rotation-help.tsx b/components/public/rotation-help.tsx new file mode 100644 index 0000000..6d52af8 --- /dev/null +++ b/components/public/rotation-help.tsx @@ -0,0 +1,22 @@ +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; + +export function RotationHelp() { + return + + อ่านโรเทชั่นและค่า DPS อย่างไร? + +
+

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

+
+
E / Q
สกิลธาตุ / ท่าไม้ตาย
+
N / C / P
โจมตีปกติ / ชาร์จโจมตี / โจมตีพุ่งลง
+
D / J
แดช / กระโดดเพื่อยกเลิกแอนิเมชั่น
+
hE / tE
กดสกิลธาตุค้าง / แตะสกิลธาตุ
+
C0 / R1
กลุ่มดาวระดับ 0 / อาวุธขัดเกลาระดับ 1
+
+

ตัวอย่าง: EQ > N2D = ใช้สกิลธาตุ ตามด้วยท่าไม้ตาย แล้วโจมตีปกติ 2 ครั้งและแดช เครื่องหมาย > คือทำขั้นตอนถัดไป ส่วน 2[N2C] คือทำชุดโจมตีปกติ 2 ครั้ง + ชาร์จโจมตี ซ้ำ 2 รอบ

+
+
+
+
; +} diff --git a/components/public/scrollable-nav.tsx b/components/public/scrollable-nav.tsx index 5751518..7fd7644 100644 --- a/components/public/scrollable-nav.tsx +++ b/components/public/scrollable-nav.tsx @@ -10,10 +10,12 @@ export function ScrollableNav({ ariaLabel, children, className, + activeKey, }: { ariaLabel: string; children: ReactNode; className?: string; + activeKey?: string; }) { const navRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); @@ -29,17 +31,29 @@ export function ScrollableNav({ useEffect(() => { const nav = navRef.current; if (!nav) return; - updateScrollState(); - nav.addEventListener("scroll", updateScrollState, { passive: true }); - window.addEventListener("resize", updateScrollState); - return () => { - nav.removeEventListener("scroll", updateScrollState); - window.removeEventListener("resize", updateScrollState); + const revealActive = () => { + const active = nav.querySelector('[aria-current="page"]'); + if (active) { + nav.scrollLeft = Math.max(0, active.offsetLeft - nav.offsetLeft - (nav.clientWidth - active.offsetWidth) / 2); + } + updateScrollState(); }; - }, [updateScrollState]); + const frame = requestAnimationFrame(revealActive); + const observer = new ResizeObserver(updateScrollState); + observer.observe(nav); + nav.addEventListener("scroll", updateScrollState, { passive: true }); + window.addEventListener("resize", revealActive); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + nav.removeEventListener("scroll", updateScrollState); + window.removeEventListener("resize", revealActive); + }; + }, [activeKey, updateScrollState]); const scroll = (direction: -1 | 1) => { - navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75, behavior: "smooth" }); + navRef.current?.scrollBy({ left: direction * navRef.current.clientWidth * 0.75, + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); }; return ( @@ -58,7 +72,7 @@ export function ScrollableNav({ diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 8a3f99d..1c57249 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -55,7 +55,9 @@ import { import { normalizeTalentPriorities, talentRelationSymbol, + talentTypeLabel, } from "@/lib/guides/talents"; +import { comparisonDelta, comparisonReferenceNames } from "@/lib/guides/comparison"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { normalizeExtraRichText } from "@/lib/rich-text"; import { @@ -94,8 +96,8 @@ export function StructuredGuideShell({ const sections = publicSections(guide); const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`; const currentPageTitle = section - ? (sections.find((item) => item.slug === section)?.title ?? section) - : "Overview"; + ? publicSectionTitle(section, sections.find((item) => item.slug === section)?.title ?? section) + : "ภาพรวม"; return (
@@ -109,16 +111,16 @@ export function StructuredGuideShell({
) : null}
-
+
-
+
{guide.name}
@@ -126,7 +128,7 @@ export function StructuredGuideShell({

{guide.name}

-

+

{currentPageTitle}

@@ -134,14 +136,16 @@ export function StructuredGuideShell({
- Overview + ภาพรวม {sections.map((item) => ( ) : null} - {item.title} + {publicSectionTitle(item.slug, item.title)} ))} @@ -186,7 +191,7 @@ export function StructuredGuideShell({ {section ? ( ) : ( - + )}
@@ -197,9 +202,11 @@ export function StructuredGuideShell({ function GuideOverview({ guide, sections, + baseHref, }: { guide: PublicGuide; sections: ReturnType; + baseHref: string; }) { const youtubeVideos = guide.youtubeUrls.flatMap((url) => { const video = parseYouTubeUrl(url); @@ -217,6 +224,7 @@ function GuideOverview({
+
-
- +
+
{guide.posterArtUrl || youtubeVideos.length ? ( <> @@ -299,19 +307,90 @@ function GuideOverview({ ); } +function GuideBuildSummary({ guide, baseHref }: { guide: PublicGuide; baseHref: string }) { + const weaponGroup = guide.weaponGroups[0]; + const summarizeWeapon = (row: PublicGuide["weapons"][number] | undefined) => row ? { + name: row.weapon.name, refinement: row.refinement, note: row.note, + conditionName: weaponGroup?.name, conditionNote: weaponGroup?.note, + } : null; + const artifactGroup = guide.artifactGroups[0]; + const artifact = artifactGroup?.tiers.flatMap((tier) => tier.rows)[0]; + // Complete admin previews already contain this data; keep their reads unchanged. + const summary = guide.buildSummary ?? { + weapon: summarizeWeapon(weaponGroup?.rows[0]), + fourStarWeapon: summarizeWeapon(weaponGroup?.rows.find((row) => row.weapon.rarity === 4)), + artifact: artifact ? { + name: artifact.first.name, secondKey: artifact.secondArtifactKey, secondName: artifact.second?.name, + note: artifact.note, conditionName: artifactGroup?.name, conditionNote: artifactGroup?.note, + } : null, + profile: guide.artifactProfile, + }; + if (!summary || (!summary.weapon && !summary.artifact && !summary.profile)) return null; + const profile = summary.profile; + const recommended = profile?.recommendedConditions[0] ?? profile; + const target = profile?.targetConditions[0]; + const targets = target?.rows ?? profile?.targets ?? []; + const talents = normalizeTalentPriorities(profile?.talents ?? []); + return + + สรุปคำแนะนำในไกด์ + สรุปจากเงื่อนไขแรกของแต่ละหัวข้อ เปิดรายละเอียดเพื่อเลือกเงื่อนไขที่ตรงกับทีมของคุณ + + + {summary.weapon &&
+

อาวุธ

+

{summary.weapon.name} R{summary.weapon.refinement}

+ {summary.fourStarWeapon &&

ตัวเลือก 4 ดาวในไกด์: {summary.fourStarWeapon.name} R{summary.fourStarWeapon.refinement}

} + {summary.weapon.conditionName && {summary.weapon.conditionName}} + {summary.weapon.conditionNote &&

{summary.weapon.conditionNote}

} + {summary.weapon.note &&

{summary.weapon.note}

} + เปรียบเทียบอาวุธทั้งหมด +
} + {summary.artifact &&
+

อาร์ติแฟกต์

+

{summary.artifact.secondKey ? `2 ${summary.artifact.name} + 2 ${summary.artifact.secondName ?? "ชุดที่สองตามไกด์"}` : `4 ${summary.artifact.name}`}

+ {summary.artifact.conditionName && {summary.artifact.conditionName}} + {summary.artifact.conditionNote &&

{summary.artifact.conditionNote}

} + {summary.artifact.note &&

{summary.artifact.note}

} +
} + {recommended && [recommended.sands, recommended.goblet, recommended.circlet].some((values) => values.length) &&
+

ออฟหลัก

+ {profile?.recommendedConditions[0]?.name && {profile.recommendedConditions[0].name}} + {profile?.recommendedConditions[0]?.note &&

{profile.recommendedConditions[0].note}

} +
+ {([['นาฬิกา', recommended.sands], ['ถ้วย', recommended.goblet], ['หัว', recommended.circlet]] as const).map(([label, values]) => + values.length ?
{label}
{values.map((value) => statLabel(value)).join(" / ")}
: null)} +
+
} + {(targets.length > 0 || talents.length > 0) &&
+ {targets.length > 0 && <> +

ค่าสถานะเป้าหมาย

+ {target?.name && {target.name}} + {target?.note &&

{target.note}

} +
{targets.map((row, index) =>
{statLabel(row.stat)}
{row.value}
)}
+ } + {talents.length > 0 && <>

ลำดับพรสวรรค์

{talents.map((talent, index) => {talentTypeLabel(talent.type)}{talent.level ? ` Lv.${talent.level}` : ""}{index < talents.length - 1 ? ` ${talentRelationSymbol(talent.relationToNext)} ` : ""})}

} + ดูค่าสถานะและเงื่อนไขทั้งหมด +
} +
+
; +} + function GuideNavigation({ guide, sections, + baseHref, }: { guide: PublicGuide; sections: ReturnType; + baseHref: string; }) { return sections.length ? (
{note ? ( @@ -472,11 +551,41 @@ function WeaponSectionContent({ ) : null}
{group.note ?

{group.note}

: null} + {!exporting && + เปอร์เซ็นต์นี้หมายถึงอะไร? + +

100% คือค่าฐานของเงื่อนไขนี้ เช่น 109% คือดาเมจมากกว่าค่าฐาน 9% เปรียบเทียบภายในทีมและเงื่อนไขเดียวกัน

+ {([['overallPercent', 'ดาเมจทั้งทีม'], ['personalPercent', 'ดาเมจส่วนตัว']] as const).map(([key, label]) => { + if (!group.rows.some((row) => Number(row[key]) > 0)) return null; + const references = comparisonReferenceNames(group.rows, key); + return

{label} · แถวที่แสดง 100%: {references.length ? references.join(", ") : "ผู้เขียนยังไม่ได้ระบุแถว 100%"}

; + })} +
+
}
- {group.rows.map((row, index) => ( + {group.rows.map((row, index) => (!exporting ? ( + + + + + + + {index + 1}. {row.weapon.name} R{row.refinement} + +
+ {([['overallPercent', 'ทั้งทีม'], ['personalPercent', 'ส่วนตัว']] as const).map(([key, label]) => comparisonDelta(row[key]) !== null ? +

{metric(row[key])}

{label}

{comparisonDelta(row[key])}

: null)} + {comparisonDelta(row.overallPercent) === null && comparisonDelta(row.personalPercent) === null && ยังไม่มีผลคำนวณ} +
+ {row.note &&

{row.note}

} +
+
+ ) : ( - ))} + )))}
@@ -742,7 +851,7 @@ function ArtifactSectionContent({ )} />

- Artifacts & Stats + {exportLabel(exporting, "อาร์ติแฟกต์และค่าสถานะ", "Artifacts & Stats")}

{action} @@ -761,7 +870,7 @@ function ArtifactSectionContent({ {recommendedCards} )} -
+
{(() => { const tierCards = guide.artifactGroups.map((condition, conditionIndex) => (
@@ -1179,7 +1288,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) { className="size-14 shrink-0 object-contain" />

- Constellations + กลุ่มดาว

{note ? ( @@ -1256,7 +1365,7 @@ function TeamSection({ guide }: { guide: PublicGuide }) {

คำนวณความเสียหาย

-

Team Calculation

+

คำนวณทีม

diff --git a/components/public/stygian-guide.tsx b/components/public/stygian-guide.tsx index 73523fa..50de994 100644 --- a/components/public/stygian-guide.tsx +++ b/components/public/stygian-guide.tsx @@ -1,6 +1,9 @@ import Image from "@/components/ui/resilient-image"; +import Link from "next/link"; import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { buttonVariants } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { StygianBossCard, @@ -77,12 +80,22 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position); if (!boss) return null; const groups = schedule.recommendations.filter((group) => group.level === level && group.bossPosition === position); - const teams = groups.filter((group) => group.kind === "team"); + const teams = groups.filter((group) => group.kind === "team" && group.items.length > 0); const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items); return ( - -
-

Recommended team

+ + {!teams.length && !units.length && + คำแนะนำสำหรับบอสนี้กำลังจัดทำ + +

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

+
+ อ่านกลไกบอส + เลือกไกด์ตัวละคร +
+
+
} +
+

ทีมแนะนำ

{teams.length ?
{teams.map((team) => (
@@ -90,11 +103,11 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; {Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => ))} -
:

No team recommendations yet.

} +
:

ยังไม่มีทีมแนะนำสำหรับบอสนี้ในระดับที่เลือก

}
-
-

Recommended unit

- {units.length ?
{units.map((item) => )}
:

No unit recommendations yet.

} +
+

ตัวเลือกแนะนำ

+ {units.length ?
{units.map((item) => )}
:

ยังไม่มีตัวเลือกแนะนำเพิ่มเติมในระดับที่เลือก

}
); @@ -103,9 +116,9 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) { return ( - + {stygianLevels.map((level) => ( - Level {level} + เลเวล {level} ))} {stygianLevels.map((level) => ( diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index 98954bf..f2b405f 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -9,6 +9,7 @@ import { Fragment, useMemo, useState, type ReactNode } from "react"; import { GuideImageExport } from "@/components/public/guide-image-export"; import { ConditionTabIcon } from "@/components/public/condition-switcher"; import { ExportText } from "@/components/public/export-language"; +import { RotationHelp } from "@/components/public/rotation-help"; import { ScrollableNav } from "@/components/public/scrollable-nav"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -503,8 +504,8 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { if (order) setDpsOrder(order); }} > - Best DPS - Worst DPS + DPS สูงสุด + DPS ต่ำสุด
+ {filteredTeams.length ? (
{filteredTeams.map((team) => ( diff --git a/components/public/weapon-display-mode.tsx b/components/public/weapon-display-mode.tsx index fb39ae7..0dcbbf3 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/site-entry-prompt.tsx b/components/site-entry-prompt.tsx deleted file mode 100644 index fe62656..0000000 --- a/components/site-entry-prompt.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"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"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { Switch } from "@/components/ui/switch"; - -type InstallEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: string }> }; - -const dismissedKey = "site-entry-prompt-dismissed"; -const dontAskAgainKey = "site-entry-prompt-dont-ask-again"; - -function isPromptDismissed() { - return sessionStorage.getItem(dismissedKey) === "1" || - localStorage.getItem(dontAskAgainKey) === "1"; -} - -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 [permission, setPermission] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [dontAskAgain, setDontAskAgain] = useState(false); - - 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 canNotify = window.isSecureContext && "Notification" in window && - "PushManager" in window && "serviceWorker" in navigator; - - const frame = requestAnimationFrame(() => { - setInstalled(installedNow); - setPermission(canNotify ? Notification.permission : null); - if (!isPromptDismissed() && - ((ios && !installedNow) || (canNotify && Notification.permission === "default"))) { - setOpen(true); - } - }); - - const beforeInstall = (event: Event) => { - event.preventDefault(); - setInstallEvent(event as InstallEvent); - if (!isPromptDismissed()) setOpen(true); - }; - const onInstalled = () => { - setInstalled(true); - setInstallEvent(null); - 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); - } - - function toggleDontAskAgain(checked: boolean) { - setDontAskAgain(checked); - if (checked) localStorage.setItem(dontAskAgainKey, "1"); - else localStorage.removeItem(dontAskAgainKey); - } - - async function install() { - if (!installEvent) 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 showInstall = !installed && (ios || installEvent !== null); - const canAskNotification = permission === "default" && (!ios || installed); - - return { if (!next) dismiss(); }}> - - - {showInstall ? "ติดตั้ง Buzz Guide และรับการแจ้งเตือน" : "รับการแจ้งเตือนจาก Buzz Guide"} - - {showInstall ? - "เพิ่มเว็บไซต์ลงหน้าจอหลักเพื่อเปิดใช้งานได้สะดวก และรับการแจ้งเตือนเมื่อมีข้อความใหม่ใน Commission หลังเข้าสู่ระบบ" : - "รับการแจ้งเตือนเมื่อมีข้อความใหม่ใน Commission หลังเข้าสู่ระบบ"} - - - {!installed && ios &&

- บน iPhone/iPad แตะปุ่มแชร์ใน Safari แล้วเลือก ‘เพิ่มไปยังหน้าจอโฮม’ จากนั้นเปิดแอปที่ติดตั้ง -

} - {ios && !installed &&

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

} - {error &&

{error}

} - - ไม่ต้องถามอีก - - - - - {installEvent && !installed && } - {canAskNotification && } - -
-
; -} diff --git a/components/site-install-button.tsx b/components/site-install-button.tsx new file mode 100644 index 0000000..74e0e56 --- /dev/null +++ b/components/site-install-button.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { DownloadIcon } from "lucide-react"; + +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 }>; +}; + +export function SiteInstallButton() { + const [open, setOpen] = useState(false); + const [installed, setInstalled] = useState(false); + const [installEvent, setInstallEvent] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const displayMode = window.matchMedia("(display-mode: standalone)"); + const frame = requestAnimationFrame(() => setInstalled(displayMode.matches || + (navigator as Navigator & { standalone?: boolean }).standalone === true)); + const beforeInstall = (event: Event) => { + event.preventDefault(); + setInstallEvent(event as InstallEvent); + }; + const onInstalled = () => { setInstalled(true); setOpen(false); setInstallEvent(null); }; + window.addEventListener("beforeinstallprompt", beforeInstall); + window.addEventListener("appinstalled", onInstalled); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("beforeinstallprompt", beforeInstall); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + + async function install() { + if (!installEvent || busy) return; + setBusy(true); + setError(""); + try { + await installEvent.prompt(); + const result = await installEvent.userChoice; + setInstallEvent(null); + if (result.outcome === "accepted") setOpen(false); + } catch { setError("ติดตั้งไม่สำเร็จ กรุณาลองจากเมนูของเบราว์เซอร์"); } + finally { setBusy(false); } + } + + if (installed) return null; + + return <> + + + + + ติดตั้ง Buzz Guide + เพิ่มไกด์ลงหน้าจอหลักเพื่อเปิดอ่านได้สะดวก + + {!installEvent &&
+

บน iPhone/iPad เปิดใน Safari แตะปุ่มแชร์ แล้วเลือก “เพิ่มไปยังหน้าจอโฮม”

+

บน Android หรือคอมพิวเตอร์ เปิดเมนูของเบราว์เซอร์ แล้วเลือก “ติดตั้งแอป” หรือ “เพิ่มไปยังหน้าจอหลัก” หากมีตัวเลือกนี้

+
} + {error &&

{error}

} + + + {installEvent && } + +
+
+ ; +} diff --git a/components/stygian/stygian-boss-display.tsx b/components/stygian/stygian-boss-display.tsx index 3ab34e6..80da6da 100644 --- a/components/stygian/stygian-boss-display.tsx +++ b/components/stygian/stygian-boss-display.tsx @@ -117,10 +117,12 @@ export function StygianBossCard({ boss, level, children, + mechanicsId, }: { boss: StygianBossDisplay; level: 105 | 110; children: ReactNode; + mechanicsId?: string; }) { return ( @@ -128,14 +130,14 @@ export function StygianBossCard({

{boss.name}

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

{children} - + - Boss overview + ข้อมูลและกลไกของบอส { + it("preserves a partially completed request and its refinements", () => { + expect(restoreCommissionDraft(serializeCommissionDraft(request, now), catalog, now)).toEqual(request); + }); + it("keeps drafts scoped to the account", () => { + expect(commissionDraftKey("first")).not.toBe(commissionDraftKey("second")); + }); + it("ignores corrupt, unsupported, expired, and future drafts", () => { + for (const serialized of ["broken", '{"version":2}', serializeCommissionDraft(request, now - 8 * 86400000), serializeCommissionDraft(request, now + 1)]) { + expect(restoreCommissionDraft(serialized, catalog, now)).toBeNull(); + } + }); + it("rejects malformed member arrays rather than breaking the picker UI", () => { + expect(restoreCommissionDraft(JSON.stringify({ version: 1, savedAt: now, request: { ...request, teams: [{ members: [blank] }] } }), catalog, now)).toBeNull(); + }); + it("clears removed and incompatible items without losing the rest of the draft", () => { + const changed = structuredClone(request); + changed.teams[0].members[0].weaponKey = "bow"; + changed.teams[0].members[0].artifactKey = "removed"; + changed.weapons[0].weaponKeys = ["sword", "removed"]; + changed.weapons[0].refinements = { sword: "3", removed: "5" }; + changed.constellations[0] = { characterKey: "removed", levels: [2, 4] }; + const restored = restoreCommissionDraft(serializeCommissionDraft(changed, now), catalog, now)!; + expect(restored.teams[0].members[0]).toEqual({ ...blank, characterKey: "character" }); + expect(restored.weapons[0]).toEqual({ characterKey: "character", weaponKeys: ["sword", ""], refinements: { sword: "3" } }); + expect(restored.constellations[0]).toEqual({ characterKey: "", levels: [2, 4] }); + }); +}); diff --git a/lib/commission/draft.ts b/lib/commission/draft.ts new file mode 100644 index 0000000..57687e2 --- /dev/null +++ b/lib/commission/draft.ts @@ -0,0 +1,61 @@ +import * as z from "zod"; +import type { CommissionRequest } from "./request"; + +const draftKey = z.string().max(64); +const draftRequestSchema = z.object({ + teams: z.array(z.object({ members: z.array(z.object({ + characterKey: draftKey, weaponKey: draftKey, artifactKey: draftKey, + constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]), + refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]), + })).length(4) })).max(20), + weapons: z.array(z.object({ + characterKey: draftKey, weaponKeys: z.array(draftKey).min(1).max(100), + refinements: z.record(draftKey, z.enum(["1", "2", "3", "4", "5"])).optional(), + })).max(20), + constellations: z.array(z.object({ + characterKey: draftKey, levels: z.array(z.number().int().min(0).max(6)).max(7), + })).max(20), +}); +const draftSchema = z.object({ version: z.literal(1), savedAt: z.number(), request: draftRequestSchema }); +const DRAFT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; + +type DraftCatalog = { + characters: { key: string; weaponType?: string | null }[]; + weapons: { key: string; weaponType?: string | null }[]; + artifacts: { key: string }[]; +}; + +export function commissionDraftKey(userId: string) { + return `commission-request-draft:${userId}`; +} + +export function serializeCommissionDraft(request: CommissionRequest, now = Date.now()) { + return JSON.stringify({ version: 1, savedAt: now, request }); +} + +export function restoreCommissionDraft(serialized: string | null, catalog: DraftCatalog, now = Date.now()): CommissionRequest | null { + if (!serialized) return null; + let parsed; + try { parsed = draftSchema.safeParse(JSON.parse(serialized)); } catch { return null; } + if (!parsed.success || now - parsed.data.savedAt > DRAFT_LIFETIME_MS || parsed.data.savedAt > now) return null; + const request = parsed.data.request; + const characterKeys = new Set(catalog.characters.map((item) => item.key)); + const artifactKeys = new Set(catalog.artifacts.map((item) => item.key)); + const compatibleWeapon = (characterKey: string, weaponKey: string) => { + const character = catalog.characters.find((item) => item.key === characterKey); + return character?.weaponType && catalog.weapons.some((item) => item.key === weaponKey && item.weaponType === character.weaponType) + ? weaponKey : ""; + }; + for (const team of request.teams) for (const member of team.members) { + if (!characterKeys.has(member.characterKey)) member.characterKey = ""; + member.weaponKey = compatibleWeapon(member.characterKey, member.weaponKey); + if (!artifactKeys.has(member.artifactKey)) member.artifactKey = ""; + } + for (const set of request.weapons) { + if (!characterKeys.has(set.characterKey)) set.characterKey = ""; + set.weaponKeys = set.weaponKeys.map((key) => compatibleWeapon(set.characterKey, key)); + set.refinements = Object.fromEntries(Object.entries(set.refinements ?? {}).filter(([key]) => key && set.weaponKeys.includes(key))); + } + for (const set of request.constellations) if (!characterKeys.has(set.characterKey)) set.characterKey = ""; + return request; +} diff --git a/lib/commission/request-validation.test.ts b/lib/commission/request-validation.test.ts new file mode 100644 index 0000000..b88516e --- /dev/null +++ b/lib/commission/request-validation.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { commissionRequestIssues } from "./request-validation"; +import type { CommissionRequest } from "./request"; + +const member = (key: string) => ({ characterKey: key, weaponKey: "weapon", artifactKey: "artifact", constellation: "0", refinement: "1" } as const); + +describe("commission request feedback", () => { + it("identifies each missing selection so errors can appear beside their pickers", () => { + const request: CommissionRequest = { teams: [{ members: [member("a"), member("b"), { ...member("c"), weaponKey: "", artifactKey: "" }, member("d")] }], weapons: [], constellations: [] }; + expect(commissionRequestIssues(request)).toEqual([ + { path: "teams.0.members.2.weaponKey", message: "ทีม 1: เลือกอาวุธให้ตัวละครที่ 3" }, + { path: "teams.0.members.2.artifactKey", message: "ทีม 1: เลือกอาร์ติแฟกต์ให้ตัวละครที่ 3" }, + ]); + }); + it("reports duplicate characters at their team rather than at an unrelated picker", () => { + expect(commissionRequestIssues({ teams: [{ members: [member("a"), member("a"), member("c"), member("d")] }], weapons: [], constellations: [] })[0].path).toBe("teams.0"); + }); + it("accepts a valid comparison without requiring a team", () => { + expect(commissionRequestIssues({ teams: [], weapons: [{ characterKey: "a", weaponKeys: ["one", "two"] }], constellations: [] })).toEqual([]); + }); +}); diff --git a/lib/commission/request-validation.ts b/lib/commission/request-validation.ts new file mode 100644 index 0000000..0d98897 --- /dev/null +++ b/lib/commission/request-validation.ts @@ -0,0 +1,20 @@ +import { commissionRequestSchema, type CommissionRequest } from "./request"; + +export function commissionRequestIssues(request: CommissionRequest) { + const parsed = commissionRequestSchema.safeParse(request); + if (parsed.success) return []; + return parsed.error.issues.map(({ path }) => { + let message = "เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ"; + if (path[0] === "teams") { + const teamNumber = Number(path[1]) + 1; + const memberNumber = typeof path[3] === "number" ? path[3] + 1 : null; + message = memberNumber && path[4] === "characterKey" ? `ทีม ${teamNumber}: เลือกตัวละครที่ ${memberNumber}` + : memberNumber && path[4] === "weaponKey" ? `ทีม ${teamNumber}: เลือกอาวุธให้ตัวละครที่ ${memberNumber}` + : memberNumber && path[4] === "artifactKey" ? `ทีม ${teamNumber}: เลือกอาร์ติแฟกต์ให้ตัวละครที่ ${memberNumber}` + : `ทีม ${teamNumber}: เลือกตัวละคร 4 ตัวที่ไม่ซ้ำกัน พร้อมอาวุธและอาร์ติแฟกต์`; + } + if (path[0] === "weapons") message = `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`; + if (path[0] === "constellations") message = `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`; + return { path: path.join("."), message }; + }); +} diff --git a/lib/guides/comparison.test.ts b/lib/guides/comparison.test.ts new file mode 100644 index 0000000..db29702 --- /dev/null +++ b/lib/guides/comparison.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { comparisonDelta, comparisonReferenceNames } from "./comparison"; + +describe("weapon comparison context", () => { + it("shows relative gains and losses, including decimals", () => { + expect(comparisonDelta("109")).toBe("+9% จากค่าฐาน"); + expect(comparisonDelta("98.5")).toBe("-1.5% จากค่าฐาน"); + expect(comparisonDelta("100")).toBe("เท่าค่าฐาน"); + }); + it("does not present missing or placeholder numbers as damage losses", () => { + for (const value of [null, "0", "invalid"]) expect(comparisonDelta(value)).toBeNull(); + }); + it("keeps team and personal reference rows separate", () => { + const rows = [ + { weapon: { name: "Team reference" }, refinement: "5", overallPercent: "100", personalPercent: "110" }, + { weapon: { name: "Personal reference" }, refinement: "1", overallPercent: "105", personalPercent: "100" }, + ]; + expect(comparisonReferenceNames(rows, "overallPercent")).toEqual(["Team reference R5"]); + expect(comparisonReferenceNames(rows, "personalPercent")).toEqual(["Personal reference R1"]); + expect(comparisonReferenceNames(rows.slice(0, 1), "personalPercent")).toEqual([]); + }); +}); diff --git a/lib/guides/comparison.ts b/lib/guides/comparison.ts new file mode 100644 index 0000000..e079bb3 --- /dev/null +++ b/lib/guides/comparison.ts @@ -0,0 +1,14 @@ +export function comparisonDelta(value: string | null): string | null { + if (value === null || !Number.isFinite(Number(value)) || Number(value) === 0) return null; + const difference = Number(value) - 100; + if (!difference) return "เท่าค่าฐาน"; + return `${difference > 0 ? "+" : ""}${difference.toLocaleString("th-TH", { maximumFractionDigits: 2 })}% จากค่าฐาน`; +} + +export function comparisonReferenceNames(rows: readonly { + weapon: { name: string }; refinement: string; + overallPercent: string | null; personalPercent: string | null; +}[], metric: "overallPercent" | "personalPercent") { + return rows.filter((row) => row[metric] !== null && Number(row[metric]) === 100) + .map((row) => `${row.weapon.name} R${row.refinement}`); +} diff --git a/lib/guides/queries.test.ts b/lib/guides/queries.test.ts index 65c3539..564af63 100644 --- a/lib/guides/queries.test.ts +++ b/lib/guides/queries.test.ts @@ -27,9 +27,9 @@ vi.mock("@/db", () => ({ import { artifactConditions, artifactProfiles, constellationConditions, extraSectionRows, guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions, - catalogCharacters, catalogMaterials, guidePosterBleeding, media, + catalogCharacters, catalogMaterials, guidePosterBleeding, media, weaponRecommendations, artifactRecommendations, } from "@/db/schema"; -import { getActiveCatalog, getAdminGuideContent, listAdminTeamImportsForCharacter } from "./queries"; +import { getActiveCatalog, getAdminGuideContent, getPublicGuide, listAdminTeamImportsForCharacter } from "./queries"; beforeEach(() => { mocks.reads.length = 0; @@ -173,3 +173,32 @@ describe("team import lookup", () => { expect(mocks.reads).toEqual([teamCompositions, teamMembers]); }); }); + +describe("public overview build summary", () => { + it("loads small recommendation reads only for published sections", async () => { + mocks.rows.set(guideSections, [{ section: { id: "weapon", slug: "weapon", kind: "weapon" }, populated: true }]); + mocks.rows.set(weaponRecommendations, [ + { name: "First", rarity: 5, refinement: "1", conditionId: "first" }, + { name: "Four-star", rarity: 4, refinement: "5", conditionId: "first" }, + { name: "Different condition", rarity: 4, refinement: "1", conditionId: "second" }, + ]); + const result = await getPublicGuide("test"); + expect(result?.buildSummary?.weapon?.name).toBe("First"); + expect(result?.buildSummary?.fourStarWeapon?.name).toBe("Four-star"); + expect(mocks.reads).not.toContain(artifactProfiles); + expect(mocks.reads).not.toContain(artifactRecommendations); + expect(mocks.reads).not.toContain(teamMembers); + }); + it("does not add overview recommendation reads to admin editing", async () => { + await getAdminGuideContent("test", "overview"); + expect(mocks.reads).not.toContain(weaponRecommendations); + expect(mocks.reads).not.toContain(artifactProfiles); + }); + it("does not disclose recommendations from hidden sections", async () => { + mocks.rows.set(guideSections, []); + mocks.rows.set(weaponRecommendations, [{ name: "Hidden", conditionId: "hidden", rarity: 5, refinement: "1" }]); + const result = await getPublicGuide("test"); + expect(result?.buildSummary?.weapon).toBeNull(); + expect(mocks.reads).not.toContain(weaponRecommendations); + }); +}); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index bb659e3..3a2bdd0 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -430,6 +430,45 @@ export async function getPublicGuideSummary(slug: string) { return { ...summary, coverUrl: publicMediaUrl(coverObjectKey), sections: await populatedSections(row.id) }; } +async function getOverviewBuildSummary(guideId: string, sections: { slug: string }[]) { + const hasSection = (slug: string) => sections.some((section) => section.slug === slug); + const [weapons, artifacts, profiles] = await runWithConcurrency([ + () => hasSection("weapon") ? getDb().select({ + name: catalogWeapons.name, rarity: catalogWeapons.rarity, refinement: weaponRecommendations.refinement, note: weaponRecommendations.note, + conditionId: weaponConditions.id, conditionName: weaponConditions.name, conditionNote: weaponConditions.note, + }).from(weaponRecommendations) + .innerJoin(weaponConditions, eq(weaponConditions.id, weaponRecommendations.conditionId)) + .innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)) + .where(eq(weaponConditions.guideId, guideId)) + .orderBy(asc(weaponConditions.sortOrder), asc(weaponRecommendations.sortOrder)).limit(30) : Promise.resolve([]), + () => hasSection("artifact") ? getDb().select({ + name: catalogArtifacts.name, + secondKey: artifactRecommendations.secondArtifactKey, note: artifactRecommendations.note, + conditionName: artifactConditions.name, conditionNote: artifactConditions.note, + }).from(artifactRecommendations) + .innerJoin(artifactGroups, eq(artifactGroups.id, artifactRecommendations.groupId)) + .innerJoin(artifactConditions, eq(artifactConditions.id, artifactGroups.conditionId)) + .innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)) + .where(eq(artifactConditions.guideId, guideId)) + .orderBy(asc(artifactConditions.sortOrder), asc(artifactGroups.sortOrder), asc(artifactRecommendations.sortOrder)).limit(1) : Promise.resolve([]), + () => hasSection("artifact") ? getDb().select({ + sands: artifactProfiles.sands, goblet: artifactProfiles.goblet, circlet: artifactProfiles.circlet, + substats: artifactProfiles.substats, recommendedConditions: artifactProfiles.recommendedConditions, + targets: artifactProfiles.targets, targetConditions: artifactProfiles.targetConditions, talents: artifactProfiles.talents, + }).from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1) : Promise.resolve([]), + ]); + const firstWeapons = weapons.filter((weapon) => weapon.conditionId === weapons[0]?.conditionId); + const artifact = artifacts[0]; + const [second] = artifact?.secondKey ? await getDb().select({ name: catalogArtifacts.name }) + .from(catalogArtifacts).where(eq(catalogArtifacts.key, artifact.secondKey)).limit(1) : []; + return { + weapon: firstWeapons[0] ?? null, + fourStarWeapon: firstWeapons.find((weapon) => weapon.rarity === 4) ?? null, + artifact: artifact ? { ...artifact, secondName: second?.name ?? null } : null, + profile: profiles[0] ?? null, + }; +} + async function getGuideContent(slug: string, publicOnly: boolean, selectedSection?: string) { const [row] = await getDb() .select({ guide: guides, cover: media, character: catalogCharacters }) @@ -534,6 +573,8 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio return { ...row.guide, + buildSummary: publicOnly && selectedSection === "overview" + ? await getOverviewBuildSummary(row.guide.id, availableSections ?? []) : null, coverUrl: publicMediaUrl(row.cover.objectKey), coverStatus: row.cover.status, posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,