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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user