Add cookie banner translations UI

Add default language select to the settings page and a new Translations
tab with react-hook-form-based editing of banner, preferences panel,
and placeholder UI strings with live previews.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 18:08:58 +04:00
parent fcd58b0f48
commit 879b6dd733
13 changed files with 1206 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import {
Badge,
Breadcrumb,
Button,
IconGlobe,
IconListStack,
IconPageTextLine,
IconSettingsGear2,
@@ -242,6 +243,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
<IconListStack size={20} />
{__("Cookies")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/translations`}>
<IconGlobe size={20} />
{__("Translations")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
<IconPageTextLine size={20} />
{__("JS / CSS snippets")}

View File

@@ -30,6 +30,7 @@ const bannerSettingsFormFragment = graphql`
privacyPolicyUrl
consentExpiryDays
consentMode
defaultLanguage
}
`;
@@ -43,6 +44,7 @@ const updateBannerMutation = graphql`
privacyPolicyUrl
consentExpiryDays
consentMode
defaultLanguage
latestVersion {
id
version
@@ -70,6 +72,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
const [consentExpiryDays, setConsentExpiryDays] = useState(String(banner.consentExpiryDays));
const [consentMode, setConsentMode] = useState(banner.consentMode);
const [defaultLanguage, setDefaultLanguage] = useState(banner.defaultLanguage);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -83,6 +86,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
privacyPolicyUrl,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode,
defaultLanguage,
},
},
onCompleted() {
@@ -111,7 +115,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
</Field>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>{__("Consent Expiry (days)")}</Label>
<Input
@@ -129,6 +133,15 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
<Option value="OPT_OUT">{__("Opt-out")}</Option>
</Select>
</div>
<div className="space-y-2">
<Label>{__("Default Language")}</Label>
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
<Option value="en">{__("English")}</Option>
<Option value="fr">{__("French")}</Option>
<Option value="de">{__("German")}</Option>
<Option value="es">{__("Spanish")}</Option>
</Select>
</div>
</div>
<Button type="submit" disabled={isUpdating}>

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Option, Select } from "@probo/ui";
import { useMemo, useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieBannerTranslationsPageQuery } from "#/__generated__/core/CookieBannerTranslationsPageQuery.graphql";
import { SUPPORTED_LANGUAGES } from "./_components/translationDefaults";
import { TranslationEditor } from "./_components/TranslationEditor";
export const cookieBannerTranslationsPageQuery = graphql`
query CookieBannerTranslationsPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
__typename
... on CookieBanner {
id
defaultLanguage
showBranding
translations {
id
language
translations
}
categories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) {
edges {
node {
id
name
kind
}
}
}
}
}
}
`;
interface CookieBannerTranslationsPageProps {
queryRef: PreloadedQuery<CookieBannerTranslationsPageQuery>;
}
export default function CookieBannerTranslationsPage({
queryRef,
}: CookieBannerTranslationsPageProps) {
const { __ } = useTranslate();
const data = usePreloadedQuery(cookieBannerTranslationsPageQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const banner = data.node;
const existingLanguages = useMemo(
() => banner.translations.map(t => t.language),
[banner.translations],
);
const [selectedLanguage, setSelectedLanguage] = useState(
() => banner.defaultLanguage,
);
const selectedTranslation = banner.translations.find(
t => t.language === selectedLanguage,
);
const translationJson = useMemo(() => {
if (!selectedTranslation) return null;
try {
return JSON.parse(selectedTranslation.translations) as Record<
string,
string
>;
} catch {
return null;
}
}, [selectedTranslation]);
const categoryNames = useMemo(
() =>
banner.categories.edges.map(e => ({
name: e.node.name,
kind: e.node.kind,
})) ?? [],
[banner.categories],
);
const necessaryCategoryName = useMemo(
() => categoryNames.find(c => c.kind === "NECESSARY")?.name ?? "Necessary",
[categoryNames],
);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Select
value={selectedLanguage}
onValueChange={setSelectedLanguage}
>
{SUPPORTED_LANGUAGES.filter(
l =>
existingLanguages.includes(l.code)
|| l.code === selectedLanguage,
).map(l => (
<Option key={l.code} value={l.code}>
{l.label}
{l.code === banner.defaultLanguage ? ` (${__("default")})` : ""}
</Option>
))}
</Select>
</div>
<TranslationEditor
key={selectedLanguage}
cookieBannerId={banner.id}
language={selectedLanguage}
existingTranslations={translationJson}
showBranding={banner.showBranding}
categoryNames={categoryNames.map(c => c.name)}
necessaryCategoryName={necessaryCategoryName}
/>
{selectedLanguage === banner.defaultLanguage && (
<p className="text-sm text-txt-secondary">
{__(
"This is the default language. These translations are shown when a visitor's language is not available.",
)}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { CookieBannerTranslationsPageQuery } from "#/__generated__/core/CookieBannerTranslationsPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import CookieBannerTranslationsPage, { cookieBannerTranslationsPageQuery } from "./CookieBannerTranslationsPage";
export default function CookieBannerTranslationsPageLoader() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
if (!cookieBannerId) {
throw new Error("missing cookieBannerId param");
}
const [queryRef, loadQuery] = useQueryLoader<CookieBannerTranslationsPageQuery>(cookieBannerTranslationsPageQuery);
useEffect(() => {
loadQuery({ cookieBannerId });
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerTranslationsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Logo } from "@probo/ui";
interface BannerPreviewProps {
bannerTitle: string;
bannerDescription: string;
buttonAcceptAll: string;
buttonRejectAll: string;
buttonCustomize: string;
privacyPolicyLinkText: string;
showBranding: boolean;
}
function interpolateDescription(
description: string,
linkText: string,
): string {
return description.replace(
"{{privacy_policy_link}}",
linkText,
);
}
export function BannerPreview({
bannerTitle,
bannerDescription,
buttonAcceptAll,
buttonRejectAll,
buttonCustomize,
privacyPolicyLinkText,
showBranding,
}: BannerPreviewProps) {
const descriptionParts = bannerDescription.split("{{privacy_policy_link}}");
const hasPlaceholder = descriptionParts.length > 1;
return (
<div
style={{
background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)",
borderRadius: "var(--probo-radius, 12px)",
boxShadow:
"var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
fontFamily:
"var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
fontSize: "var(--probo-font-size, 14px)",
lineHeight: 1.5,
maxWidth: 450,
width: "100%",
padding: "24px 24px 12px 24px",
}}
>
<p
style={{
fontSize: "calc(var(--probo-font-size, 14px) + 2px)",
fontWeight: 600,
margin: "0 0 8px",
}}
>
{bannerTitle}
</p>
<p
style={{
color: "var(--probo-text-secondary, #555555)",
margin: "0 0 20px",
}}
>
{hasPlaceholder
? (
<>
{descriptionParts[0]}
<a
href="#"
onClick={e => e.preventDefault()}
style={{
color: "var(--probo-accent, #1a1a1a)",
textDecoration: "underline",
}}
>
{privacyPolicyLinkText}
</a>
{descriptionParts[1]}
</>
)
: (
interpolateDescription(bannerDescription, privacyPolicyLinkText)
)}
</p>
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
paddingBottom: "12px",
}}
>
<span>
<button
type="button"
style={{
padding: "8px 10px",
borderRadius: "var(--probo-btn-radius, 8px)",
border: "1px solid var(--probo-accent, #1a1a1a)",
background: "var(--probo-accent, #1a1a1a)",
color: "var(--probo-accent-text, #ffffff)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
lineHeight: "normal",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{buttonAcceptAll}
</button>
</span>
<span>
<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>
</span>
<span>
<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",
}}
>
{buttonCustomize}
</button>
</span>
</div>
{showBranding && (
<div
style={{
textAlign: "center",
fontSize: "calc(var(--probo-font-size, 14px) - 2px)",
fontWeight: 400,
color: "var(--probo-text-secondary, #555555)",
}}
>
Privacy by
{" "}
<Logo withPicto className="inline h-3.5 align-[-3px]" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Card, Field, Input, Textarea } from "@probo/ui";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { BannerPreview } from "./BannerPreview";
import { TRANSLATION_LABELS } from "./translationDefaults";
import type { TranslationFormValues } from "./TranslationEditor";
interface BannerTranslationSectionProps {
showBranding: boolean;
}
export function BannerTranslationSection({
showBranding,
}: BannerTranslationSectionProps) {
const { __ } = useTranslate();
const { control } = useFormContext<TranslationFormValues>();
const bannerTitle = useWatch({ control, name: "banner_title" });
const bannerDescription = useWatch({ control, name: "banner_description" });
const buttonAcceptAll = useWatch({ control, name: "button_accept_all" });
const buttonRejectAll = useWatch({ control, name: "button_reject_all" });
const buttonCustomize = useWatch({ control, name: "button_customize" });
const privacyPolicyLinkText = useWatch({
control,
name: "privacy_policy_link_text",
});
return (
<div className="space-y-4">
<h3 className="font-medium text-lg">{__("Banner")}</h3>
<div className="grid grid-cols-2 gap-6">
<Card className="border p-4">
<div className="space-y-4">
<Controller
control={control}
name="banner_title"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.banner_title)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="banner_description"
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.banner_description)}
// description={__(
// "Use {{privacy_policy_link}} to insert the privacy policy link.",
// )}
>
<Textarea {...field} rows={3} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="button_accept_all"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_accept_all)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="button_reject_all"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_reject_all)}>
<Input {...field} />
</Field>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="button_customize"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_customize)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="privacy_policy_link_text"
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.privacy_policy_link_text)}
>
<Input {...field} />
</Field>
)}
/>
</div>
</div>
</Card>
<div className="flex items-start justify-center rounded-lg border border-border-low bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-size-[20px_20px] p-6">
<BannerPreview
bannerTitle={bannerTitle}
bannerDescription={bannerDescription}
buttonAcceptAll={buttonAcceptAll}
buttonRejectAll={buttonRejectAll}
buttonCustomize={buttonCustomize}
privacyPolicyLinkText={privacyPolicyLinkText}
showBranding={showBranding}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
interface PanelPreviewProps {
panelTitle: string;
panelDescription: string;
buttonSave: string;
categoryNames: string[];
necessaryCategoryName: string;
}
export function PanelPreview({
panelTitle,
panelDescription,
buttonSave,
categoryNames,
necessaryCategoryName,
}: PanelPreviewProps) {
const descriptionParts = panelDescription.split(
"{{necessary_category}}",
);
const hasPlaceholder = descriptionParts.length > 1;
return (
<div
style={{
background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)",
borderRadius: "var(--probo-radius, 12px)",
boxShadow:
"var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
fontFamily:
"var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
fontSize: "var(--probo-font-size, 14px)",
lineHeight: 1.5,
maxWidth: 380,
width: "100%",
padding: "24px",
}}
>
<p
style={{
fontSize: "calc(var(--probo-font-size, 14px) + 2px)",
fontWeight: 600,
margin: "0 0 8px",
}}
>
{panelTitle}
</p>
<p
style={{
color: "var(--probo-text-secondary, #555555)",
margin: "0 0 20px",
fontSize: "calc(var(--probo-font-size, 14px) - 1px)",
}}
>
{hasPlaceholder
? (
<>
{descriptionParts[0]}
<strong>{necessaryCategoryName}</strong>
{descriptionParts[1]}
</>
)
: (
panelDescription
)}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{categoryNames.map(name => (
<div
key={name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 0",
borderBottom: "1px solid var(--probo-border, #e0e0e0)",
}}
>
<span style={{ fontWeight: 500 }}>{name}</span>
<div
style={{
width: 36,
height: 20,
borderRadius: 10,
background:
name === necessaryCategoryName
? "var(--probo-accent, #1a1a1a)"
: "var(--probo-border, #e0e0e0)",
position: "relative",
cursor: "default",
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: "var(--probo-bg, #ffffff)",
position: "absolute",
top: 2,
left:
name === necessaryCategoryName ? 18 : 2,
transition: "left 0.2s",
}}
/>
</div>
</div>
))}
</div>
<div style={{ marginTop: 20 }}>
<button
type="button"
style={{
padding: "8px 16px",
borderRadius: "var(--probo-btn-radius, 8px)",
border: "1px solid var(--probo-accent, #1a1a1a)",
background: "var(--probo-accent, #1a1a1a)",
color: "var(--probo-accent-text, #ffffff)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
lineHeight: "normal",
cursor: "pointer",
width: "100%",
}}
>
{buttonSave}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Card, Field, Input, Textarea } from "@probo/ui";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { PanelPreview } from "./PanelPreview";
import { TRANSLATION_LABELS } from "./translationDefaults";
import type { TranslationFormValues } from "./TranslationEditor";
interface PanelTranslationSectionProps {
categoryNames: string[];
necessaryCategoryName: string;
}
export function PanelTranslationSection({
categoryNames,
necessaryCategoryName,
}: PanelTranslationSectionProps) {
const { __ } = useTranslate();
const { control } = useFormContext<TranslationFormValues>();
const panelTitle = useWatch({ control, name: "panel_title" });
const panelDescription = useWatch({ control, name: "panel_description" });
const buttonSave = useWatch({ control, name: "button_save" });
return (
<div className="space-y-4">
<h3 className="font-medium text-lg">{__("Preferences panel")}</h3>
<div className="grid grid-cols-2 gap-6">
<Card className="border p-4">
<div className="space-y-4">
<Controller
control={control}
name="panel_title"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.panel_title)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="panel_description"
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.panel_description)}
// description={__(
// "Use {{necessary_category}} to insert the necessary category name.",
// )}
>
<Textarea {...field} rows={3} />
</Field>
)}
/>
<Controller
control={control}
name="button_save"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_save)}>
<Input {...field} />
</Field>
)}
/>
<div className="space-y-4 border-t border-border-low pt-4">
<h4 className="text-sm font-medium text-txt-secondary">
{__("Accessibility labels")}
</h4>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="aria_close"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.aria_close)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="aria_cookie_settings"
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.aria_cookie_settings)}
>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="aria_show_details"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.aria_show_details)}>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="aria_hide_details"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.aria_hide_details)}>
<Input {...field} />
</Field>
)}
/>
</div>
</div>
</div>
</Card>
<div className="flex items-start justify-center rounded-lg border border-border-low bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-size-[20px_20px] p-6">
<PanelPreview
panelTitle={panelTitle}
panelDescription={panelDescription}
buttonSave={buttonSave}
categoryNames={categoryNames}
necessaryCategoryName={necessaryCategoryName}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
interface PlaceholderPreviewProps {
placeholderText: string;
placeholderButton: string;
categoryName: string;
}
export function PlaceholderPreview({
placeholderText,
placeholderButton,
categoryName,
}: PlaceholderPreviewProps) {
const displayText = placeholderText.replace("{{category}}", categoryName);
return (
<div
style={{
background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)",
borderRadius: "var(--probo-radius, 12px)",
boxShadow:
"var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
fontFamily:
"var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
fontSize: "var(--probo-font-size, 14px)",
lineHeight: 1.5,
maxWidth: 380,
width: "100%",
padding: "24px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
textAlign: "center",
}}
>
<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}
</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",
cursor: "pointer",
}}
>
{placeholderButton}
</button>
</div>
);
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Card, Field, Input } from "@probo/ui";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { PlaceholderPreview } from "./PlaceholderPreview";
import { TRANSLATION_LABELS } from "./translationDefaults";
import type { TranslationFormValues } from "./TranslationEditor";
interface PlaceholderTranslationSectionProps {
exampleCategoryName: string;
}
export function PlaceholderTranslationSection({
exampleCategoryName,
}: PlaceholderTranslationSectionProps) {
const { __ } = useTranslate();
const { control } = useFormContext<TranslationFormValues>();
const placeholderText = useWatch({ control, name: "placeholder_text" });
const placeholderButton = useWatch({ control, name: "placeholder_button" });
return (
<div className="space-y-4">
<h3 className="font-medium text-lg">{__("Placeholder")}</h3>
<p className="text-sm text-txt-secondary">
{__(
"Shown in place of blocked content until the visitor gives consent.",
)}
</p>
<div className="grid grid-cols-2 gap-6">
<Card className="border p-4">
<div className="space-y-4">
<Controller
control={control}
name="placeholder_text"
render={({ field }) => (
<Field
label={__(TRANSLATION_LABELS.placeholder_text)}
>
<Input {...field} />
</Field>
)}
/>
<Controller
control={control}
name="placeholder_button"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.placeholder_button)}>
<Input {...field} />
</Field>
)}
/>
</div>
</Card>
<div className="flex items-start justify-center rounded-lg border border-border-low bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-size-[20px_20px] p-6">
<PlaceholderPreview
placeholderText={placeholderText}
placeholderButton={placeholderButton}
categoryName={exampleCategoryName}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, useToast } from "@probo/ui";
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { TranslationEditorMutation } from "#/__generated__/core/TranslationEditorMutation.graphql";
import { BannerTranslationSection } from "./BannerTranslationSection";
import { PanelTranslationSection } from "./PanelTranslationSection";
import { PlaceholderTranslationSection } from "./PlaceholderTranslationSection";
import { ALL_KEYS } from "./translationDefaults";
const upsertTranslationMutation = graphql`
mutation TranslationEditorMutation(
$input: UpsertCookieBannerTranslationInput!
) {
upsertCookieBannerTranslation(input: $input) {
cookieBanner {
id
translations {
id
language
translations
}
latestVersion {
id
version
state
}
}
}
}
`;
export type TranslationFormValues = Record<string, string>;
interface TranslationEditorProps {
cookieBannerId: string;
language: string;
existingTranslations: Record<string, string> | null;
showBranding: boolean;
categoryNames: string[];
necessaryCategoryName: string;
}
export function TranslationEditor({
cookieBannerId,
language,
existingTranslations,
showBranding,
categoryNames,
necessaryCategoryName,
}: TranslationEditorProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [upsertTranslation, isUpserting]
= useMutation<TranslationEditorMutation>(upsertTranslationMutation);
const defaultValues = useMemo(() => {
const values: Record<string, string> = {};
for (const key of ALL_KEYS) {
values[key] = existingTranslations?.[key] ?? "";
}
return values;
}, [existingTranslations]);
const methods = useForm<TranslationFormValues>({
defaultValues,
});
const handleSave = (formData: TranslationFormValues) => {
upsertTranslation({
variables: {
input: {
cookieBannerId,
language,
translations: JSON.stringify(formData),
},
},
onCompleted() {
toast({
title: __("Success"),
description: __("Translation saved"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to save translation"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<FormProvider {...methods}>
<form
className="space-y-8"
onSubmit={e => void methods.handleSubmit(handleSave)(e)}
>
<BannerTranslationSection showBranding={showBranding} />
<PanelTranslationSection
categoryNames={categoryNames}
necessaryCategoryName={necessaryCategoryName}
/>
<PlaceholderTranslationSection
exampleCategoryName={categoryNames[1] ?? categoryNames[0] ?? "Analytics"}
/>
<Button type="submit" disabled={isUpserting}>
{isUpserting ? __("Saving...") : __("Save translations")}
</Button>
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export const SUPPORTED_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" },
{ code: "es", label: "Español" },
] as const;
export const BANNER_KEYS = [
"banner_title",
"banner_description",
"button_accept_all",
"button_reject_all",
"button_customize",
"privacy_policy_link_text",
] as const;
export const PANEL_KEYS = [
"panel_title",
"panel_description",
"button_save",
"aria_close",
"aria_show_details",
"aria_hide_details",
"aria_cookie_settings",
] as const;
export const PLACEHOLDER_KEYS = [
"placeholder_text",
"placeholder_button",
] as const;
export type TranslationKey
= | (typeof BANNER_KEYS)[number]
| (typeof PANEL_KEYS)[number]
| (typeof PLACEHOLDER_KEYS)[number];
export const ALL_KEYS: readonly TranslationKey[] = [
...BANNER_KEYS,
...PANEL_KEYS,
...PLACEHOLDER_KEYS,
];
export const TRANSLATION_LABELS: Record<string, string> = {
banner_title: "Banner title",
banner_description: "Banner description",
button_accept_all: "Accept all button",
button_reject_all: "Reject all button",
button_customize: "Customize button",
privacy_policy_link_text: "Privacy policy link text",
panel_title: "Panel title",
panel_description: "Panel description",
button_save: "Save button",
aria_close: "Close (ARIA)",
aria_show_details: "Show details (ARIA)",
aria_hide_details: "Hide details (ARIA)",
aria_cookie_settings: "Cookie settings (ARIA)",
placeholder_text: "Placeholder text",
placeholder_button: "Placeholder button",
};

View File

@@ -54,6 +54,11 @@ export const cookieBannerRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader")),
},
{
path: "translations",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/translations/CookieBannerTranslationsPageLoader")),
},
{
path: "snippet",
Fallback: LinkCardSkeleton,