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 {
id
name
slug
description
kind
}
}
@@ -79,30 +81,44 @@ export default function CookieBannerTranslationsPage({
t => t.language === selectedLanguage,
);
const translationJson = useMemo(() => {
if (!selectedTranslation) return null;
const { uiStrings, categoryTranslations } = useMemo(() => {
if (!selectedTranslation) {
return { uiStrings: null, categoryTranslations: null };
}
try {
return JSON.parse(selectedTranslation.translations) as Record<
string,
string
>;
const raw = JSON.parse(selectedTranslation.translations) as Record<string, unknown>;
const ui: Record<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 {
return null;
return { uiStrings: null, categoryTranslations: null };
}
}, [selectedTranslation]);
const categoryNames = useMemo(
const categories = useMemo(
() =>
banner.categories.edges.map(e => ({
id: e.node.id,
name: e.node.name,
slug: e.node.slug,
description: e.node.description,
kind: e.node.kind,
})) ?? [],
})),
[banner.categories],
);
const necessaryCategoryName = useMemo(
() => categoryNames.find(c => c.kind === "NECESSARY")?.name ?? "Necessary",
[categoryNames],
() => categories.find(c => c.kind === "NECESSARY")?.name ?? "Necessary",
[categories],
);
return (
@@ -129,9 +145,10 @@ export default function CookieBannerTranslationsPage({
key={selectedLanguage}
cookieBannerId={banner.id}
language={selectedLanguage}
existingTranslations={translationJson}
existingTranslations={uiStrings}
existingCategoryTranslations={categoryTranslations}
showBranding={banner.showBranding}
categoryNames={categoryNames.map(c => c.name)}
categories={categories}
necessaryCategoryName={necessaryCategoryName}
/>

View File

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

View File

@@ -18,15 +18,18 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { PanelPreview } from "./PanelPreview";
import { TRANSLATION_LABELS } from "./translationDefaults";
import type { TranslationFormValues } from "./TranslationEditor";
import type {
CategoryInfo,
TranslationFormValues,
} from "./TranslationEditor";
interface PanelTranslationSectionProps {
categoryNames: string[];
categories: CategoryInfo[];
necessaryCategoryName: string;
}
export function PanelTranslationSection({
categoryNames,
categories,
necessaryCategoryName,
}: PanelTranslationSectionProps) {
const { __ } = useTranslate();
@@ -34,7 +37,21 @@ export function PanelTranslationSection({
const panelTitle = useWatch({ control, name: "panel_title" });
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 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 (
<div className="space-y-4">
@@ -57,9 +74,6 @@ export function PanelTranslationSection({
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.panel_description)}
// description={__(
// "Use {{necessary_category}} to insert the necessary category name.",
// )}
>
<Textarea {...field} rows={3} />
</Field>
@@ -127,12 +141,53 @@ export function PanelTranslationSection({
<PanelPreview
panelTitle={panelTitle}
panelDescription={panelDescription}
buttonAcceptAll={buttonAcceptAll}
buttonRejectAll={buttonRejectAll}
buttonSave={buttonSave}
categoryNames={categoryNames}
categories={previewCategories}
necessaryCategoryName={necessaryCategoryName}
/>
</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>
);
}

View File

@@ -18,21 +18,40 @@ interface PlaceholderPreviewProps {
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({
placeholderText,
placeholderButton,
categoryName,
}: PlaceholderPreviewProps) {
const displayText = placeholderText.replace("{{category}}", categoryName);
const parts = placeholderText.split("{{category}}");
const hasPlaceholder = parts.length > 1;
return (
<div
style={{
background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)",
color: "var(--probo-text-secondary, #555555)",
borderRadius: "var(--probo-radius, 12px)",
boxShadow:
"var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
border: "1px dashed var(--probo-border, #e0e0e0)",
fontFamily:
"var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
fontSize: "var(--probo-font-size, 14px)",
@@ -43,47 +62,37 @@ export function PlaceholderPreview({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
gap: 12,
textAlign: "center",
minHeight: 120,
boxSizing: "border-box",
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: "50%",
background:
"color-mix(in srgb, var(--probo-text, #1a1a1a) 8%, var(--probo-bg, #ffffff))",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 20,
}}
>
🍪
</div>
<p
style={{
color: "var(--probo-text-secondary, #555555)",
margin: 0,
}}
>
{displayText}
<span style={{ color: "var(--probo-text-secondary, #555555)" }}>
<LockIcon />
</span>
<p style={{ margin: 0 }}>
{hasPlaceholder
? (
<>
{parts[0]}
<strong>{categoryName}</strong>
{parts[1]}
</>
)
: placeholderText.replace("{{category}}", categoryName)}
</p>
<button
type="button"
style={{
padding: "8px 16px",
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",
background: "none",
border: "none",
color: "var(--probo-accent, #1a1a1a)",
textDecoration: "underline",
cursor: "pointer",
fontFamily: "inherit",
fontSize: "inherit",
padding: 0,
}}
>
{placeholderButton}

View File

@@ -25,7 +25,7 @@ import type { TranslationEditorMutation } from "#/__generated__/core/Translation
import { BannerTranslationSection } from "./BannerTranslationSection";
import { PanelTranslationSection } from "./PanelTranslationSection";
import { PlaceholderTranslationSection } from "./PlaceholderTranslationSection";
import { ALL_KEYS } from "./translationDefaults";
import { ALL_KEYS, type TranslationKey } from "./translationDefaults";
const upsertTranslationMutation = graphql`
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 {
cookieBannerId: string;
language: string;
existingTranslations: Record<string, string> | null;
existingCategoryTranslations: CategoryTranslations | null;
showBranding: boolean;
categoryNames: string[];
categories: CategoryInfo[];
necessaryCategoryName: string;
}
@@ -64,8 +80,9 @@ export function TranslationEditor({
cookieBannerId,
language,
existingTranslations,
existingCategoryTranslations,
showBranding,
categoryNames,
categories,
necessaryCategoryName,
}: TranslationEditorProps) {
const { __ } = useTranslate();
@@ -75,24 +92,51 @@ export function TranslationEditor({
= useMutation<TranslationEditorMutation>(upsertTranslationMutation);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
const translations: Record<string, string> = {};
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>({
defaultValues,
});
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({
variables: {
input: {
cookieBannerId,
language,
translations: JSON.stringify(formData),
translations: JSON.stringify(payload),
},
},
onCompleted() {
@@ -123,11 +167,11 @@ export function TranslationEditor({
>
<BannerTranslationSection showBranding={showBranding} />
<PanelTranslationSection
categoryNames={categoryNames}
categories={categories}
necessaryCategoryName={necessaryCategoryName}
/>
<PlaceholderTranslationSection
exampleCategoryName={categoryNames[1] ?? categoryNames[0] ?? "Analytics"}
exampleCategoryName={categories[1]?.name ?? categories[0]?.name ?? "Analytics"}
/>
<Button type="submit" disabled={isUpserting}>