Add per-category translations and improve preferences panel preview

Introduce category-level name/description translations in the cookie
banner i18n flow. Seed default translations for fr/de/es on banner
creation, parse them from the stored JSON, and manage them via
react-hook-form Controllers instead of a manual ref/callback pattern.
Enhance the panel preview with category descriptions and all three
action buttons.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 20:05:04 +04:00
parent 165b9ad9d3
commit cd824c2c55
7 changed files with 327 additions and 82 deletions

View File

@@ -41,6 +41,8 @@ export const cookieBannerTranslationsPageQuery = graphql`
node { node {
id id
name name
slug
description
kind kind
} }
} }
@@ -79,30 +81,44 @@ export default function CookieBannerTranslationsPage({
t => t.language === selectedLanguage, t => t.language === selectedLanguage,
); );
const translationJson = useMemo(() => { const { uiStrings, categoryTranslations } = useMemo(() => {
if (!selectedTranslation) return null; if (!selectedTranslation) {
return { uiStrings: null, categoryTranslations: null };
}
try { try {
return JSON.parse(selectedTranslation.translations) as Record< const raw = JSON.parse(selectedTranslation.translations) as Record<string, unknown>;
string, const ui: Record<string, string> = {};
string let cats: Record<string, { name: string; description: string }> | null = null;
>;
for (const [k, v] of Object.entries(raw)) {
if (k === "categories" && typeof v === "object" && v !== null) {
cats = v as Record<string, { name: string; description: string }>;
} else if (typeof v === "string") {
ui[k] = v;
}
}
return { uiStrings: ui, categoryTranslations: cats };
} catch { } catch {
return null; return { uiStrings: null, categoryTranslations: null };
} }
}, [selectedTranslation]); }, [selectedTranslation]);
const categoryNames = useMemo( const categories = useMemo(
() => () =>
banner.categories.edges.map(e => ({ banner.categories.edges.map(e => ({
id: e.node.id,
name: e.node.name, name: e.node.name,
slug: e.node.slug,
description: e.node.description,
kind: e.node.kind, kind: e.node.kind,
})) ?? [], })),
[banner.categories], [banner.categories],
); );
const necessaryCategoryName = useMemo( const necessaryCategoryName = useMemo(
() => categoryNames.find(c => c.kind === "NECESSARY")?.name ?? "Necessary", () => categories.find(c => c.kind === "NECESSARY")?.name ?? "Necessary",
[categoryNames], [categories],
); );
return ( return (
@@ -129,9 +145,10 @@ export default function CookieBannerTranslationsPage({
key={selectedLanguage} key={selectedLanguage}
cookieBannerId={banner.id} cookieBannerId={banner.id}
language={selectedLanguage} language={selectedLanguage}
existingTranslations={translationJson} existingTranslations={uiStrings}
existingCategoryTranslations={categoryTranslations}
showBranding={banner.showBranding} showBranding={banner.showBranding}
categoryNames={categoryNames.map(c => c.name)} categories={categories}
necessaryCategoryName={necessaryCategoryName} necessaryCategoryName={necessaryCategoryName}
/> />

View File

@@ -12,19 +12,29 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
interface CategoryPreview {
name: string;
description: string;
isNecessary: boolean;
}
interface PanelPreviewProps { interface PanelPreviewProps {
panelTitle: string; panelTitle: string;
panelDescription: string; panelDescription: string;
buttonAcceptAll: string;
buttonRejectAll: string;
buttonSave: string; buttonSave: string;
categoryNames: string[]; categories: CategoryPreview[];
necessaryCategoryName: string; necessaryCategoryName: string;
} }
export function PanelPreview({ export function PanelPreview({
panelTitle, panelTitle,
panelDescription, panelDescription,
buttonAcceptAll,
buttonRejectAll,
buttonSave, buttonSave,
categoryNames, categories,
necessaryCategoryName, necessaryCategoryName,
}: PanelPreviewProps) { }: PanelPreviewProps) {
const descriptionParts = panelDescription.split( const descriptionParts = panelDescription.split(
@@ -46,7 +56,7 @@ export function PanelPreview({
lineHeight: 1.5, lineHeight: 1.5,
maxWidth: 380, maxWidth: 380,
width: "100%", width: "100%",
padding: "24px", padding: "24px 24px 12px 24px",
}} }}
> >
<p <p
@@ -79,9 +89,9 @@ export function PanelPreview({
</p> </p>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{categoryNames.map(name => ( {categories.map(cat => (
<div <div
key={name} key={cat.name}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -90,18 +100,32 @@ export function PanelPreview({
borderBottom: "1px solid var(--probo-border, #e0e0e0)", borderBottom: "1px solid var(--probo-border, #e0e0e0)",
}} }}
> >
<span style={{ fontWeight: 500 }}>{name}</span> <div style={{ display: "flex", flexDirection: "column", gap: 2, flex: 1, minWidth: 0 }}>
<span style={{ fontWeight: 500 }}>{cat.name}</span>
{cat.description && (
<span
style={{
fontSize: "calc(var(--probo-font-size, 14px) - 2px)",
color: "var(--probo-text-secondary, #555555)",
}}
>
{cat.description}
</span>
)}
</div>
<div <div
style={{ style={{
width: 36, width: 36,
height: 20, height: 20,
borderRadius: 10, borderRadius: 10,
background: background:
name === necessaryCategoryName cat.isNecessary
? "var(--probo-accent, #1a1a1a)" ? "var(--probo-accent, #1a1a1a)"
: "var(--probo-border, #e0e0e0)", : "var(--probo-border, #e0e0e0)",
position: "relative", position: "relative",
cursor: "default", cursor: "default",
flexShrink: 0,
marginLeft: 12,
}} }}
> >
<div <div
@@ -112,8 +136,7 @@ export function PanelPreview({
background: "var(--probo-bg, #ffffff)", background: "var(--probo-bg, #ffffff)",
position: "absolute", position: "absolute",
top: 2, top: 2,
left: left: cat.isNecessary ? 18 : 2,
name === necessaryCategoryName ? 18 : 2,
transition: "left 0.2s", transition: "left 0.2s",
}} }}
/> />
@@ -122,11 +145,19 @@ export function PanelPreview({
))} ))}
</div> </div>
<div style={{ marginTop: 20 }}> <div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
marginTop: 20,
paddingBottom: 12,
}}
>
<button <button
type="button" type="button"
style={{ style={{
padding: "8px 16px", padding: "8px 10px",
borderRadius: "var(--probo-btn-radius, 8px)", borderRadius: "var(--probo-btn-radius, 8px)",
border: "1px solid var(--probo-accent, #1a1a1a)", border: "1px solid var(--probo-accent, #1a1a1a)",
background: "var(--probo-accent, #1a1a1a)", background: "var(--probo-accent, #1a1a1a)",
@@ -136,7 +167,46 @@ export function PanelPreview({
fontWeight: 500, fontWeight: 500,
lineHeight: "normal", lineHeight: "normal",
cursor: "pointer", cursor: "pointer",
width: "100%", whiteSpace: "nowrap",
}}
>
{buttonAcceptAll}
</button>
<button
type="button"
style={{
padding: "8px 10px",
borderRadius: "var(--probo-btn-radius, 8px)",
border: "1px solid var(--probo-border, #e0e0e0)",
background:
"color-mix(in srgb, var(--probo-text, #1a1a1a) 8%, var(--probo-bg, #ffffff))",
color: "var(--probo-text, #1a1a1a)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
lineHeight: "normal",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{buttonRejectAll}
</button>
<button
type="button"
style={{
padding: "8px 10px",
borderRadius: "var(--probo-btn-radius, 8px)",
border: "none",
background: "transparent",
color: "var(--probo-accent, #1a1a1a)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
lineHeight: "normal",
cursor: "pointer",
whiteSpace: "nowrap",
textDecoration: "underline",
flex: 1,
}} }}
> >
{buttonSave} {buttonSave}

View File

@@ -18,15 +18,18 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { PanelPreview } from "./PanelPreview"; import { PanelPreview } from "./PanelPreview";
import { TRANSLATION_LABELS } from "./translationDefaults"; import { TRANSLATION_LABELS } from "./translationDefaults";
import type { TranslationFormValues } from "./TranslationEditor"; import type {
CategoryInfo,
TranslationFormValues,
} from "./TranslationEditor";
interface PanelTranslationSectionProps { interface PanelTranslationSectionProps {
categoryNames: string[]; categories: CategoryInfo[];
necessaryCategoryName: string; necessaryCategoryName: string;
} }
export function PanelTranslationSection({ export function PanelTranslationSection({
categoryNames, categories,
necessaryCategoryName, necessaryCategoryName,
}: PanelTranslationSectionProps) { }: PanelTranslationSectionProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
@@ -34,7 +37,21 @@ export function PanelTranslationSection({
const panelTitle = useWatch({ control, name: "panel_title" }); const panelTitle = useWatch({ control, name: "panel_title" });
const panelDescription = useWatch({ control, name: "panel_description" }); const panelDescription = useWatch({ control, name: "panel_description" });
const buttonAcceptAll = useWatch({ control, name: "button_accept_all" });
const buttonRejectAll = useWatch({ control, name: "button_reject_all" });
const buttonSave = useWatch({ control, name: "button_save" }); const buttonSave = useWatch({ control, name: "button_save" });
const categoryTranslations = useWatch({ control, name: "categories" });
const visibleCategories = categories.filter(c => c.kind !== "UNCATEGORISED");
const previewCategories = categories.map((c) => {
const translated = categoryTranslations?.[c.id];
return {
name: translated?.name || c.name,
description: translated?.description || c.description,
isNecessary: c.kind === "NECESSARY",
};
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -57,9 +74,6 @@ export function PanelTranslationSection({
render={({ field }) => ( render={({ field }) => (
<Field <Field
label={__(TRANSLATION_LABELS.panel_description)} label={__(TRANSLATION_LABELS.panel_description)}
// description={__(
// "Use {{necessary_category}} to insert the necessary category name.",
// )}
> >
<Textarea {...field} rows={3} /> <Textarea {...field} rows={3} />
</Field> </Field>
@@ -127,12 +141,53 @@ export function PanelTranslationSection({
<PanelPreview <PanelPreview
panelTitle={panelTitle} panelTitle={panelTitle}
panelDescription={panelDescription} panelDescription={panelDescription}
buttonAcceptAll={buttonAcceptAll}
buttonRejectAll={buttonRejectAll}
buttonSave={buttonSave} buttonSave={buttonSave}
categoryNames={categoryNames} categories={previewCategories}
necessaryCategoryName={necessaryCategoryName} necessaryCategoryName={necessaryCategoryName}
/> />
</div> </div>
</div> </div>
{visibleCategories.length > 0 && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-txt-secondary">
{__("Category names")}
</h4>
<div className="grid grid-cols-2 gap-4">
{visibleCategories.map(cat => (
<Card key={cat.id} className="border p-4 space-y-3">
<div className="text-sm text-txt-secondary">
{cat.name}
{" "}
<span className="text-txt-secondary/60">
{`(${cat.slug})`}
</span>
</div>
<Controller
control={control}
name={`categories.${cat.id}.name`}
render={({ field }) => (
<Field label={__("Translated name")}>
<Input {...field} placeholder={cat.name} />
</Field>
)}
/>
<Controller
control={control}
name={`categories.${cat.id}.description`}
render={({ field }) => (
<Field label={__("Translated description")}>
<Textarea {...field} placeholder={cat.description} rows={2} />
</Field>
)}
/>
</Card>
))}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -18,21 +18,40 @@ interface PlaceholderPreviewProps {
categoryName: string; categoryName: string;
} }
function LockIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function PlaceholderPreview({ export function PlaceholderPreview({
placeholderText, placeholderText,
placeholderButton, placeholderButton,
categoryName, categoryName,
}: PlaceholderPreviewProps) { }: PlaceholderPreviewProps) {
const displayText = placeholderText.replace("{{category}}", categoryName); const parts = placeholderText.split("{{category}}");
const hasPlaceholder = parts.length > 1;
return ( return (
<div <div
style={{ style={{
background: "var(--probo-bg, #ffffff)", background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)", color: "var(--probo-text-secondary, #555555)",
borderRadius: "var(--probo-radius, 12px)", borderRadius: "var(--probo-radius, 12px)",
boxShadow: border: "1px dashed var(--probo-border, #e0e0e0)",
"var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
fontFamily: fontFamily:
"var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)", "var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
fontSize: "var(--probo-font-size, 14px)", fontSize: "var(--probo-font-size, 14px)",
@@ -43,47 +62,37 @@ export function PlaceholderPreview({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: 16, gap: 12,
textAlign: "center", textAlign: "center",
minHeight: 120,
boxSizing: "border-box",
}} }}
> >
<div <span style={{ color: "var(--probo-text-secondary, #555555)" }}>
style={{ <LockIcon />
width: 48, </span>
height: 48, <p style={{ margin: 0 }}>
borderRadius: "50%", {hasPlaceholder
background: ? (
"color-mix(in srgb, var(--probo-text, #1a1a1a) 8%, var(--probo-bg, #ffffff))", <>
display: "flex", {parts[0]}
alignItems: "center", <strong>{categoryName}</strong>
justifyContent: "center", {parts[1]}
fontSize: 20, </>
}} )
> : placeholderText.replace("{{category}}", categoryName)}
🍪
</div>
<p
style={{
color: "var(--probo-text-secondary, #555555)",
margin: 0,
}}
>
{displayText}
</p> </p>
<button <button
type="button" type="button"
style={{ style={{
padding: "8px 16px", background: "none",
borderRadius: "var(--probo-btn-radius, 8px)", border: "none",
border: "1px solid var(--probo-border, #e0e0e0)", color: "var(--probo-accent, #1a1a1a)",
background: textDecoration: "underline",
"color-mix(in srgb, var(--probo-text, #1a1a1a) 8%, var(--probo-bg, #ffffff))",
color: "var(--probo-text, #1a1a1a)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
lineHeight: "normal",
cursor: "pointer", cursor: "pointer",
fontFamily: "inherit",
fontSize: "inherit",
padding: 0,
}} }}
> >
{placeholderButton} {placeholderButton}

View File

@@ -25,7 +25,7 @@ import type { TranslationEditorMutation } from "#/__generated__/core/Translation
import { BannerTranslationSection } from "./BannerTranslationSection"; import { BannerTranslationSection } from "./BannerTranslationSection";
import { PanelTranslationSection } from "./PanelTranslationSection"; import { PanelTranslationSection } from "./PanelTranslationSection";
import { PlaceholderTranslationSection } from "./PlaceholderTranslationSection"; import { PlaceholderTranslationSection } from "./PlaceholderTranslationSection";
import { ALL_KEYS } from "./translationDefaults"; import { ALL_KEYS, type TranslationKey } from "./translationDefaults";
const upsertTranslationMutation = graphql` const upsertTranslationMutation = graphql`
mutation TranslationEditorMutation( mutation TranslationEditorMutation(
@@ -49,14 +49,30 @@ const upsertTranslationMutation = graphql`
} }
`; `;
export type TranslationFormValues = Record<string, string>; export type TranslationFormValues = Record<TranslationKey, string> & {
categories: CategoryTranslations;
};
export interface CategoryInfo {
id: string;
name: string;
slug: string;
description: string;
kind: string;
}
export type CategoryTranslations = Record<
string,
{ name: string; description: string }
>;
interface TranslationEditorProps { interface TranslationEditorProps {
cookieBannerId: string; cookieBannerId: string;
language: string; language: string;
existingTranslations: Record<string, string> | null; existingTranslations: Record<string, string> | null;
existingCategoryTranslations: CategoryTranslations | null;
showBranding: boolean; showBranding: boolean;
categoryNames: string[]; categories: CategoryInfo[];
necessaryCategoryName: string; necessaryCategoryName: string;
} }
@@ -64,8 +80,9 @@ export function TranslationEditor({
cookieBannerId, cookieBannerId,
language, language,
existingTranslations, existingTranslations,
existingCategoryTranslations,
showBranding, showBranding,
categoryNames, categories,
necessaryCategoryName, necessaryCategoryName,
}: TranslationEditorProps) { }: TranslationEditorProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
@@ -75,24 +92,51 @@ export function TranslationEditor({
= useMutation<TranslationEditorMutation>(upsertTranslationMutation); = useMutation<TranslationEditorMutation>(upsertTranslationMutation);
const defaultValues = useMemo(() => { const defaultValues = useMemo(() => {
const values: Record<string, string> = {}; const translations: Record<string, string> = {};
for (const key of ALL_KEYS) { for (const key of ALL_KEYS) {
values[key] = existingTranslations?.[key] ?? ""; translations[key] = existingTranslations?.[key] ?? "";
} }
return values;
}, [existingTranslations]); const catDefaults: CategoryTranslations = {};
for (const cat of categories) {
if (cat.kind === "UNCATEGORISED") continue;
const existing = existingCategoryTranslations?.[cat.id];
catDefaults[cat.id] = {
name: existing?.name ?? "",
description: existing?.description ?? "",
};
}
return {
...translations,
categories: catDefaults,
} as TranslationFormValues;
}, [existingTranslations, existingCategoryTranslations, categories]);
const methods = useForm<TranslationFormValues>({ const methods = useForm<TranslationFormValues>({
defaultValues, defaultValues,
}); });
const handleSave = (formData: TranslationFormValues) => { const handleSave = (formData: TranslationFormValues) => {
const { categories: catTranslations, ...translations } = formData;
const payload: Record<string, unknown> = { ...translations };
const nonEmpty: CategoryTranslations = {};
for (const [id, entry] of Object.entries(catTranslations)) {
if (entry.name || entry.description) {
nonEmpty[id] = entry;
}
}
if (Object.keys(nonEmpty).length > 0) {
payload.categories = nonEmpty;
}
upsertTranslation({ upsertTranslation({
variables: { variables: {
input: { input: {
cookieBannerId, cookieBannerId,
language, language,
translations: JSON.stringify(formData), translations: JSON.stringify(payload),
}, },
}, },
onCompleted() { onCompleted() {
@@ -123,11 +167,11 @@ export function TranslationEditor({
> >
<BannerTranslationSection showBranding={showBranding} /> <BannerTranslationSection showBranding={showBranding} />
<PanelTranslationSection <PanelTranslationSection
categoryNames={categoryNames} categories={categories}
necessaryCategoryName={necessaryCategoryName} necessaryCategoryName={necessaryCategoryName}
/> />
<PlaceholderTranslationSection <PlaceholderTranslationSection
exampleCategoryName={categoryNames[1] ?? categoryNames[0] ?? "Analytics"} exampleCategoryName={categories[1]?.name ?? categories[0]?.name ?? "Analytics"}
/> />
<Button type="submit" disabled={isUpserting}> <Button type="submit" disabled={isUpserting}>

View File

@@ -30,6 +30,33 @@ var defaultCategories = []struct {
{"Uncategorised", "uncategorised", "Cookies that have not been assigned to a category yet.", coredata.CookieCategoryKindUncategorised, 4}, {"Uncategorised", "uncategorised", "Cookies that have not been assigned to a category yet.", coredata.CookieCategoryKindUncategorised, 4},
} }
var defaultCategoryTranslationsByLanguage = map[string]map[string]struct {
Name string
Description string
}{
"fr": {
"necessary": {"Nécessaires", "Cookies essentiels au bon fonctionnement du site web."},
"analytics": {"Analytiques", "Cookies qui aident à comprendre comment les visiteurs interagissent avec le site web."},
"advertising": {"Publicitaires", "Cookies utilisés pour diffuser des publicités pertinentes et suivre les campagnes."},
"functional": {"Fonctionnels", "Cookies qui permettent des fonctionnalités améliorées et la personnalisation."},
"uncategorised": {"Non classés", "Cookies qui n'ont pas encore été assignés à une catégorie."},
},
"de": {
"necessary": {"Notwendige", "Wesentliche Cookies, die für das ordnungsgemäße Funktionieren der Website erforderlich sind."},
"analytics": {"Analytische", "Cookies, die helfen zu verstehen, wie Besucher mit der Website interagieren."},
"advertising": {"Werbe", "Cookies, die verwendet werden, um relevante Werbung zu liefern und Kampagnen zu verfolgen."},
"functional": {"Funktionale", "Cookies, die erweiterte Funktionalität und Personalisierung ermöglichen."},
"uncategorised": {"Nicht kategorisiert", "Cookies, die noch keiner Kategorie zugeordnet wurden."},
},
"es": {
"necessary": {"Necesarias", "Cookies esenciales necesarias para el correcto funcionamiento del sitio web."},
"analytics": {"Analíticas", "Cookies que ayudan a entender cómo los visitantes interactúan con el sitio web."},
"advertising": {"Publicitarias", "Cookies utilizadas para mostrar anuncios relevantes y rastrear campañas."},
"functional": {"Funcionales", "Cookies que permiten funcionalidades mejoradas y personalización."},
"uncategorised": {"Sin categoría", "Cookies que aún no han sido asignadas a una categoría."},
},
}
var defaultUIStringsByLanguage = map[string]map[string]string{ var defaultUIStringsByLanguage = map[string]map[string]string{
"en": { "en": {
"banner_title": "Cookie Preferences", "banner_title": "Cookie Preferences",

View File

@@ -531,6 +531,7 @@ func (s *Service) CreateCookieBanner(
return fmt.Errorf("cannot insert cookie banner: %w", err) return fmt.Errorf("cannot insert cookie banner: %w", err)
} }
slugToGID := make(map[string]gid.GID, len(defaultCategories))
for _, dc := range defaultCategories { for _, dc := range defaultCategories {
category := &coredata.CookieCategory{ category := &coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType), ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
@@ -548,12 +549,34 @@ func (s *Service) CreateCookieBanner(
if err := category.Insert(ctx, tx, scope); err != nil { if err := category.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert default cookie category %q: %w", dc.Name, err) return fmt.Errorf("cannot insert default cookie category %q: %w", dc.Name, err)
} }
slugToGID[dc.Slug] = category.ID
} }
for lang, uiStrings := range defaultUIStringsByLanguage { for lang, uiStrings := range defaultUIStringsByLanguage {
translationsJSON, err := json.Marshal(uiStrings) blob := make(map[string]any, len(uiStrings)+1)
for k, v := range uiStrings {
blob[k] = v
}
if catDefaults, ok := defaultCategoryTranslationsByLanguage[lang]; ok {
catMap := make(map[string]map[string]string, len(catDefaults))
for slug, ct := range catDefaults {
if id, exists := slugToGID[slug]; exists {
catMap[id.String()] = map[string]string{
"name": ct.Name,
"description": ct.Description,
}
}
}
if len(catMap) > 0 {
blob["categories"] = catMap
}
}
translationsJSON, err := json.Marshal(blob)
if err != nil { if err != nil {
return fmt.Errorf("cannot marshal default UI strings for %s: %w", lang, err) return fmt.Errorf("cannot marshal default translations for %s: %w", lang, err)
} }
translation := &coredata.CookieBannerTranslation{ translation := &coredata.CookieBannerTranslation{