feat : add analytics page
CI / Verify (push) Successful in 2m10s
CI / Build immutable images and deploy (push) Successful in 2m33s

This commit is contained in:
2026-10-10 20:19:49 +07:00 Unverified
parent 6fc4427ae1
commit a3803118ef
24 changed files with 910 additions and 29 deletions
@@ -101,7 +101,7 @@ export function ConstellationImageGenerator({
<Card>
<CardHeader>
<CardTitle>ข้อมูล Constellation</CardTitle>
<CardDescription>กรอกดาเมจสะสม C1–C6 เทียบกับ C0 ที่ 100%</CardDescription>
<CardDescription>กรอกดาเมจสะสม C1-C6 เทียบกับ C0 ที่ 100%</CardDescription>
<CardAction>
<Button type="button" variant="ghost" size="sm" onClick={resetDraft}>
<RotateCcwIcon data-icon="inline-start" />
@@ -115,7 +115,7 @@ export function ConstellationImageGenerator({
<EmptyHeader>
<EmptyMedia variant="icon"><PlusIcon /></EmptyMedia>
<EmptyTitle>ยังไม่มี Constellation</EmptyTitle>
<EmptyDescription>สร้างรายการ C1–C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
<EmptyDescription>สร้างรายการ C1-C6 เพื่อเริ่มกรอกเปอร์เซ็นต์ดาเมจ</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setDraft((current) => ({ ...current, rows: constellationRows() }))}>
+1 -1
View File
@@ -173,7 +173,7 @@ export function TalentLevelPicker({
>
<ComboboxInput
className="w-full"
placeholder="เลือกระดับ 1–10"
placeholder="เลือกระดับ 1-10"
showClear
/>
<ComboboxContent>
+1 -1
View File
@@ -42,7 +42,7 @@ export function TeamImageGenerator({ characters, weapons, artifacts }: { charact
return <div className="grid items-start gap-6 xl:grid-cols-[minmax(24rem,1fr)_minmax(28rem,1fr)]">
<Card>
<CardHeader><CardTitle>ข้อมูล Team</CardTitle><CardDescription>รองรับตัวละคร 1–4 คน และคำนวณ DPS อัตโนมัติ</CardDescription><CardAction><Button type="button" variant="ghost" size="sm" onClick={resetDraft}><RotateCcwIcon data-icon="inline-start" />Reset</Button></CardAction></CardHeader>
<CardHeader><CardTitle>ข้อมูล Team</CardTitle><CardDescription>รองรับตัวละคร 1-4 คน และคำนวณ DPS อัตโนมัติ</CardDescription><CardAction><Button type="button" variant="ghost" size="sm" onClick={resetDraft}><RotateCcwIcon data-icon="inline-start" />Reset</Button></CardAction></CardHeader>
<CardContent className="flex flex-col gap-5">
{!draft.members.length ? <Empty className="border"><EmptyHeader><EmptyMedia variant="icon"><PlusIcon /></EmptyMedia><EmptyTitle>ยังไม่มี Team</EmptyTitle><EmptyDescription>เพิ่มตัวละครคนแรกเพื่อเริ่มกรอกข้อมูล Team</EmptyDescription></EmptyHeader><EmptyContent><Button type="button" onClick={() => setDraft((current) => ({ ...current, members: [emptyMember()] }))}><PlusIcon data-icon="inline-start" />เพิ่ม Team</Button></EmptyContent></Empty> : <>
<FieldGroup>
+1 -1
View File
@@ -54,7 +54,7 @@ export function CommissionIntro() {
<div>
<h2 id="commission-how-it-works" className="font-heading text-2xl font-semibold">ขอคำนวณแบบที่ต้องการ</h2>
<p className="mt-2 text-muted-foreground">เลือกได้ทั้งจัดทีม เปรียบเทียบอาวุธ และเปรียบเทียบกลุ่มดาว โดยเห็นยอดก่อนชำระเงิน</p>
<p className="mt-2 text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)</p>
<p className="mt-2 text-sm text-muted-foreground">ระยะเวลาจัดทำ 1-7 วันหลังยืนยันการชำระเงิน ขึ้นอยู่กับขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมง ติดตามความคืบหน้าและสอบถามได้ในแชตของคำขอ (Ticket)</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{steps.map((step) => <Card key={step.title} size="sm">
+1 -1
View File
@@ -142,7 +142,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
return <FieldSet ref={root} disabled={busy || !hydrated} className="flex flex-col gap-6">
<FieldLegend className="sr-only">คำขอคำนวณทีม อาวุธ และกลุ่มดาว</FieldLegend>
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</p>
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1-7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
{attempted && issues.some((issue) => !issue.path) && <Alert variant="destructive" role="alert"><AlertDescription>เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ</AlertDescription></Alert>}
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalyticsDashboard } from "./analytics-dashboard";
import type { AnalyticsItem, PublicAnalytics } from "@/lib/analytics/model";
// Layout measurements belong to browser verification; the table exposes the same counts.
vi.mock("@/components/ui/chart", () => ({
ChartContainer: () => null, ChartTooltip: () => null, ChartTooltipContent: () => null,
}));
const item = (key: string, guideCount: number, stygianCount: number): AnalyticsItem => ({
key, name: key, imageUrl: null, rarity: null, guideCount, stygianCount,
contributions: [{ guideId: "g0", teamSignature: "guide-team", count: guideCount }, { guideId: "s0", teamSignature: "stygian-team", count: stygianCount }],
});
const entries = [item("Shared", 2, 5), item("Stygian only", 0, 3)];
const data: PublicAnalytics = {
guides: [
...Array.from({ length: 4 }, (_, index) => ({ id: `g${index}`, source: "guide" as const, name: `Guide ${index}` })),
...Array.from({ length: 2 }, (_, index) => ({ id: `s${index}`, source: "stygian" as const, name: `Stygian ${index}` })),
],
teamGroups: [{ guideId: "g0", signature: "guide-team", count: 1 }, { guideId: "s0", signature: "stygian-team", count: 1 }],
guideCount: 4, stygianScheduleCount: 2, guideTeamCount: 6, stygianTeamCount: 3,
team: { character: entries, weapon: entries, artifact: entries },
recommendation: { weapon: entries, artifact: entries },
};
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe("public analytics dashboard", () => {
it("starts with Stygian enabled and updates all five rankings and totals on toggle", async () => {
await act(async () => root.render(<AnalyticsDashboard data={data} />));
const toggle = container.querySelector<HTMLElement>('[role="switch"][aria-labelledby="include-stygian-label"]')!;
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(container.querySelector(`#${toggle.getAttribute("aria-labelledby")}`)?.textContent).toBe("รวมข้อมูล Stygian");
expect(container.querySelectorAll("details[data-ranking]")).toHaveLength(5);
expect(container.querySelectorAll("tbody tr")).toHaveLength(10);
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2");
expect(Array.from(container.querySelectorAll("tbody tr:first-child td:last-child"), (cell) => cell.textContent)).toEqual(Array(5).fill("7"));
const details = container.querySelector<HTMLDetailsElement>("details[data-ranking]")!;
details.open = true;
await act(async () => toggle.click());
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(details.open).toBe(true);
expect(container.querySelectorAll("tbody tr")).toHaveLength(5);
expect(container.textContent).not.toContain("Stygian only");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด1");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("เวอร์ชัน Stygian0");
expect(Array.from(container.querySelectorAll("tbody tr:first-child td:last-child"), (cell) => cell.textContent)).toEqual(Array(5).fill("2"));
await act(async () => root.render(<AnalyticsDashboard data={{ ...data, teamGroups: [...data.teamGroups, { guideId: "g1", signature: "new-team", count: 1 }] }} />));
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2");
});
it("shows empty states for sources without entries", async () => {
await act(async () => root.render(<AnalyticsDashboard data={{
...data, team: { character: [], weapon: [], artifact: [] }, recommendation: { weapon: [], artifact: [] },
}} />));
expect(container.querySelectorAll("details[data-ranking]")).toHaveLength(0);
expect(container.querySelectorAll('[data-slot="empty-title"]')).toHaveLength(5);
});
it("lets each guide be excluded and restored and supports repeated-team filtering", async () => {
await act(async () => root.render(<AnalyticsDashboard data={data} />));
const guide = container.querySelector<HTMLButtonElement>('[aria-label="นับไกด์ Guide 0"]')!;
await act(async () => guide.click());
expect(guide.getAttribute("aria-pressed")).toBe("false");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ไกด์ตัวละคร3");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด1");
const reset = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "รวมทุกไกด์")!;
await act(async () => reset.click());
expect(guide.getAttribute("aria-pressed")).toBe("true");
const deduplicate = container.querySelector<HTMLElement>('[role="switch"][aria-labelledby="ignore-repeated-teams-label"]')!;
expect(deduplicate.getAttribute("aria-checked")).toBe("true");
await act(async () => deduplicate.click());
expect(deduplicate.getAttribute("aria-checked")).toBe("false");
expect(container.querySelector('[aria-label="ภาพรวมข้อมูล"]')?.textContent).toContain("ทีมทั้งหมด2");
});
});
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts";
import { CheckIcon, MinusIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { selectAnalytics, type PublicAnalytics, type RankedAnalyticsItem } from "@/lib/analytics/model";
import { rarityColor } from "@/lib/catalog/rarity";
const formatCount = (count: number) => count.toLocaleString("th-TH");
const chartConfig = { count: { label: "จำนวนครั้ง", color: "var(--muted-foreground)" } };
const rarityLabel = (rarity: number | null) => rarity ? `${rarity} ดาว` : "ไม่ระบุดาว";
function ItemImageTick({ x, y, item }: { x: number; y: number; item: RankedAnalyticsItem }) {
const [failedSource, setFailedSource] = useState<string | null>(null);
return <g transform={`translate(${x},${y})`}>
<title>{item.name} · {rarityLabel(item.rarity)}</title>
{item.imageUrl && failedSource !== item.imageUrl ?
<image href={item.imageUrl} x={-32} y={-16} width={32} height={32} preserveAspectRatio="xMidYMid meet"
onError={() => setFailedSource(item.imageUrl)} /> :
<text x={-16} y={0} textAnchor="middle" dominantBaseline="central" className="fill-foreground">{item.name.slice(0, 1)}</text>}
</g>;
}
function UsageRanking({ title, description, items }: {
title: string;
description: string;
items: RankedAnalyticsItem[];
}) {
const top = items.slice(0, 10).map((item) => ({ ...item, fill: rarityColor(item.rarity) }));
const rarities = [...new Set(top.map((item) => item.rarity))].sort((a, b) => (b ?? 0) - (a ?? 0));
return (
<Card className="min-w-0">
<CardHeader>
<CardTitle><h3>{title}</h3></CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{items.length ? <>
<ul aria-label="สีตามระดับดาว" className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{rarities.map((rarity) => <li key={rarity ?? "unknown"} className="flex items-center gap-1.5">
<span aria-hidden="true" className="size-2.5 rounded-sm" style={{ backgroundColor: rarityColor(rarity) }} />
{rarityLabel(rarity)}
</li>)}
</ul>
<ChartContainer config={chartConfig} className="w-full aspect-auto" style={{ height: Math.max(150, top.length * 36 + 30) }} aria-label={`${title} 10 อันดับแรก`}>
<BarChart accessibilityLayer data={top} layout="vertical" margin={{ left: 0, right: 36, top: 4, bottom: 0 }}>
<CartesianGrid horizontal={false} />
<XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tickFormatter={formatCount} />
<YAxis dataKey="key" type="category" width={46} axisLine={false} tickLine={false}
tick={({ x, y, payload }) => {
const item = top.find((entry) => entry.key === payload.value);
return item ? <ItemImageTick x={Number(x)} y={Number(y)} item={item} /> : <g />;
}} />
<ChartTooltip content={<ChartTooltipContent labelFormatter={(_label, payload) => {
const item = payload[0]?.payload;
return item ? `${item.name} · ${rarityLabel(item.rarity)}` : "";
}} />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} isAnimationActive={false}>
<LabelList dataKey="count" position="right" className="fill-foreground" formatter={(value) => formatCount(Number(value))} />
</Bar>
</BarChart>
</ChartContainer>
<details data-ranking className="group">
<summary className="cursor-pointer rounded-md py-2 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
ดูอันดับทั้งหมด ({formatCount(items.length)} รายการ)
</summary>
<Table>
<TableHeader><TableRow>
<TableHead className="w-12">อันดับ</TableHead>
<TableHead>ชื่อ</TableHead>
<TableHead className="text-right">ครั้ง</TableHead>
</TableRow></TableHeader>
<TableBody>{items.map((item, index) => <TableRow key={item.key}>
<TableCell>{formatCount(index + 1)}</TableCell>
<TableCell className="whitespace-normal">
<div className="flex items-center gap-2">
<Avatar>
{item.imageUrl ? <AvatarImage src={item.imageUrl} alt="" loading="lazy" /> : null}
<AvatarFallback>{item.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<span className="flex min-w-0 flex-col gap-0.5 break-words">
<span>{item.name}</span>
<span className="text-xs text-muted-foreground">{rarityLabel(item.rarity)}</span>
</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">{formatCount(item.count)}</TableCell>
</TableRow>)}</TableBody>
</Table>
</details>
</> : <Empty>
<EmptyHeader><EmptyTitle>ยังไม่มีข้อมูล</EmptyTitle><EmptyDescription>ยังไม่มีรายการในแหล่งข้อมูลที่เลือก</EmptyDescription></EmptyHeader>
</Empty>}
</CardContent>
</Card>
);
}
export function AnalyticsDashboard({ data }: { data: PublicAnalytics }) {
const [includeStygian, setIncludeStygian] = useState(true);
const [excludedGuideIds, setExcludedGuideIds] = useState<Set<string>>(() => new Set());
const [ignoreRepeatedTeams, setIgnoreRepeatedTeams] = useState(true);
const selected = selectAnalytics(data, includeStygian, excludedGuideIds, ignoreRepeatedTeams);
const toggleGuide = (id: string) => setExcludedGuideIds((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const summaries = [
{ label: "ไกด์ตัวละคร", count: selected.guideCount },
{ label: "เวอร์ชัน Stygian", count: selected.stygianScheduleCount },
{ label: "ทีมทั้งหมด", count: selected.teamCount },
{ label: "ตัวละครที่ใช้ในทีม", count: selected.team.character.length },
{ label: "อาวุธที่ใช้ในทีม", count: selected.team.weapon.length },
{ label: "เซ็ตอาร์ติแฟกต์ที่ใช้ในทีม", count: selected.team.artifact.length },
];
return <div className="flex flex-col gap-8">
<Field orientation="horizontal" className="max-w-xl">
<Switch id="include-stygian" checked={includeStygian} onCheckedChange={setIncludeStygian} aria-labelledby="include-stygian-label" aria-describedby="stygian-description" />
<FieldContent>
<FieldLabel id="include-stygian-label" htmlFor="include-stygian">รวมข้อมูล Stygian</FieldLabel>
<FieldDescription id="stygian-description">รวมทุกเวอร์ชัน ระดับบอส และทีมที่แสดงในไกด์ Stygian</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal" className="max-w-xl">
<Switch id="ignore-repeated-teams" checked={ignoreRepeatedTeams} onCheckedChange={setIgnoreRepeatedTeams}
aria-labelledby="ignore-repeated-teams-label" aria-describedby="repeated-teams-description" />
<FieldContent>
<FieldLabel id="ignore-repeated-teams-label" htmlFor="ignore-repeated-teams">นับทีมซ้ำเพียงครั้งเดียว</FieldLabel>
<FieldDescription id="repeated-teams-description">ทีมที่มีตัวละคร อาวุธ เซ็ตอาร์ติแฟกต์ กลุ่มดาว และขัดเกลาเหมือนกัน จะนับครั้งเดียว ไม่ขึ้นกับลำดับสมาชิกหรือไกด์ที่บันทึก</FieldDescription>
</FieldContent>
</Field>
<details className="rounded-xl border p-4">
<summary className="cursor-pointer rounded-md font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
เลือกไกด์ที่นำมานับ ({formatCount(selected.guideCount + selected.stygianScheduleCount)} ไกด์)
</summary>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">กดปุ่มไกด์เพื่อไม่นับข้อมูล กดอีกครั้งเพื่อรวมกลับ</p>
<Button variant="outline" size="sm" disabled={!excludedGuideIds.size} onClick={() => setExcludedGuideIds(new Set())}>รวมทุกไกด์</Button>
</div>
{(["guide", "stygian"] as const).map((source) => <div key={source} className="flex flex-col gap-2">
<h3 className="text-sm font-medium">{source === "guide" ? "ไกด์ตัวละคร" : "เวอร์ชัน Stygian"}</h3>
<div className="flex flex-wrap gap-2">
{data.guides.filter((guide) => guide.source === source).map((guide) => {
const included = !excludedGuideIds.has(guide.id);
return <Button key={guide.id} variant={included ? "secondary" : "outline"} size="sm"
className="h-auto min-h-11 max-w-full whitespace-normal text-left"
aria-label={`นับไกด์ ${guide.name}`} aria-pressed={included} disabled={source === "stygian" && !includeStygian}
onClick={() => toggleGuide(guide.id)}>
{included ? <CheckIcon data-icon="inline-start" aria-hidden="true" /> : <MinusIcon data-icon="inline-start" aria-hidden="true" />}
{guide.name}
</Button>;
})}
</div>
</div>)}
</div>
</details>
<section aria-label="ภาพรวมข้อมูล" className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{summaries.map(({ label, count }) => <Card key={label} size="sm">
<CardHeader><CardDescription>{label}</CardDescription></CardHeader>
<CardContent><p className="font-heading text-3xl font-semibold tabular-nums">{formatCount(count)}</p></CardContent>
</Card>)}
</section>
<p className="text-sm leading-relaxed text-muted-foreground">
นับจากข้อมูลที่บันทึกในไกด์สาธารณะ ไม่ใช่สถิติการเล่นของผู้เล่น
แต่ละรายการแนะนำถูกนับแยกกัน รวมตัวเลือกซ้ำ
{ignoreRepeatedTeams ? " ทีมที่มีตัวละครและบิลด์เหมือนกันจะนับเพียงครั้งเดียวจากไกด์ที่เลือก" : " แต่ละสมาชิกทีมถูกนับแยกกัน รวมทีมที่คัดลอกข้ามไกด์หรือเวอร์ชัน"}
อาร์ติแฟกต์แบบผสมจะนับทั้งสองเซ็ต เซ็ตเดียวกันในรายการเดียวจะนับครั้งเดียว
ไกด์ที่ซ่อน อยู่ในถังขยะ และส่วนที่ปิดการแสดงผลจะไม่ถูกนับ
</p>
<section aria-labelledby="team-usage-title" className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 id="team-usage-title" className="font-heading text-2xl font-semibold">ใช้ในทีมบ่อยที่สุด</h2>
<p className="text-sm text-muted-foreground">นับจำนวนครั้งที่ตัวละครและอุปกรณ์ปรากฏในสมาชิกทีม</p>
</div>
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
<UsageRanking title="ตัวละคร" description="ตัวละครที่ปรากฏในทีมบ่อยที่สุด" items={selected.team.character} />
<UsageRanking title="อาวุธ" description="อาวุธที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.weapon} />
<UsageRanking title="อาร์ติแฟกต์" description="เซ็ตอาร์ติแฟกต์ที่สมาชิกทีมใช้บ่อยที่สุด" items={selected.team.artifact} />
</div>
</section>
<section aria-labelledby="recommendation-title" className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 id="recommendation-title" className="font-heading text-2xl font-semibold">แนะนำบ่อยที่สุด</h2>
<p className="text-sm text-muted-foreground">นับรายการแนะนำอาวุธและอาร์ติแฟกต์ แยกจากอุปกรณ์ที่ใช้ในทีม{includeStygian ? " รวมอุปกรณ์ของตัวละครแนะนำใน Stygian" : ""}</p>
</div>
<div className="grid items-start gap-4 md:grid-cols-2">
<UsageRanking title="อาวุธแนะนำ" description="อาวุธที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.weapon} />
<UsageRanking title="อาร์ติแฟกต์แนะนำ" description="เซ็ตอาร์ติแฟกต์ที่ปรากฏในรายการแนะนำบ่อยที่สุด" items={selected.recommendation.artifact} />
</div>
</section>
</div>;
}
+8 -7
View File
@@ -3,9 +3,9 @@
import Link from "next/link";
import Image from "@/components/ui/resilient-image";
import { usePathname } from "next/navigation";
import { BookOpenIcon, MenuIcon } from "lucide-react";
import { BookOpenIcon, ChartNoAxesCombinedIcon, MenuIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
@@ -20,6 +20,7 @@ const destinations = [
{ href: "/", label: "หน้ารวม", icon: null },
{ href: "/commission", label: "จ้างคำนวณทีม", icon: "/icon/commission.webp" },
{ href: "/stygian", label: "ไกด์ Stygian", icon: "/icon/stygian.webp" },
{ href: "/analytics", label: "วิเคราะห์ข้อมูล", icon: null },
] as const;
export function PublicNavigation() {
@@ -29,10 +30,10 @@ export function PublicNavigation() {
return <>
<nav aria-label="เมนูหลัก" className="hidden items-center gap-1 lg:flex">
{destinations.slice(1).map(({ href, label, icon }) =>
<Button key={href} variant="ghost" size="sm" nativeButton={false}
render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined} />}>
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : null}{label}
</Button>)}
<Link key={href} href={href} aria-current={isCurrent(href) ? "page" : undefined}
className={buttonVariants({ variant: "ghost", size: "sm" })}>
{icon ? <Image src={icon} alt="" width={24} height={24} className="size-6 object-contain" /> : <ChartNoAxesCombinedIcon data-icon="inline-start" aria-hidden="true" />}{label}
</Link>)}
</nav>
<div className="lg:hidden">
<Drawer>
@@ -48,7 +49,7 @@ export function PublicNavigation() {
{destinations.map(({ href, label, icon }) =>
<DrawerClose key={href} render={<Link href={href} aria-current={isCurrent(href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-lg px-3 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />}>
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : <BookOpenIcon aria-hidden="true" />}{label}
{icon ? <Image src={icon} alt="" width={20} height={20} className="size-5 object-contain" /> : href === "/analytics" ? <ChartNoAxesCombinedIcon aria-hidden="true" /> : <BookOpenIcon aria-hidden="true" />}{label}
{isCurrent(href) ? <span className="ml-auto text-xs text-primary">ปัจจุบัน</span> : null}
</DrawerClose>)}
</nav>