feat(ui) : improve guide navigation, signup, and commissions

This commit is contained in:
2026-10-07 04:39:57 +07:00 Unverified
parent b35c1a4471
commit 22f5f96231
31 changed files with 737 additions and 400 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { commissionDraftKey, restoreCommissionDraft, serializeCommissionDraft } from "./draft";
import type { CommissionRequest } from "./request";
const now = 1_800_000_000_000;
const catalog = {
characters: [{ key: "character", weaponType: "sword" }],
weapons: [{ key: "sword", weaponType: "sword" }, { key: "bow", weaponType: "bow" }],
artifacts: [{ key: "artifact" }],
};
const blank = { characterKey: "", weaponKey: "", artifactKey: "", constellation: "0", refinement: "1" } as const;
const request: CommissionRequest = {
teams: [{ members: [{ ...blank, characterKey: "character", weaponKey: "sword", artifactKey: "artifact" }, blank, blank, blank] }],
weapons: [{ characterKey: "character", weaponKeys: ["sword", ""], refinements: { sword: "3" } }],
constellations: [{ characterKey: "character", levels: [] }],
};
describe("commission draft recovery", () => {
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] });
});
});
+61
View File
@@ -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;
}
+21
View File
@@ -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([]);
});
});
+20
View File
@@ -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 };
});
}