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