Fix PR review comments on cookie banner i18n

Address locale normalization for region-tagged values, guard
language detection for non-DOM runtimes, validate DefaultLanguage
on update, pass translated texts through the deactivation flow,
handle slug collisions in migration, add organizations FK, fix
consent migration from name-keyed to slug-keyed data, render all
template placeholders in previews, and wrap helper text for i18n.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 23:41:37 +04:00
parent fa7a9c96c1
commit 2b6f131f43
17 changed files with 89 additions and 55 deletions

View File

@@ -59,7 +59,10 @@ export function EditCategoryForm({
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
onClick={() => onSave(editName, editSlug, editDescription)} onClick={() => {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(editSlug)) return;
onSave(editName, editSlug, editDescription);
}}
disabled={isUpdating} disabled={isUpdating}
> >
{isUpdating ? __("Saving...") : __("Save")} {isUpdating ? __("Saving...") : __("Save")}

View File

@@ -68,11 +68,6 @@ export default function CookieBannerTranslationsPage({
const banner = data.node; const banner = data.node;
const existingLanguages = useMemo(
() => banner.translations.map(t => t.language),
[banner.translations],
);
const [selectedLanguage, setSelectedLanguage] = useState( const [selectedLanguage, setSelectedLanguage] = useState(
() => banner.defaultLanguage, () => banner.defaultLanguage,
); );
@@ -128,11 +123,7 @@ export default function CookieBannerTranslationsPage({
value={selectedLanguage} value={selectedLanguage}
onValueChange={setSelectedLanguage} onValueChange={setSelectedLanguage}
> >
{SUPPORTED_LANGUAGES.filter( {SUPPORTED_LANGUAGES.map(l => (
l =>
existingLanguages.includes(l.code)
|| l.code === selectedLanguage,
).map(l => (
<Option key={l.code} value={l.code}> <Option key={l.code} value={l.code}>
{l.label} {l.label}
{l.code === banner.defaultLanguage ? ` (${__("default")})` : ""} {l.code === banner.defaultLanguage ? ` (${__("default")})` : ""}

View File

@@ -28,7 +28,7 @@ function interpolateDescription(
description: string, description: string,
linkText: string, linkText: string,
): string { ): string {
return description.replace( return description.replaceAll(
"{{privacy_policy_link}}", "{{privacy_policy_link}}",
linkText, linkText,
); );
@@ -79,9 +79,10 @@ export function BannerPreview({
}} }}
> >
{hasPlaceholder {hasPlaceholder
? ( ? descriptionParts.map((part, i) => (
<> <span key={i}>
{descriptionParts[0]} {part}
{i < descriptionParts.length - 1 && (
<a <a
href="#" href="#"
onClick={e => e.preventDefault()} onClick={e => e.preventDefault()}
@@ -92,9 +93,9 @@ export function BannerPreview({
> >
{privacyPolicyLinkText} {privacyPolicyLinkText}
</a> </a>
{descriptionParts[1]} )}
</> </span>
) ))
: ( : (
interpolateDescription(bannerDescription, privacyPolicyLinkText) interpolateDescription(bannerDescription, privacyPolicyLinkText)
)} )}

View File

@@ -44,6 +44,12 @@ export function PanelTranslationSection({
const visibleCategories = categories.filter(c => c.kind !== "UNCATEGORISED"); const visibleCategories = categories.filter(c => c.kind !== "UNCATEGORISED");
const translatedNecessaryName = (() => {
const necessaryCat = categories.find(c => c.kind === "NECESSARY");
if (!necessaryCat) return necessaryCategoryName;
return categoryTranslations?.[necessaryCat.id]?.name || necessaryCategoryName;
})();
const previewCategories = categories.map((c) => { const previewCategories = categories.map((c) => {
const translated = categoryTranslations?.[c.id]; const translated = categoryTranslations?.[c.id];
return { return {
@@ -75,7 +81,7 @@ export function PanelTranslationSection({
<Field <Field
label={__(TRANSLATION_LABELS.panel_description)} label={__(TRANSLATION_LABELS.panel_description)}
> >
<p className="text-xs text-txt-secondary mb-2">{"Use {{necessary_category}} to refer to the required cookies category name."}</p> <p className="text-xs text-txt-secondary mb-2">{__("Use {{necessary_category}} to refer to the required cookies category name.")}</p>
<Textarea {...field} rows={3} /> <Textarea {...field} rows={3} />
</Field> </Field>
)} )}
@@ -146,7 +152,7 @@ export function PanelTranslationSection({
buttonRejectAll={buttonRejectAll} buttonRejectAll={buttonRejectAll}
buttonSave={buttonSave} buttonSave={buttonSave}
categories={previewCategories} categories={previewCategories}
necessaryCategoryName={necessaryCategoryName} necessaryCategoryName={translatedNecessaryName}
/> />
</div> </div>
</div> </div>

View File

@@ -73,14 +73,13 @@ export function PlaceholderPreview({
</span> </span>
<p style={{ margin: 0 }}> <p style={{ margin: 0 }}>
{hasPlaceholder {hasPlaceholder
? ( ? parts.map((part, i) => (
<> <span key={i}>
{parts[0]} {part}
<strong>{categoryName}</strong> {i < parts.length - 1 && <strong>{categoryName}</strong>}
{parts[1]} </span>
</> ))
) : placeholderText}
: placeholderText.replace("{{category}}", categoryName)}
</p> </p>
<button <button
type="button" type="button"

View File

@@ -51,7 +51,7 @@ export function PlaceholderTranslationSection({
<Field <Field
label={__(TRANSLATION_LABELS.placeholder_text)} label={__(TRANSLATION_LABELS.placeholder_text)}
> >
<p className="text-xs text-txt-secondary mb-2">{"Use {{category}} to refer to the content category."}</p> <p className="text-xs text-txt-secondary mb-2">{__("Use {{category}} to refer to the content category.")}</p>
<Input {...field} /> <Input {...field} />
</Field> </Field>
)} )}

View File

@@ -302,7 +302,7 @@ function deactivateScript(el: HTMLScriptElement): void {
el.parentNode!.replaceChild(replacement, el); el.parentNode!.replaceChild(replacement, el);
} }
function deactivateElement(el: Element, label?: string): void { function deactivateElement(el: Element, label?: string, texts?: BannerTexts): void {
const category = el.getAttribute(ATTR_ACTIVATED); const category = el.getAttribute(ATTR_ACTIVATED);
const src = el.getAttribute("src"); const src = el.getAttribute("src");
@@ -323,7 +323,7 @@ function deactivateElement(el: Element, label?: string): void {
el.removeAttribute(ATTR_ACTIVATED); el.removeAttribute(ATTR_ACTIVATED);
if (category && VISUAL_TAGS.has(el.tagName)) { if (category && VISUAL_TAGS.has(el.tagName)) {
createPlaceholder(el, category, label); createPlaceholder(el, category, label, texts);
} }
} }
@@ -332,6 +332,7 @@ export function deactivateElements(
consentData: Record<string, boolean>, consentData: Record<string, boolean>,
categoryCookies: Record<string, string[]>, categoryCookies: Record<string, string[]>,
categoryLabels: Record<string, string>, categoryLabels: Record<string, string>,
texts?: BannerTexts,
): void { ): void {
const elements = document.querySelectorAll(`[${ATTR_ACTIVATED}]`); const elements = document.querySelectorAll(`[${ATTR_ACTIVATED}]`);
const cookiesToRemove = new Set<string>(); const cookiesToRemove = new Set<string>();
@@ -345,7 +346,7 @@ export function deactivateElements(
if (el instanceof HTMLScriptElement) { if (el instanceof HTMLScriptElement) {
deactivateScript(el); deactivateScript(el);
} else { } else {
deactivateElement(el, categoryLabels[category]); deactivateElement(el, categoryLabels[category], texts);
} }
const cookies = categoryCookies[category]; const cookies = categoryCookies[category];

View File

@@ -250,7 +250,7 @@ export class CookieBannerClient {
} }
const texts = this.config.texts; const texts = this.config.texts;
deactivateElements(consentData, categoryCookies, categoryLabels); deactivateElements(consentData, categoryCookies, categoryLabels, texts);
activateElements(consentData); activateElements(consentData);
addPlaceholders(consentData, categoryLabels, texts); addPlaceholders(consentData, categoryLabels, texts);
if (this.observer) { if (this.observer) {

View File

@@ -110,8 +110,8 @@ export class ProboCookieBannerRoot extends ProboElement implements ProboRootElem
for (const cat of config.categories) { for (const cat of config.categories) {
if (cat.kind === "NECESSARY") { if (cat.kind === "NECESSARY") {
draft[cat.slug] = true; draft[cat.slug] = true;
} else if (existing && cat.slug in existing) { } else if (existing && (cat.slug in existing || cat.name in existing)) {
draft[cat.slug] = existing[cat.slug]; draft[cat.slug] = existing[cat.slug] ?? existing[cat.name];
} else { } else {
draft[cat.slug] = config.consent_mode === "OPT_OUT"; draft[cat.slug] = config.consent_mode === "OPT_OUT";
} }

View File

@@ -29,6 +29,15 @@ export class ProboSettingsButton extends HTMLElement {
return ["position", "aria-settings-label"]; return ["position", "aria-settings-label"];
} }
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void {
if (name === "aria-settings-label") {
const btn = this.shadow.querySelector("button");
if (btn && newValue) {
btn.setAttribute("aria-label", newValue);
}
}
}
private get position(): string { private get position(): string {
return this.getAttribute("position") ?? "bottom-left"; return this.getAttribute("position") ?? "bottom-left";
} }

View File

@@ -16,14 +16,20 @@ export interface BannerTexts {
[key: string]: string; [key: string]: string;
} }
export function detectLanguage(explicit?: string): string { function normalizeLocale(locale: string): string {
if (explicit) return explicit; return locale.split("-")[0].toLowerCase();
}
export function detectLanguage(explicit?: string): string {
if (explicit) return normalizeLocale(explicit);
if (typeof document !== "undefined" && document.documentElement) {
const htmlLang = document.documentElement.lang; const htmlLang = document.documentElement.lang;
if (htmlLang) return htmlLang.split("-")[0]; if (htmlLang) return normalizeLocale(htmlLang);
}
if (typeof navigator !== "undefined" && navigator.language) { if (typeof navigator !== "undefined" && navigator.language) {
return navigator.language.split("-")[0]; return normalizeLocale(navigator.language);
} }
return ""; return "";

View File

@@ -40,7 +40,7 @@ if (script) {
const lang = script.getAttribute("data-lang"); const lang = script.getAttribute("data-lang");
if (lang) { if (lang) {
el.setAttribute("lang", lang); el.setAttribute("lang", lang.split("-")[0].toLowerCase());
} }
document.body.appendChild(el); document.body.appendChild(el);

View File

@@ -170,7 +170,7 @@ export class ProboThemedBanner extends HTMLElement {
this.shadow.querySelectorAll("[data-text]").forEach(el => { this.shadow.querySelectorAll("[data-text]").forEach(el => {
const key = el.getAttribute("data-text")!; const key = el.getAttribute("data-text")!;
const raw = texts[key]; const raw = texts[key] ?? "";
if (!raw) return; if (!raw) return;
if (key === "banner_description") { if (key === "banner_description") {
@@ -190,7 +190,7 @@ export class ProboThemedBanner extends HTMLElement {
this.shadow.querySelectorAll("[data-aria-text]").forEach(el => { this.shadow.querySelectorAll("[data-aria-text]").forEach(el => {
const key = el.getAttribute("data-aria-text")!; const key = el.getAttribute("data-aria-text")!;
const raw = texts[key]; const raw = texts[key] ?? el.getAttribute("aria-label") ?? "";
if (raw) el.setAttribute("aria-label", raw); if (raw) el.setAttribute("aria-label", raw);
}); });

View File

@@ -16,6 +16,8 @@ package cookiebanner
import "go.probo.inc/probo/pkg/coredata" import "go.probo.inc/probo/pkg/coredata"
var SupportedLanguages = []string{"en", "fr", "de", "es"}
var defaultCategories = []struct { var defaultCategories = []struct {
Name string Name string
Slug string Slug string

View File

@@ -177,6 +177,7 @@ func (r *UpdateCookieBannerRequest) Validate() error {
v.Check(r.PrivacyPolicyURL, "privacy_policy_url", validator.URL()) v.Check(r.PrivacyPolicyURL, "privacy_policy_url", validator.URL())
v.Check(r.ConsentExpiryDays, "consent_expiry_days", validator.Min(1)) v.Check(r.ConsentExpiryDays, "consent_expiry_days", validator.Min(1))
v.Check(r.ConsentMode, "consent_mode", validator.OneOfSlice(coredata.CookieConsentModes())) v.Check(r.ConsentMode, "consent_mode", validator.OneOfSlice(coredata.CookieConsentModes()))
v.Check(r.DefaultLanguage, "default_language", validator.OneOfSlice(SupportedLanguages))
return v.Error() return v.Error()
} }

View File

@@ -18,7 +18,7 @@ ALTER TABLE cookie_banners ALTER COLUMN default_language DROP DEFAULT;
CREATE TABLE cookie_banner_translations ( CREATE TABLE cookie_banner_translations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL, organization_id TEXT NOT NULL REFERENCES organizations(id),
cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE, cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE,
language TEXT NOT NULL, language TEXT NOT NULL,
translations JSONB NOT NULL, translations JSONB NOT NULL,

View File

@@ -16,7 +16,22 @@ ALTER TABLE cookie_categories
ADD COLUMN slug TEXT NOT NULL DEFAULT ''; ADD COLUMN slug TEXT NOT NULL DEFAULT '';
UPDATE cookie_categories UPDATE cookie_categories
SET slug = LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9]+', '-', 'g'), '^-|-$', '', 'g')); SET slug = COALESCE(
NULLIF(LOWER(REGEXP_REPLACE(REGEXP_REPLACE(name, '[^a-zA-Z0-9]+', '-', 'g'), '^-|-$', '', 'g')), ''),
'category-' || SUBSTR(id, 1, 8)
);
WITH dupes AS (
SELECT id,
cookie_banner_id,
slug,
ROW_NUMBER() OVER (PARTITION BY cookie_banner_id, slug ORDER BY created_at) AS rn
FROM cookie_categories
)
UPDATE cookie_categories
SET slug = cookie_categories.slug || '-' || dupes.rn
FROM dupes
WHERE cookie_categories.id = dupes.id AND dupes.rn > 1;
ALTER TABLE cookie_categories ALTER COLUMN slug DROP DEFAULT; ALTER TABLE cookie_categories ALTER COLUMN slug DROP DEFAULT;