feat : custom stats with double price in commission
CI / Verify (push) Successful in 1m49s
CI / Build immutable images and deploy (push) Successful in 2m14s

This commit is contained in:
2026-10-10 05:08:06 +07:00 Unverified
parent 0e813a0a3e
commit 6fc4427ae1
10 changed files with 186 additions and 10 deletions
@@ -68,6 +68,7 @@ export function CommissionIntro() {
<p className="flex justify-between gap-3"><span>จัดทีม 4 ตัวละคร</span><strong>฿100 ต่อทีม</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบอาวุธ</span><strong>฿20 ต่ออาวุธ</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบกลุ่มดาว</span><strong>฿20 ต่อระดับ</strong></p>
<p className="flex justify-between gap-3"><span>กำหนดค่าสเตตัสเอง (Custom stats)</span><strong>ยอดรวมทั้งหมด ×2</strong></p>
<p className="border-t pt-3 text-muted-foreground">หากไม่เลือกทีม จะมีค่าบริการพื้นฐาน ฿100</p>
</CardContent>
</Card>
+49 -3
View File
@@ -9,9 +9,10 @@ import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/opt
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { commissionPrice, type CommissionRequest } from "@/lib/commission/request";
import { commissionPrice, hasCustomStats, 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";
@@ -24,6 +25,26 @@ const blankMember = (): CommissionRequest["teams"][number]["members"][number] =>
});
const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) });
function CustomStatsFields({ id, characterName, value, disabled, invalid, onValueChange }: {
id: string; characterName: string; value: string | undefined; disabled: boolean;
invalid: boolean; onValueChange: (value: string | undefined) => void;
}) {
return <FieldGroup className="mt-2 gap-2">
<Field orientation="horizontal">
<Checkbox id={id} disabled={disabled} checked={value !== undefined}
onCheckedChange={(checked) => onValueChange(checked ? "" : undefined)} />
<FieldLabel htmlFor={id}>Custom stats</FieldLabel>
</Field>
{value !== undefined && <Field data-invalid={invalid}>
<FieldLabel htmlFor={`${id}-details`}>ค่าสเตตัสของ {characterName}</FieldLabel>
<Textarea id={`${id}-details`} value={value} maxLength={4000} required aria-invalid={invalid}
aria-describedby={`${id}-help`} placeholder="ATK 2,000, CRIT Rate 70%, CRIT DMG 140%, ER 180%"
onChange={(event) => onValueChange(event.target.value)} />
<FieldDescription id={`${id}-help`}>ราคาทั้งคำขอ ×2 เมื่อมีตัวละครใช้ Custom stats</FieldDescription>
</Field>}
</FieldGroup>;
}
function checkoutError(code: string | undefined, status: number): string {
if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง";
if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
@@ -123,7 +144,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</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 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว</CardDescription></CardHeader>
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{request.teams.map((team, teamIndex) => <Card key={teamIndex} className="w-full"><CardHeader><CardTitle>ทีม {teamIndex + 1}</CardTitle></CardHeader><CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -171,6 +192,14 @@ 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" }} />
</div>
<p className="mt-1 truncate text-center text-xs font-medium" title={character?.name}>{character?.name ?? "เลือกตัวละคร"}</p>
<CustomStatsFields id={`custom-stats-team-${teamIndex}-${memberIndex}`}
characterName={character?.name ?? `ตัวละครที่ ${memberIndex + 1}`} value={member.customStats}
disabled={!character} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.customStats`)}
onValueChange={(customStats) => {
const teams = structuredClone(request.teams);
teams[teamIndex].members[memberIndex].customStats = customStats;
patch({ teams });
}} />
{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && <FieldError>{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" - ")}</FieldError>}
</div>;
})}
@@ -234,6 +263,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
<CustomStatsFields id={`custom-stats-weapons-${index}`}
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`weapons.${index}.customStats`)}
onValueChange={(customStats) => {
const weapons = structuredClone(request.weapons);
weapons[index].customStats = customStats;
patch({ weapons });
}} />
{errorsAt(`weapons.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`weapons.${index}`).join(" - ")}</FieldError>}
<Button variant="outline" disabled={set.weaponKeys.length >= 100} onClick={() => {
const weapons = structuredClone(request.weapons); weapons[index].weaponKeys.push(""); patch({ weapons });
@@ -263,6 +300,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
<CustomStatsFields id={`custom-stats-constellations-${index}`}
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`constellations.${index}.customStats`)}
onValueChange={(customStats) => {
const constellations = structuredClone(request.constellations);
constellations[index].customStats = customStats;
patch({ constellations });
}} />
{errorsAt(`constellations.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`constellations.${index}`).join(" - ")}</FieldError>}
<Button variant="outline" onClick={() => patch({ constellations: request.constellations.filter((_, i) => i !== index) })}>ลบชุดนี้</Button>
</FieldGroup>)}
@@ -275,6 +320,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
<CardFooter className="flex flex-wrap items-center justify-between gap-3">
{error && <Alert variant="destructive" role="alert" className="w-full"><AlertDescription>{error}</AlertDescription></Alert>}
<div><strong className="text-lg">รวม ฿{commissionPrice(request)}</strong>
{hasCustomStats(request) && <p className="text-sm text-muted-foreground">Custom stats: ยอดรวมปกติ ฿{commissionPrice(request) / 2} ×2</p>}
<p className="text-xs text-muted-foreground sm:hidden">{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)} ระดับกลุ่มดาว</p>
{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 && <p className="text-sm text-muted-foreground">รวมค่าบริการพื้นฐาน ฿100</p>}
</div>
+21 -1
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Kbd } from "@/components/ui/kbd";
import { elementBackgroundColor } from "@/lib/catalog/element";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import type { CommissionRequest } from "@/lib/commission/request";
import { hasCustomStats, type CommissionRequest } from "@/lib/commission/request";
import { cn } from "@/lib/utils";
type Item = { key: string; name: string; imageUrl: string; rarity?: number | null; element?: string | null };
@@ -60,6 +60,10 @@ function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest[
<Badge variant="secondary" className="h-4 px-0.5 text-[9px] lg:h-auto lg:px-2 lg:text-xs">R{member.refinement ?? "1"}</Badge>
</div>
<p className="mt-1 truncate text-center text-[10px] font-medium lg:text-xs" title={character?.name}>{character?.name ?? member.characterKey}</p>
{member.customStats !== undefined && <div className="mt-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-xs">{member.customStats}</p>
</div>}
</div>;
})}
</CardContent>
@@ -75,6 +79,14 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
request.constellations.reduce((sum, set) => sum + set.levels.length * 20, 0);
return <div className="@container flex flex-col gap-4">
{hasCustomStats(request) && <Card size="sm">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle>กำหนดค่าสเตตัสเอง (Custom stats)</CardTitle><Badge variant="secondary">ราคาทั้งหมด ×2</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">เพิ่มจากยอดรวมปกติ ฿{amountBaht / 2} รวมทั้งหมด ฿{amountBaht}</p>
</CardContent>
</Card>}
{request.teams.length === 0 && amountBaht > comparisonTotal ?
<Card size="sm"><CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle>ค่าบริการพื้นฐาน</CardTitle><Badge variant="secondary">฿100</Badge>
@@ -98,6 +110,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
})}
</div>
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
</div>}
</CardContent>
</Card>;
})}
@@ -110,6 +126,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
<CardContent className="grid grid-cols-[minmax(0,1fr)_64px] items-center gap-3">
<div className="flex min-w-0 flex-wrap gap-2">{set.levels.map((level) => <Badge key={level} variant="outline">C{level}</Badge>)}</div>
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
</div>}
</CardContent>
</Card>;
})}