feat(i18n) : add personal languages and compact weapon exports
CI / Verify (push) Successful in 1m18s
CI / Build immutable images and deploy (push) Successful in 1m57s

This commit is contained in:
2026-09-14 16:44:44 +07:00 Unverified
parent f214d330f0
commit e0794822b1
72 changed files with 4199 additions and 1011 deletions
+68 -85
View File
@@ -1,3 +1,4 @@
import { Localized, Text, ExportText, StatText, MetricText, SectionTitle, ConditionLabel } from "@/components/i18n/text";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react";
@@ -54,7 +55,6 @@ import {
} from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text";
import { exportText, exportStatLabel, type Locale } from "@/lib/guides/export-text";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
@@ -84,14 +84,6 @@ export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug));
}
function publicSectionTitle(slug: string, fallback: string): string {
if (slug === "weapon") return "อาวุธ";
if (slug === "artifact") return "อาร์ติแฟกต์และค่าสถานะ";
if (slug === "constellations") return "กลุ่มดาว";
if (slug === "team") return "คำนวณทีม";
return fallback;
}
export function StructuredGuideShell({
guide,
section,
@@ -109,13 +101,13 @@ export function StructuredGuideShell({
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
<div className="sticky top-0 z-40 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6">
<Link
<Localized attributes={{ "aria-label": "กลับหน้ารวม" }}><Link
href="/"
aria-label="กลับหน้ารวม"
className="grid size-10 place-items-center rounded-lg hover:bg-muted"
>
<ChevronLeftIcon />
</Link>
</Link></Localized>
<div className="relative size-11 overflow-hidden rounded-lg border">
<Image
src={guide.coverUrl}
@@ -130,7 +122,7 @@ export function StructuredGuideShell({
{guide.name}
</p>
<p className="truncate font-heading text-2xl font-semibold">
{currentPageTitle}
<SectionTitle slug={section ?? "overview"} fallback={currentPageTitle} />
</p>
</div>
</div>
@@ -155,8 +147,7 @@ export function StructuredGuideShell({
height={20}
className="size-5 object-contain"
/>
Overview
</Link>
<Text message="Overview" /></Link>
{sections.map((item) => (
<Link
key={item.id}
@@ -178,7 +169,7 @@ export function StructuredGuideShell({
className="size-5 object-contain"
/>
) : null}
{item.title}
<SectionTitle slug={item.slug} fallback={item.title} />
</Link>
))}
</ScrollableNav>
@@ -210,20 +201,20 @@ function GuideOverview({
return (
<div className="flex flex-col gap-8">
<div className="mx-auto flex max-w-3xl flex-col gap-3 text-center">
<p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p>
<p className="text-sm font-medium text-primary"><Text message="ภาพรวมแนวทาง" /></p>
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
<p className="text-lg font-semibold">{guide.overview}</p>
</div>
<div className="grid items-start gap-6 lg:grid-cols-2">
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">
<Image
<Localized attributes={{ "alt": { message: "ไกด์ {name}", params: { name: guide.name } } }}><Image
src={guide.coverUrl}
alt={`ไกด์ ${guide.name}`}
fill
sizes="(min-width: 1280px) 600px, (min-width: 1024px) calc(50vw - 44px), calc(100vw - 32px)"
priority
className="object-cover"
/>
/></Localized>
</div>
<div className="order-3 lg:col-start-2 lg:row-start-1">
<GuideNavigation guide={guide} sections={sections} />
@@ -268,7 +259,7 @@ function GuideOverview({
{youtubeVideos.map((youtubeVideo, index) => (
<section key={`${youtubeVideo.videoId}-${index}`} className="flex flex-col items-center gap-3">
<div className="aspect-video w-full overflow-hidden rounded-xl border bg-muted">
<iframe
<Localized attributes={{ "title": { message: "วิดีโอไกด์ {name} {index}", params: { name: guide.name, index: index + 1 } } }}><iframe
src={youtubeVideo.embedUrl}
title={`วิดีโอไกด์ ${guide.name} ${index + 1}`}
loading="lazy"
@@ -276,7 +267,7 @@ function GuideOverview({
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="size-full"
/>
/></Localized>
</div>
</section>
))}
@@ -297,7 +288,7 @@ function GuideNavigation({
sections: ReturnType<typeof publicSections>;
}) {
return sections.length ? (
<nav aria-label={`เมนูคู่มือ ${guide.name}`} className="flex flex-col gap-3">
<Localized attributes={{ "aria-label": { message: "เมนูคู่มือ {name}", params: { name: guide.name } } }}><nav aria-label={`เมนูคู่มือ ${guide.name}`} className="flex flex-col gap-3">
{sections.map((item) => (
<Link
key={item.id}
@@ -316,8 +307,8 @@ function GuideNavigation({
className="size-8 object-contain"
/>
) : null}
<CardTitle>{publicSectionTitle(item.slug, item.title)}</CardTitle>
<CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription>
<CardTitle><SectionTitle slug={item.slug} fallback={item.title} /></CardTitle>
<CardDescription><Text message="เปิดดูคำแนะนำฉบับเต็ม" /></CardDescription>
<CardAction>
<ArrowUpRightIcon aria-hidden="true" className="size-5 text-primary" />
</CardAction>
@@ -325,12 +316,12 @@ function GuideNavigation({
</Card>
</Link>
))}
</nav>
</nav></Localized>
) : (
<Card>
<CardHeader>
<CardTitle>กำลังจัดทำเนื้อหา</CardTitle>
<CardDescription>กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล</CardDescription>
<CardTitle><Text message="กำลังจัดทำเนื้อหา" /></CardTitle>
<CardDescription><Text message="กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล" /></CardDescription>
</CardHeader>
</Card>
);
@@ -352,10 +343,8 @@ function GuideSection({
return extra ? <ExtraSection guide={guide} extra={extra} /> : null;
}
function metric(value: string | null, locale?: string) {
return value === null
? null
: `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`;
function metric(value: string | null) {
return value === null ? null : <MetricText value={value} />;
}
function lunarisItemUrl(
@@ -409,8 +398,7 @@ function WeaponSectionContent({
<header className="relative z-10 flex items-center justify-center text-center">
<div className="flex flex-col items-center gap-3">
<p className="text-sm font-medium text-primary">
อาวุธที่แนะนำ
</p>
<Text message="อาวุธที่แนะนำ" /></p>
<div className="flex items-center justify-center gap-3">
<Image
src="/icon/nav/Weapon.png"
@@ -425,8 +413,7 @@ function WeaponSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
Weapons
</h1>
<Text message="Weapons" /></h1>
</div>
{note ? (
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
@@ -438,14 +425,14 @@ function WeaponSectionContent({
<WeaponDisplayModeProvider>
<ConditionSwitcher
label="เลือกเงื่อนไขอาวุธ"
labels={weaponGroups.map((group, index) => group.name || `Condition ${index + 1}`)}
labels={weaponGroups.map((group) => group.name || "")}
>
{weaponGroups.map((group, groupIndex, groups) => (
<div key={group.id} className="relative z-10 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
{groups.length > 1 || conditionNumber ? (
<h2 className="font-heading text-2xl font-semibold">
{group.name || `Condition ${conditionNumber ?? groupIndex + 1}`}
<ConditionLabel name={group.name} index={conditionNumber ?? groupIndex + 1} />
</h2>
) : <span />}
{showExportActions ? (
@@ -473,7 +460,7 @@ function WeaponSectionContent({
: "md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)]",
)}
>
<a
<Localized attributes={{ "aria-label": { message: "เปิดข้อมูลอาวุธ {name} บน Lunaris", params: { name: row.weapon.name } } }}><a
href={lunarisItemUrl("weapon", row.weapon.key)}
target="_blank"
rel="noreferrer"
@@ -510,7 +497,7 @@ function WeaponSectionContent({
: "text-base sm:text-lg",
)}
>
{index + 1}. {row.weapon.name} R{row.refinement}
{index + 1}. {row.weapon.name} <Text message="R" />{row.refinement}
</p>
{row.note ? (
<p
@@ -523,7 +510,7 @@ function WeaponSectionContent({
</p>
) : null}
</div>
</a>
</a></Localized>
<div
className={cn(
"flex items-center justify-around bg-white py-5 text-black",
@@ -557,8 +544,7 @@ function WeaponSectionContent({
: "text-sm sm:text-base",
)}
>
ดาเมจทั้งทีม
</p>
<Text message="ดาเมจทั้งทีม" /></p>
</div>
) : null}
{row.personalPercent ? (
@@ -588,8 +574,7 @@ function WeaponSectionContent({
: "text-sm sm:text-base",
)}
>
ดาเมจส่วนตัว
</p>
<Text message="ดาเมจส่วนตัว" /></p>
</div>
) : null}
</div>
@@ -617,8 +602,8 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
<ArtifactImageExport
fileName={`${guide.name}-Artifact.png`}
hasNote={Boolean(guide.artifactProfile?.note)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group, index) => group.name || `Condition ${index + 1}`)}
targetConditionLabels={targetConditions.map((group, index) => group.name || `Condition ${index + 1}`)}
recommendedConditionLabels={(guide.artifactProfile?.recommendedConditions ?? []).map((group) => group.name || "")}
targetConditionLabels={targetConditions.map((group) => group.name || "")}
>
<ArtifactSectionContent guide={guide} exporting />
</ArtifactImageExport>
@@ -640,31 +625,31 @@ function ArtifactSectionContent({
const recommendedConditions = profile?.recommendedConditions ?? [];
const recommendedCards = recommendedConditions.map((condition, conditionIndex) => (
<div key={conditionIndex} data-artifact-export-section="best-stats" data-recommended-condition={conditionIndex} className="flex flex-col gap-5">
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium">{condition.name || exportText("condition", { index: conditionIndex + 1 })}</p> : null}
{exporting && recommendedConditions.length > 1 ? <p className="text-sm font-medium"><ConditionLabel name={condition.name ?? null} index={conditionIndex + 1} /></p> : null}
{condition.note ? <p data-export-note className="whitespace-pre-wrap text-sm text-muted-foreground">{condition.note}</p> : null}
{profile ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-main-stats overflow-hidden pt-0">
<CardHeader className="bg-primary py-4 text-center text-primary-foreground">
<CardTitle className="text-2xl">{exportText("mainStats", {}, exporting ? "en" : "th")}</CardTitle>
<CardTitle className="text-2xl"><ExportText message="mainStats" /></CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
label={exportText("sands", {}, exporting ? "en" : "th")}
label={<ExportText message="sands" />}
values={condition.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label={exportText("goblet", {}, exporting ? "en" : "th")}
label={<ExportText message="goblet" />}
values={condition.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label={exportText("circlet", {}, exporting ? "en" : "th")}
label={<ExportText message="circlet" />}
values={condition.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
@@ -675,7 +660,7 @@ function ArtifactSectionContent({
{condition.substats.length ? (
<Card data-artifact-export-section="best-stats" className="artifact-export-substats">
<CardContent className="flex flex-col items-center gap-2 py-5 text-center text-sm">
<strong>{exportText("substats", {}, exporting ? "en" : "th")}</strong>
<strong><ExportText message="substats" /></strong>
<div className="flex flex-col items-center gap-1">
{condition.substats.map((stat, index) => (
<ArtifactStatValue
@@ -710,7 +695,7 @@ function ArtifactSectionContent({
<header className="relative z-10 flex items-center justify-center text-center">
<div className="flex flex-col items-center gap-3">
<p className="text-sm font-medium text-primary">
{exportText("artifactRecommendation", {}, exporting ? "en" : "th")}
<ExportText message="artifactRecommendation" />
</p>
<div className="flex items-center justify-center gap-3">
<Image
@@ -726,7 +711,7 @@ function ArtifactSectionContent({
)}
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
{exportText("artifactTitle", {}, exporting ? "en" : "th")}
<ExportText message="artifactTitle" />
</h1>
</div>
{action}
@@ -741,7 +726,7 @@ function ArtifactSectionContent({
{" "}
<div className="artifact-export-left flex flex-col gap-5">
{exporting ? recommendedCards : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition, index) => condition.name || `Condition ${index + 1}`)}>
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะแนะนำ" labels={recommendedConditions.map((condition) => condition.name || "")}>
{recommendedCards}
</ConditionSwitcher>
)}
@@ -751,9 +736,9 @@ function ArtifactSectionContent({
<CardHeader>
<CardTitle>
{group.name === "Late Game"
? exportText("lateGame", {}, exporting ? "en" : "th")
? <ExportText message="lateGame" />
: group.name === "Early Game"
? exportText("earlyGame", {}, exporting ? "en" : "th")
? <ExportText message="earlyGame" />
: group.name}
</CardTitle>
</CardHeader>
@@ -787,7 +772,7 @@ function ArtifactSectionContent({
) : null}
</div>
<p className="text-2xl font-semibold text-primary">
{Number(row.percent) === 0 ? "-%" : metric(row.percent, exporting ? "en-US" : undefined)}
{Number(row.percent) === 0 ? "-%" : metric(row.percent)}
</p>
</div>
))}
@@ -806,7 +791,7 @@ function ArtifactSectionContent({
exporting ? (
profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
<div key={groupIndex} data-artifact-export-section="target-stats" data-target-condition={groupIndex}>
<p className="mb-2 text-sm font-medium">{exportText("minimumStats", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium"><ExportText message="minimumStats" /></p>
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
<div className="flex flex-col gap-1">
{group.rows.map((target, index) => (
@@ -830,7 +815,7 @@ function ArtifactSectionContent({
) : null}
<span>
{statLabel(
normalizeTargetStat(target.stat) ?? target.stat, exporting ? "en" : "th",
normalizeTargetStat(target.stat) ?? target.stat,
)}
</span>
</span>
@@ -841,10 +826,10 @@ function ArtifactSectionContent({
</div>
))
) : (
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group, index) => group.name || `Condition ${index + 1}`)}>
<ConditionSwitcher label="เลือกเงื่อนไขค่าสถานะเป้าหมาย" labels={profile.targetConditions.filter((group) => group.rows.length).map((group) => group.name || "")}>
{profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => (
<div key={groupIndex}>
<p className="mb-2 text-sm font-medium">{exportText("minimumStats", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium"><ExportText message="minimumStats" /></p>
{group.note ? <p data-export-note className="mb-2 whitespace-pre-wrap text-sm text-muted-foreground">{group.note}</p> : null}
<div className="flex flex-col gap-1">
{group.rows.map((target, index) => (
@@ -865,7 +850,7 @@ function ArtifactSectionContent({
{profile.talents.length ? (
<div data-artifact-export-section="talents">
<p className="mb-2 text-sm font-medium">
{exportText("talents", {}, exporting ? "en" : "th")}
<ExportText message="talents" />
</p>
<div
className={cn(
@@ -915,7 +900,7 @@ function ArtifactSectionContent({
</div>
<Badge variant="secondary">
{talent.level
? exportText("level", { level: talent.level }, exporting ? "en" : "th")
? <ExportText message="level" params={{ level: talent.level }} />
: "-"}
</Badge>
<p
@@ -932,11 +917,11 @@ function ArtifactSectionContent({
exporting ? "text-xs" : "text-[11px] sm:text-xs",
)}
>
{exportText(talent.type === "E" ? "skill" : talent.type === "Q" ? "burst" : "normalAttack", {}, exporting ? "en" : "th")}
<ExportText message={talent.type === "E" ? "skill" : talent.type === "Q" ? "burst" : "normalAttack"} />
</p>
</div>
{index < talents.length - 1 ? (
<span
<Localized attributes={{ "aria-label": talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : talent.relationToNext === "eq" ? "เท่ากับ" : "มากกว่า" }}><span
className={cn(
"font-semibold",
exporting
@@ -952,7 +937,7 @@ function ArtifactSectionContent({
}
>
{talentRelationSymbol(talent.relationToNext)}
</span>
</span></Localized>
) : null}
</div>
);
@@ -963,7 +948,7 @@ function ArtifactSectionContent({
) : null}
{profile.note ? (
<div data-export-note data-artifact-export-section="note" className="pt-3">
<p className="mb-2 text-sm font-medium">{exportText("note", {}, exporting ? "en" : "th")}</p>
<p className="mb-2 text-sm font-medium"><ExportText message="note" /></p>
<p className="text-sm leading-6 text-muted-foreground">
{profile.note}
</p>
@@ -978,8 +963,8 @@ function ArtifactSectionContent({
);
}
function statLabel(value: string, locale: Locale = "th"): string {
return exportStatLabel(value, locale);
function statLabel(value: string) {
return <StatText value={value} />;
}
function ArtifactStat({
@@ -990,7 +975,7 @@ function ArtifactStat({
elementIconUrls,
}: {
iconSrc: string;
label: string;
label: ReactNode;
values: string[];
exporting?: boolean;
elementIconUrls: Record<string, string>;
@@ -1080,7 +1065,7 @@ function ArtifactStatValue({
className="size-5 shrink-0 object-contain brightness-0 invert"
/>
) : null}
<span className="break-words">{statLabel(value, exporting ? "en" : "th")}</span>
<span className="break-words">{statLabel(value)}</span>
</span>
);
}
@@ -1093,8 +1078,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
<section className="flex flex-col gap-6">
<header className="flex flex-col items-center gap-3 text-center">
<p className="text-sm font-medium text-primary">
กลุ่มดาวที่แนะนำ
</p>
<Text message="กลุ่มดาวที่แนะนำ" /></p>
<div className="flex items-center justify-center gap-3">
<Image
src="/icon/nav/Constellations.webp"
@@ -1105,8 +1089,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
className="size-14 shrink-0 object-contain"
/>
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">
Constellations
</h1>
<Text message="Constellations" /></h1>
</div>
{note ? (
<p className="max-w-3xl whitespace-pre-wrap text-muted-foreground">
@@ -1114,7 +1097,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p>
) : null}
</header>
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, index) => group.name || `Condition ${index + 1}`)}>
<ConditionSwitcher label="เลือกเงื่อนไขกลุ่มดาว" labels={guide.constellationGroups.filter((group) => group.rows.length > 0).map((group) => group.name || "")}>
{guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
<div key={group.id} className="flex flex-col gap-4">
<div>
@@ -1130,7 +1113,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
/>
</GuideImageExport>
</div>
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold">{group.name || `Condition ${groupIndex + 1}`}</h2> : null}
{groups.length > 1 ? <h2 className="font-heading text-2xl font-semibold"><ConditionLabel name={group.name} index={groupIndex + 1} /></h2> : null}
{group.note ? <p className="whitespace-pre-wrap">{group.note}</p> : null}
<div className="grid gap-4 md:grid-cols-2">
{group.rows.map((row) => (
@@ -1155,7 +1138,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{metric(row.cumulativePercent)}
</CardTitle>
<CardDescription className="text-base font-medium text-white">
C{row.level} · {row.name}
<Text message="C" />{row.level} · {row.name}
</CardDescription>
</div>
</CardHeader>
@@ -1176,8 +1159,8 @@ function TeamSection({ guide }: { guide: PublicGuide }) {
return (
<section className="flex flex-col gap-6">
<header className="text-center">
<p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p>
<h1 className="font-heading text-4xl font-semibold">คำนวณทีม</h1>
<p className="text-sm font-medium text-primary"><Text message="คำนวณความเสียหาย" /></p>
<h1 className="font-heading text-4xl font-semibold"><Text message="คำนวณทีม" /></h1>
</header>
<TeamSearch groups={guide.teamGroups} />
</section>
@@ -1223,7 +1206,7 @@ function ExtraSectionContent({
/>
) : null}
<header className="relative z-10 flex flex-col items-center gap-3 text-center">
<p className="text-sm font-medium text-primary">{exportText("extraGuide", {}, exporting ? "en" : "th")}</p>
<p className="text-sm font-medium text-primary"><ExportText message="extraGuide" /></p>
<h1 className="font-heading text-4xl font-semibold">{extra.title}</h1>
{action}
</header>
@@ -1258,7 +1241,7 @@ function ExtraSectionContent({
{row.catalogItems.length ? (
<CardContent className="grid grid-cols-2 gap-2 pt-5">
{row.catalogItems.map((item) => (
<a
<Localized key={`${item.kind}:${item.key}`} attributes={{ "aria-label": { message: "เปิดข้อมูล {name} บน Lunaris", params: { name: item.name } } }}><a
key={`${item.kind}:${item.key}`}
href={lunarisItemUrl(item.kind, item.key)}
target="_blank"
@@ -1274,10 +1257,10 @@ function ExtraSectionContent({
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.name}</p>
{!item.available ? <Badge variant="secondary">{exportText("missingItem", {}, exporting ? "en" : "th")}</Badge> : null}
{!item.available ? <Badge variant="secondary"><ExportText message="missingItem" /></Badge> : null}
</div>
<ArrowUpRightIcon aria-hidden="true" className="ml-auto shrink-0" />
</a>
</a></Localized>
))}
</CardContent>
) : null}
@@ -1286,7 +1269,7 @@ function ExtraSectionContent({
<CardDescription>
<RichTextContent html={normalizeExtraRichText(row.body, row.highlights)} />
</CardDescription>
{row.percent ? <Badge>{metric(row.percent, exporting ? "en-US" : undefined)}</Badge> : null}
{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}
</CardHeader>
{row.sheet ? (
<CardContent