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
+22
View File
@@ -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([]);
});
});
+14
View File
@@ -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}`);
}
+31 -2
View File
@@ -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);
});
});
+41
View File
@@ -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,