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,
|
Badge,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
|
IconGlobe,
|
||||||
IconListStack,
|
IconListStack,
|
||||||
IconPageTextLine,
|
IconPageTextLine,
|
||||||
IconSettingsGear2,
|
IconSettingsGear2,
|
||||||
@@ -242,6 +243,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
|
|||||||
<IconListStack size={20} />
|
<IconListStack size={20} />
|
||||||
{__("Cookies")}
|
{__("Cookies")}
|
||||||
</TabLink>
|
</TabLink>
|
||||||
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/translations`}>
|
||||||
|
<IconGlobe size={20} />
|
||||||
|
{__("Translations")}
|
||||||
|
</TabLink>
|
||||||
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
|
||||||
<IconPageTextLine size={20} />
|
<IconPageTextLine size={20} />
|
||||||
{__("JS / CSS snippets")}
|
{__("JS / CSS snippets")}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const bannerSettingsFormFragment = graphql`
|
|||||||
privacyPolicyUrl
|
privacyPolicyUrl
|
||||||
consentExpiryDays
|
consentExpiryDays
|
||||||
consentMode
|
consentMode
|
||||||
|
defaultLanguage
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ const updateBannerMutation = graphql`
|
|||||||
privacyPolicyUrl
|
privacyPolicyUrl
|
||||||
consentExpiryDays
|
consentExpiryDays
|
||||||
consentMode
|
consentMode
|
||||||
|
defaultLanguage
|
||||||
latestVersion {
|
latestVersion {
|
||||||
id
|
id
|
||||||
version
|
version
|
||||||
@@ -70,6 +72,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
|
|||||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
|
||||||
const [consentExpiryDays, setConsentExpiryDays] = useState(String(banner.consentExpiryDays));
|
const [consentExpiryDays, setConsentExpiryDays] = useState(String(banner.consentExpiryDays));
|
||||||
const [consentMode, setConsentMode] = useState(banner.consentMode);
|
const [consentMode, setConsentMode] = useState(banner.consentMode);
|
||||||
|
const [defaultLanguage, setDefaultLanguage] = useState(banner.defaultLanguage);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -83,6 +86,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
|
|||||||
privacyPolicyUrl,
|
privacyPolicyUrl,
|
||||||
consentExpiryDays: parseInt(consentExpiryDays, 10),
|
consentExpiryDays: parseInt(consentExpiryDays, 10),
|
||||||
consentMode: consentMode,
|
consentMode: consentMode,
|
||||||
|
defaultLanguage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onCompleted() {
|
onCompleted() {
|
||||||
@@ -111,7 +115,7 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
|
|||||||
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
|
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{__("Consent Expiry (days)")}</Label>
|
<Label>{__("Consent Expiry (days)")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -129,6 +133,15 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
|
|||||||
<Option value="OPT_OUT">{__("Opt-out")}</Option>
|
<Option value="OPT_OUT">{__("Opt-out")}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isUpdating}>
|
<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,
|
Fallback: LinkCardSkeleton,
|
||||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader")),
|
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",
|
path: "snippet",
|
||||||
Fallback: LinkCardSkeleton,
|
Fallback: LinkCardSkeleton,
|
||||||
|
|||||||
Reference in New Issue
Block a user