Add cookie_policy_url field to cookie banners

Introduce a required cookie_policy_url alongside the existing
privacy_policy_url (now optional) so banners can link directly to a
dedicated cookie policy — a compliance best practice recommended by
CNIL, ICO, and the EDPB. Existing rows are seeded from their current
privacy_policy_url value.

Both {{cookie_policy_link}} and {{privacy_policy_link}} placeholders
are supported independently in banner description translations.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-24 18:04:05 +04:00
parent 4982cebb9c
commit 598c6b112c
15 changed files with 142 additions and 56 deletions

View File

@@ -60,6 +60,7 @@ export default function NewCookieBannerPage() {
const [name, setName] = useState("");
const [origin, setOrigin] = useState("");
const [cookiePolicyUrl, setCookiePolicyUrl] = useState("");
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState("");
const [consentExpiryDays, setConsentExpiryDays] = useState("365");
const [consentMode, setConsentMode] = useState("OPT_IN");
@@ -73,7 +74,8 @@ export default function NewCookieBannerPage() {
organizationId,
name,
origin,
privacyPolicyUrl,
cookiePolicyUrl,
privacyPolicyUrl: privacyPolicyUrl || undefined,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
},
@@ -136,12 +138,20 @@ export default function NewCookieBannerPage() {
/>
</Field>
<Field label={__("Cookie Policy URL")}>
<Input
value={cookiePolicyUrl}
onChange={e => setCookiePolicyUrl(e.target.value)}
placeholder="https://example.com/cookies"
required
/>
</Field>
<Field label={__("Privacy Policy URL")}>
<Input
value={privacyPolicyUrl}
onChange={e => setPrivacyPolicyUrl(e.target.value)}
placeholder="https://example.com/privacy"
required
/>
</Field>

View File

@@ -27,6 +27,7 @@ const bannerSettingsFormFragment = graphql`
id
name
origin
cookiePolicyUrl
privacyPolicyUrl
consentExpiryDays
consentMode
@@ -40,6 +41,7 @@ const updateBannerMutation = graphql`
cookieBanner {
id
name
cookiePolicyUrl
privacyPolicyUrl
consentExpiryDays
consentMode
@@ -67,7 +69,8 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
const [updateBanner, isUpdating] = useMutation<BannerSettingsFormMutation>(updateBannerMutation);
const [name, setName] = useState(banner.name);
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
const [cookiePolicyUrl, setCookiePolicyUrl] = useState(banner.cookiePolicyUrl);
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);
@@ -80,7 +83,8 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
input: {
cookieBannerId: banner.id,
name,
privacyPolicyUrl,
cookiePolicyUrl,
privacyPolicyUrl: privacyPolicyUrl || undefined,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode,
defaultLanguage,
@@ -108,8 +112,12 @@ export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps)
<Input value={banner.origin} disabled />
</Field>
<Field label={__("Cookie Policy URL")}>
<Input value={cookiePolicyUrl} onChange={e => setCookiePolicyUrl(e.target.value)} required />
</Field>
<Field label={__("Privacy Policy URL")}>
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} />
</Field>
<div className="grid grid-cols-3 gap-4">

View File

@@ -21,19 +21,10 @@ interface BannerPreviewProps {
buttonRejectAll: string;
buttonCustomize: string;
privacyPolicyLinkText: string;
cookiePolicyLinkText: string;
showBranding: boolean;
}
function interpolateDescription(
description: string,
linkText: string,
): string {
return description.replaceAll(
"{{privacy_policy_link}}",
linkText,
);
}
export function BannerPreview({
bannerTitle,
bannerDescription,
@@ -41,10 +32,11 @@ export function BannerPreview({
buttonRejectAll,
buttonCustomize,
privacyPolicyLinkText,
cookiePolicyLinkText,
showBranding,
}: BannerPreviewProps) {
const descriptionParts = bannerDescription.split("{{privacy_policy_link}}");
const hasPlaceholder = descriptionParts.length > 1;
const cookieParts = bannerDescription.split("{{cookie_policy_link}}");
const hasAnyPlaceholder = cookieParts.length > 1 || bannerDescription.includes("{{privacy_policy_link}}");
return (
<div
@@ -78,27 +70,27 @@ export function BannerPreview({
margin: "0 0 20px",
}}
>
{hasPlaceholder
? descriptionParts.map((part, i) => (
<span key={i}>
{part}
{i < descriptionParts.length - 1 && (
<a
href="#"
onClick={e => e.preventDefault()}
style={{
color: "var(--probo-accent, #1a1a1a)",
textDecoration: "underline",
}}
>
{privacyPolicyLinkText}
{hasAnyPlaceholder
? cookieParts.map((cookiePart, ci) => (
<span key={`c${ci}`}>
{cookiePart.split("{{privacy_policy_link}}").map((privPart, pi, arr) => (
<span key={`p${pi}`}>
{privPart}
{pi < arr.length - 1 && (
<a href="#" onClick={e => e.preventDefault()} style={{ color: "var(--probo-accent, #1a1a1a)", textDecoration: "underline" }}>
{privacyPolicyLinkText}
</a>
)}
</span>
))}
{ci < cookieParts.length - 1 && (
<a href="#" onClick={e => e.preventDefault()} style={{ color: "var(--probo-accent, #1a1a1a)", textDecoration: "underline" }}>
{cookiePolicyLinkText}
</a>
)}
</span>
))
: (
interpolateDescription(bannerDescription, privacyPolicyLinkText)
)}
: bannerDescription}
</p>
<div
style={{

View File

@@ -35,6 +35,10 @@ export function BannerTranslationSection({
const buttonAcceptAll = useWatch({ control, name: "button_accept_all" });
const buttonRejectAll = useWatch({ control, name: "button_reject_all" });
const buttonCustomize = useWatch({ control, name: "button_customize" });
const cookiePolicyLinkText = useWatch({
control,
name: "cookie_policy_link_text",
});
const privacyPolicyLinkText = useWatch({
control,
name: "privacy_policy_link_text",
@@ -62,7 +66,7 @@ export function BannerTranslationSection({
<Field
label={__(TRANSLATION_LABELS.banner_description)}
>
<p className="text-xs text-txt-secondary mb-2">{"Use {{privacy_policy_link}} to insert the privacy policy link."}</p>
<p className="text-xs text-txt-secondary mb-2">{"Use {{cookie_policy_link}} and {{privacy_policy_link}} to insert policy links."}</p>
<Textarea {...field} rows={3} />
</Field>
)}
@@ -87,12 +91,23 @@ export function BannerTranslationSection({
)}
/>
</div>
<Controller
control={control}
name="button_customize"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_customize)}>
<Input {...field} />
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="button_customize"
name="cookie_policy_link_text"
render={({ field }) => (
<Field label={__(TRANSLATION_LABELS.button_customize)}>
<Field
label={__(TRANSLATION_LABELS.cookie_policy_link_text)}
>
<Input {...field} />
</Field>
)}
@@ -119,6 +134,7 @@ export function BannerTranslationSection({
buttonAcceptAll={buttonAcceptAll}
buttonRejectAll={buttonRejectAll}
buttonCustomize={buttonCustomize}
cookiePolicyLinkText={cookiePolicyLinkText}
privacyPolicyLinkText={privacyPolicyLinkText}
showBranding={showBranding}
/>

View File

@@ -25,6 +25,7 @@ export const BANNER_KEYS = [
"button_accept_all",
"button_reject_all",
"button_customize",
"cookie_policy_link_text",
"privacy_policy_link_text",
] as const;
@@ -60,6 +61,7 @@ export const TRANSLATION_LABELS: Record<string, string> = {
button_accept_all: "Accept all button",
button_reject_all: "Reject all button",
button_customize: "Customize button",
cookie_policy_link_text: "Cookie policy link text",
privacy_policy_link_text: "Privacy policy link text",
panel_title: "Panel title",
panel_description: "Panel description",