feat(ui) : improve guide navigation, signup, and commissions
This commit is contained in:
@@ -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] });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user