feat : Con more reaible

This commit is contained in:
2026-09-26 20:54:40 +07:00 Unverified
parent bfd9f1765d
commit 17c4f21948
11 changed files with 325 additions and 24 deletions
+8 -2
View File
@@ -57,8 +57,10 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog"; import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
import { MediaUploader } from "@/components/admin/media-uploader"; import { MediaUploader } from "@/components/admin/media-uploader";
import { ScrollableNav } from "@/components/public/scrollable-nav"; import { ScrollableNav } from "@/components/public/scrollable-nav";
import { LunarisText } from "@/components/public/lunaris-text";
import { TeamArtifactIcon } from "@/components/public/team-search"; import { TeamArtifactIcon } from "@/components/public/team-search";
import { SimpleTooltip } from "@/components/tooltip"; import { SimpleTooltip } from "@/components/tooltip";
import type { CatalogTextNode } from "@/lib/catalog/lunaris";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -299,6 +301,7 @@ type CatalogConstellation = {
level: number; level: number;
name: string; name: string;
description: string; description: string;
descriptionNodes?: CatalogTextNode[];
iconUrl: string | null; iconUrl: string | null;
}; };
type CatalogTalent = { type CatalogTalent = {
@@ -2474,6 +2477,7 @@ function ConstellationEditor({
name: `C${index + 1}`, name: `C${index + 1}`,
description: description:
savedRows.find((row) => row.level === index + 1)?.detail ?? "", savedRows.find((row) => row.level === index + 1)?.detail ?? "",
descriptionNodes: undefined,
iconUrl: null, iconUrl: null,
})); }));
return source.map((constellation) => { return source.map((constellation) => {
@@ -2643,9 +2647,11 @@ function ConstellationEditor({
/> />
) : null} ) : null}
<CardTitle> <CardTitle>
C{row.level} · {row.name} C{row.level} - {row.name}
</CardTitle> </CardTitle>
<CardDescription>{row.description}</CardDescription> <CardDescription className="whitespace-pre-line">
<LunarisText nodes={row.descriptionNodes} fallback={row.description} />
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex items-end gap-3"> <CardContent className="flex items-end gap-3">
<Field className="min-w-0 flex-1"> <Field className="min-w-0 flex-1">
+34
View File
@@ -0,0 +1,34 @@
import { Fragment, type ReactNode } from "react";
import { TermDetail } from "@/components/public/term-detail";
import type { CatalogTextNode } from "@/lib/catalog/lunaris";
function renderNodes(nodes: CatalogTextNode[]): ReactNode {
return nodes.map((node, index) => {
if (node.type === "text") return <Fragment key={index}>{node.value}</Fragment>;
const children = renderNodes(node.children);
if (node.type === "color") {
return <span key={index} style={{ color: node.value }}>{children}</span>;
}
if (!node.detail) return <Fragment key={index}>{children}</Fragment>;
return (
<TermDetail
key={index}
label={children}
title={node.detail.name}
detail={node.detail.description}
showIcon={false}
/>
);
});
}
export function LunarisText({
nodes,
fallback,
}: {
nodes?: CatalogTextNode[];
fallback: string;
}) {
return <>{nodes?.length ? renderNodes(nodes) : fallback}</>;
}
+5 -2
View File
@@ -34,6 +34,7 @@ import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/publi
import { ArtifactImageExport } from "@/components/public/artifact-image-export"; import { ArtifactImageExport } from "@/components/public/artifact-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image"; import { ConstellationImage } from "@/components/public/constellation-image";
import { LunarisText } from "@/components/public/lunaris-text";
import { ConditionSwitcher } from "@/components/public/condition-switcher"; import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster"; import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content"; import { RichTextContent } from "@/components/rich-text-content";
@@ -1226,12 +1227,14 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{metric(row.cumulativePercent)} {metric(row.cumulativePercent)}
</CardTitle> </CardTitle>
<CardDescription className="text-base font-medium text-white"> <CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name} C{row.level} - {row.name}
</CardDescription> </CardDescription>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="whitespace-pre-line leading-7">{row.detail}</p> <div className="whitespace-pre-line leading-7">
<LunarisText nodes={row.detailNodes} fallback={row.detail} />
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
+17 -13
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { useSyncExternalStore } from "react"; import { useSyncExternalStore, type ReactNode } from "react";
import { import {
Drawer, Drawer,
@@ -29,33 +29,37 @@ function getSnapshot() {
return window.matchMedia(finePointerQuery).matches; return window.matchMedia(finePointerQuery).matches;
} }
function Trigger({ label }: { label: string }) { function Trigger({ label, showIcon }: { label: ReactNode; showIcon: boolean }) {
return ( return (
<span className="inline-flex min-h-11 cursor-help items-center gap-1 rounded-md text-left font-medium underline decoration-border underline-offset-4 outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring md:min-h-0"> <span className="inline-flex min-h-11 cursor-help items-center gap-1 rounded-md text-left font-medium underline decoration-border underline-offset-4 outline-none transition-colors hover:bg-primary/15 hover:text-primary hover:decoration-2 hover:decoration-primary focus-visible:bg-primary/15 focus-visible:text-primary focus-visible:ring-2 focus-visible:ring-ring md:min-h-0">
{label} {label}
<InfoIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> {showIcon ? <InfoIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> : null}
</span> </span>
); );
} }
export function TermDetail({ export function TermDetail({
label, label,
title,
detail, detail,
showIcon = true,
}: { }: {
label: string; label: ReactNode;
title: string;
detail: string; detail: string;
showIcon?: boolean;
}) { }) {
const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false); const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false);
if (finePointer) { if (finePointer) {
return ( return (
<HoverCard> <HoverCard>
<HoverCardTrigger aria-label={`ดูรายละเอียด ${label}`}> <HoverCardTrigger aria-label={`ดูรายละเอียด ${title}`}>
<Trigger label={label} /> <Trigger label={label} showIcon={showIcon} />
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent align="start"> <HoverCardContent align="start">
<p className="font-medium">{label}</p> <p className="font-medium text-primary">{title}</p>
<p className="mt-1 text-muted-foreground">{detail}</p> <p className="mt-1 whitespace-pre-line text-muted-foreground">{detail}</p>
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
); );
@@ -63,13 +67,13 @@ export function TermDetail({
return ( return (
<Drawer showSwipeHandle> <Drawer showSwipeHandle>
<DrawerTrigger aria-label={`เปิดรายละเอียด ${label}`}> <DrawerTrigger aria-label={`เปิดรายละเอียด ${title}`}>
<Trigger label={label} /> <Trigger label={label} showIcon={showIcon} />
</DrawerTrigger> </DrawerTrigger>
<DrawerContent> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerTitle>{label}</DrawerTitle> <DrawerTitle className="text-primary">{title}</DrawerTitle>
<DrawerDescription>{detail}</DrawerDescription> <DrawerDescription className="whitespace-pre-line text-left">{detail}</DrawerDescription>
</DrawerHeader> </DrawerHeader>
<div className="h-6" /> <div className="h-6" />
</DrawerContent> </DrawerContent>
+45
View File
@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { LunarisText } from "@/components/public/lunaris-text";
import type { CatalogTextNode } from "@/lib/catalog/lunaris";
describe("LunarisText", () => {
it("renders source colors and an accessible interactive term", () => {
const nodes: CatalogTextNode[] = [
{
type: "link",
target: "N11500003",
detail: {
name: "Marvelous Splendor",
description: "First line\nSecond line",
},
children: [{ type: "text", value: "Marvelous Splendor" }],
},
{ type: "text", value: " deals " },
{
type: "color",
value: "#99FFFFFF",
children: [{ type: "text", value: "Cryo AoE DMG" }],
},
];
const html = renderToStaticMarkup(
<LunarisText nodes={nodes} fallback="fallback" />,
);
expect(html).toContain("Marvelous Splendor");
expect(html).toContain("aria-label=\"เปิดรายละเอียด Marvelous Splendor\"");
expect(html).toContain("hover:bg-primary/15");
expect(html).toContain("hover:decoration-2 hover:decoration-primary");
expect(html).toContain("color:#99FFFFFF");
expect(html).toContain("Cryo AoE DMG");
expect(html).not.toContain("fallback");
});
it("uses plain text for catalogs without structured nodes", () => {
expect(renderToStaticMarkup(
<LunarisText fallback="Legacy detail" />,
)).toContain("Legacy detail");
});
});
+54
View File
@@ -8,6 +8,7 @@ import {
characterDetailSchema, characterDetailSchema,
cleanLunarisText, cleanLunarisText,
constellationSnapshot, constellationSnapshot,
parseLunarisText,
nameCardImageName, nameCardImageName,
splashImageName, splashImageName,
supportsWeaponType, supportsWeaponType,
@@ -41,6 +42,58 @@ describe("Lunaris character data", () => {
expect(cleanLunarisText("First line\\n\\n<i>Second line</i>")).toBe("First line\n\nSecond line"); expect(cleanLunarisText("First line\\n\\n<i>Second line</i>")).toBe("First line\n\nSecond line");
}); });
it("parses Lunaris links and colors into safe display nodes", () => {
const links = new Map([["N11500003", {
name: "Marvelous Splendor",
description: "A special stack effect.",
}]]);
expect(parseLunarisText(
"Gain {LINK#N11500003}Marvelous Splendor{/LINK} and deal <color=#99FFFFFF>Cryo AoE DMG</color>.",
links,
)).toEqual([
{ type: "text", value: "Gain " },
{
type: "link",
target: "N11500003",
detail: { name: "Marvelous Splendor", description: "A special stack effect." },
children: [{ type: "text", value: "Marvelous Splendor" }],
},
{ type: "text", value: " and deal " },
{
type: "color",
value: "#99FFFFFF",
children: [{ type: "text", value: "Cryo AoE DMG" }],
},
{ type: "text", value: "." },
]);
});
it("resolves named skill links and ignores malformed display tags", () => {
const skills = new Map([["Slow Dance", {
name: "Slow Dance",
description: "Skill detail",
}]]);
expect(parseLunarisText(
"{LINK#S11502}<color=#FFD780FF>Slow Dance</color>{/LINK}</color>\n<color=javascript:alert(1)>Safe</color>",
new Map(),
skills,
)).toEqual([
{
type: "link",
target: "S11502",
detail: { name: "Slow Dance", description: "Skill detail" },
children: [{
type: "color",
value: "#FFD780FF",
children: [{ type: "text", value: "Slow Dance" }],
}],
},
{ type: "text", value: "\nSafe" },
]);
});
it("normalizes constellation levels and icon keys", () => { it("normalizes constellation levels and icon keys", () => {
const result = constellationSnapshot({ const result = constellationSnapshot({
constellations: { constellations: {
@@ -51,6 +104,7 @@ describe("Lunaris character data", () => {
level: 1, level: 1,
name: "First", name: "First",
description: "Detail", description: "Detail",
descriptionNodes: [{ type: "text", value: "Detail" }],
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp", iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
}]); }]);
}); });
+119
View File
@@ -4,9 +4,25 @@ export interface CatalogConstellation {
level: number; level: number;
name: string; name: string;
description: string; description: string;
descriptionNodes?: CatalogTextNode[];
iconKey: string | null; iconKey: string | null;
} }
export interface CatalogTextDetail {
name: string;
description: string;
}
export type CatalogTextNode =
| { type: "text"; value: string }
| { type: "color"; value: string; children: CatalogTextNode[] }
| {
type: "link";
target: string;
detail: CatalogTextDetail | null;
children: CatalogTextNode[];
};
export interface CatalogTalent { export interface CatalogTalent {
type: "NA" | "E" | "Q"; type: "NA" | "E" | "Q";
name: string; name: string;
@@ -52,6 +68,12 @@ const skillSchema = z.object({
icon: z.string(), icon: z.string(),
}); });
const hyperlinkSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
});
const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()])); const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()]));
const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int())); const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int()));
@@ -62,6 +84,7 @@ export const characterDetailSchema = z.object({
attributes: z.array(attributeSchema).nullish(), attributes: z.array(attributeSchema).nullish(),
}).nullish(), }).nullish(),
constellations: z.record(z.string(), constellationSchema).nullish(), constellations: z.record(z.string(), constellationSchema).nullish(),
hyperlinks: z.array(hyperlinkSchema).nullish(),
skills: z.object({ skills: z.object({
normalattack: skillSchema.optional(), normalattack: skillSchema.optional(),
elementalskill: skillSchema.optional(), elementalskill: skillSchema.optional(),
@@ -85,6 +108,79 @@ export function cleanLunarisText(value: string): string {
.trim(); .trim();
} }
const lunarisTagPattern = /(\{LINK#[^}]+\}|\{\/LINK\}|<color=#[0-9a-f]{6}(?:[0-9a-f]{2})?>|<\/color>|<[^>]+>)/giu;
function textFromNodes(nodes: CatalogTextNode[]): string {
return nodes.map((node) =>
node.type === "text" ? node.value : textFromNodes(node.children)
).join("");
}
export function parseLunarisText(
value: string,
linkDetails: ReadonlyMap<string, CatalogTextDetail> = new Map(),
namedDetails: ReadonlyMap<string, CatalogTextDetail> = new Map(),
): CatalogTextNode[] {
const appendText = (children: CatalogTextNode[], text: string) => {
const previous = children.at(-1);
if (previous?.type === "text") previous.value += text;
else children.push({ type: "text", value: text });
};
const root: CatalogTextNode[] = [];
const stack: Array<{
type: "root" | "color" | "link";
children: CatalogTextNode[];
node?: Extract<CatalogTextNode, { type: "color" | "link" }>;
}> = [{ type: "root", children: root }];
const source = value
.replace(/\\n/gu, "\n")
.replace(/^"|"$/gu, "");
let offset = 0;
for (const match of source.matchAll(lunarisTagPattern)) {
const index = match.index;
const current = stack.at(-1)!;
if (index > offset) {
appendText(current.children, source.slice(offset, index));
}
const tag = match[0];
const link = /^\{LINK#([^}]+)\}$/iu.exec(tag);
const color = /^<color=(#[0-9a-f]{6}(?:[0-9a-f]{2})?)>$/iu.exec(tag);
if (link) {
const node: Extract<CatalogTextNode, { type: "link" }> = {
type: "link",
target: link[1],
detail: linkDetails.get(link[1]) ?? null,
children: [],
};
current.children.push(node);
stack.push({ type: "link", children: node.children, node });
} else if (color) {
const node: Extract<CatalogTextNode, { type: "color" }> = {
type: "color",
value: color[1],
children: [],
};
current.children.push(node);
stack.push({ type: "color", children: node.children, node });
} else if (tag.toUpperCase() === "{/LINK}" && stack.at(-1)?.type === "link") {
const frame = stack.pop();
if (frame?.node?.type === "link" && !frame.node.detail) {
frame.node.detail = namedDetails.get(textFromNodes(frame.children).trim()) ?? null;
}
} else if (tag.toLowerCase() === "</color>" && stack.at(-1)?.type === "color") {
stack.pop();
}
offset = index + tag.length;
}
if (offset < source.length) {
appendText(stack.at(-1)!.children, source.slice(offset));
}
return root;
}
export function avatarIconName(value: string): string { export function avatarIconName(value: string): string {
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_"); return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
} }
@@ -113,6 +209,28 @@ export function constellationSnapshot(
iconKey: (icon: string) => string, iconKey: (icon: string) => string,
): CatalogConstellation[] { ): CatalogConstellation[] {
const result: CatalogConstellation[] = []; const result: CatalogConstellation[] = [];
const linkDetails = new Map(
(value.hyperlinks ?? []).map((item) => [
`N${item.id}`,
{
name: cleanLunarisText(item.name),
description: cleanLunarisText(item.description),
},
]),
);
const namedDetails = new Map(
[
value.skills?.normalattack,
value.skills?.elementalskill,
value.skills?.elementalburst,
].flatMap((skill) => skill ? [[
cleanLunarisText(skill.name),
{
name: cleanLunarisText(skill.name),
description: cleanLunarisText(skill.description),
},
] as const] : []),
);
for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) { for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) {
const level = index + 1; const level = index + 1;
const item = value.constellations?.[`const${level}`]; const item = value.constellations?.[`const${level}`];
@@ -121,6 +239,7 @@ export function constellationSnapshot(
level, level,
name: cleanLunarisText(item.name), name: cleanLunarisText(item.name),
description: cleanLunarisText(item.description), description: cleanLunarisText(item.description),
descriptionNodes: parseLunarisText(item.description, linkDetails, namedDetails),
iconKey: iconKey(item.icon), iconKey: iconKey(item.icon),
}); });
} }
+21 -7
View File
@@ -33,7 +33,8 @@ import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock"; import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client"; import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
import { import {
compareLunarisVersions, catalogFormatVersion,
catalogSnapshotIsCurrent,
lunarisVersionSchema, lunarisVersionSchema,
} from "@/lib/catalog/version"; } from "@/lib/catalog/version";
import { getMediaStorage } from "@/lib/media/storage"; import { getMediaStorage } from "@/lib/media/storage";
@@ -53,7 +54,10 @@ const versionSchema = z.object({
version: lunarisVersionSchema, version: lunarisVersionSchema,
timestamp: z.number().int().optional(), timestamp: z.number().int().optional(),
}); });
const currentVersionSchema = z.object({ version: lunarisVersionSchema }); const currentVersionSchema = z.object({
version: lunarisVersionSchema,
formatVersion: z.number().int().optional().default(1),
});
const characterSchema = z.record( const characterSchema = z.record(
z.string(), z.string(),
z.object({ z.object({
@@ -636,7 +640,10 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
); );
}); });
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว"); await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
await storage.write("catalog/current.json", JSON.stringify({ version }), { await storage.write("catalog/current.json", JSON.stringify({
version,
formatVersion: catalogFormatVersion,
}), {
type: "application/json", type: "application/json",
acl: "public-read", acl: "public-read",
}); });
@@ -717,10 +724,14 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob) {
} }
export async function getCurrentCatalogVersion(): Promise<string | null> { export async function getCurrentCatalogVersion(): Promise<string | null> {
return (await getCurrentCatalogState())?.version ?? null;
}
async function getCurrentCatalogState() {
const storage = await getMediaStorage(); const storage = await getMediaStorage();
const current = storage.file("catalog/current.json"); const current = storage.file("catalog/current.json");
if (!(await current.exists())) return null; if (!(await current.exists())) return null;
return currentVersionSchema.parse(await current.json()).version; return currentVersionSchema.parse(await current.json());
} }
async function hasCatalogRecords(): Promise<boolean> { async function hasCatalogRecords(): Promise<boolean> {
@@ -743,10 +754,13 @@ export async function processCatalogSync(
); );
return withCatalogSyncLock(async () => { return withCatalogSyncLock(async () => {
const currentVersion = await getCurrentCatalogVersion(); const current = await getCurrentCatalogState();
if ( if (
currentVersion && catalogSnapshotIsCurrent(
compareLunarisVersions(version, currentVersion) <= 0 && version,
current?.version ?? null,
current?.formatVersion ?? null,
) &&
await hasCatalogRecords() await hasCatalogRecords()
) { ) {
return "unchanged"; return "unchanged";
+8
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
catalogFormatVersion,
catalogSnapshotIsCurrent,
compareLunarisVersions, compareLunarisVersions,
parseLunarisVersionChange, parseLunarisVersionChange,
} from "./version"; } from "./version";
@@ -29,4 +31,10 @@ Odette`),
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0); expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1); expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
}); });
it("refreshes a same-version catalog when its local format is outdated", () => {
expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", null)).toBe(false);
expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion - 1)).toBe(false);
expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion)).toBe(true);
});
}); });
+13
View File
@@ -1,6 +1,7 @@
import * as z from "zod"; import * as z from "zod";
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u); export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
export const catalogFormatVersion = 2;
export interface LunarisVersionChange { export interface LunarisVersionChange {
previousVersion: string; previousVersion: string;
@@ -31,3 +32,15 @@ export function compareLunarisVersions(left: string, right: string): number {
} }
return 0; return 0;
} }
export function catalogSnapshotIsCurrent(
requestedVersion: string,
currentVersion: string | null,
currentFormatVersion: number | null,
): boolean {
return Boolean(
currentVersion &&
compareLunarisVersions(requestedVersion, currentVersion) <= 0 &&
currentFormatVersion === catalogFormatVersion
);
}
+1
View File
@@ -472,6 +472,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
...recommendation, ...recommendation,
name: catalog?.name ?? `C${recommendation.level}`, name: catalog?.name ?? `C${recommendation.level}`,
detail: catalog?.description ?? recommendation.detail, detail: catalog?.description ?? recommendation.detail,
detailNodes: catalog?.descriptionNodes,
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null, iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
}; };
}), }),