Add UX for cookie banner management

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-20 10:48:11 +04:00
parent 6c5c1fa818
commit 1ec8e475de
37 changed files with 3263 additions and 8 deletions

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@phosphor-icons/react": "^2.1.10",
"@probo/coredata": "^1.0.0",
"@probo/helpers": "^1.0.0",
"@probo/hooks": "1.0.0",

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { CookieIcon } from "@phosphor-icons/react";
import { useTranslate } from "@probo/i18n";
import {
IconBank,
@@ -63,6 +64,7 @@ const fragment = graphql`
canListRightsRequests: permission(action: "core:rights-request:list")
canListSnapshots: permission(action: "core:snapshot:list")
canGetTrustCenter: permission(action: "core:trust-center:get")
canListCookieBanners: permission(action: "core:cookie-banner:list")
canUpdateOrganization: permission(action: "iam:organization:update")
canListStatementsOfApplicability: permission(
action: "core:statement-of-applicability:list"
@@ -218,6 +220,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/compliance-page`}
/>
)}
{organization.canListCookieBanners && (
<SidebarItem
label={__("Cookie Banners")}
icon={CookieIcon}
to={`${prefix}/cookie-banners`}
/>
)}
{organization.canUpdateOrganization && (
<SidebarItem
label={__("Settings")}

View File

@@ -0,0 +1,37 @@
// 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { PageHeader } from "@probo/ui";
import { Outlet } from "react-router";
export default function CookieBannerLayout() {
const { __ } = useTranslate();
usePageTitle(__("Cookie Banners"));
return (
<div className="space-y-6">
<PageHeader
title={__("Cookie Banners")}
description={__(
"Manage cookie consent banners for your websites. Configure categories, cookies, and install the SDK.",
)}
/>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,172 @@
// 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
Field,
IconChevronLeft,
Input,
Label,
Option,
PageHeader,
Select,
useToast,
} from "@probo/ui";
import { type FormEvent, useState } from "react";
import { useMutation } from "react-relay";
import { Link, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import type { NewCookieBannerPageMutation } from "#/__generated__/core/NewCookieBannerPageMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const createCookieBannerMutation = graphql`
mutation NewCookieBannerPageMutation($input: CreateCookieBannerInput!) {
createCookieBanner(input: $input) {
cookieBannerEdge {
node {
id
}
}
}
}
`;
export default function NewCookieBannerPage() {
const { __ } = useTranslate();
const { toast } = useToast();
const navigate = useNavigate();
const organizationId = useOrganizationId();
usePageTitle(__("New Cookie Banner"));
const [commitMutation, isInFlight]
= useMutation<NewCookieBannerPageMutation>(createCookieBannerMutation);
const [name, setName] = useState("");
const [origin, setOrigin] = useState("");
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState("");
const [consentExpiryDays, setConsentExpiryDays] = useState("365");
const [consentMode, setConsentMode] = useState("OPT_IN");
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
commitMutation({
variables: {
input: {
organizationId,
name,
origin,
privacyPolicyUrl,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
},
},
onCompleted(data) {
toast({
title: __("Success"),
description: __("Cookie banner created successfully"),
variant: "success",
});
const bannerId = data.createCookieBanner.cookieBannerEdge.node.id;
void navigate(`/organizations/${organizationId}/cookie-banners/${bannerId}`);
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create cookie banner"), error as GraphQLError),
variant: "error",
});
},
});
};
return (
<div className="space-y-6">
<Link
to={`/organizations/${organizationId}/cookie-banners`}
className="mb-4 inline-flex gap-2 items-center"
>
<IconChevronLeft size={16} />
{__("Back")}
</Link>
<PageHeader
title={__("Create Cookie Banner")}
description={__(
"Set up a new cookie consent banner with its origin URL and consent configuration.",
)}
/>
<Card padded asChild>
<form onSubmit={handleSubmit} className="space-y-4">
<Field label={__("Name")}>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={__("My Website")}
required
/>
</Field>
<Field label={__("Origin URL")}>
<Input
value={origin}
onChange={e => setOrigin(e.target.value)}
placeholder="https://example.com"
required
/>
</Field>
<Field label={__("Privacy Policy URL")}>
<Input
value={privacyPolicyUrl}
onChange={e => setPrivacyPolicyUrl(e.target.value)}
placeholder="https://example.com/privacy"
required
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{__("Consent Expiry (days)")}</Label>
<Input
type="number"
value={consentExpiryDays}
onChange={e => setConsentExpiryDays(e.target.value)}
min="1"
required
/>
</div>
<div className="space-y-2">
<Label>{__("Consent Mode")}</Label>
<Select value={consentMode} onValueChange={setConsentMode}>
<Option value="OPT_IN">{__("Opt-in")}</Option>
<Option value="OPT_OUT">{__("Opt-out")}</Option>
</Select>
</div>
</div>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? __("Creating...") : __("Create Banner")}
</Button>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,204 @@
// 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 {
Badge,
Breadcrumb,
Button,
IconImage,
IconPageTextLine,
IconSettingsGear2,
PageHeader,
TabLink,
Tabs,
useToast,
} from "@probo/ui";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { Outlet, useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { CookieBannerConfigLayoutActivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutActivateMutation.graphql";
import type { CookieBannerConfigLayoutDeactivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutDeactivateMutation.graphql";
import type { CookieBannerConfigLayoutPublishMutation } from "#/__generated__/core/CookieBannerConfigLayoutPublishMutation.graphql";
import type { CookieBannerConfigLayoutQuery } from "#/__generated__/core/CookieBannerConfigLayoutQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
export const cookieBannerConfigLayoutQuery = graphql`
query CookieBannerConfigLayoutQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
__typename
... on CookieBanner {
id
name
origin
state
latestVersion {
id
version
state
}
}
}
}
`;
const activateMutation = graphql`
mutation CookieBannerConfigLayoutActivateMutation($input: ActivateCookieBannerInput!) {
activateCookieBanner(input: $input) {
cookieBanner {
id
state
}
}
}
`;
const deactivateMutation = graphql`
mutation CookieBannerConfigLayoutDeactivateMutation($input: DeactivateCookieBannerInput!) {
deactivateCookieBanner(input: $input) {
cookieBanner {
id
state
}
}
}
`;
const publishMutation = graphql`
mutation CookieBannerConfigLayoutPublishMutation($input: PublishCookieBannerVersionInput!) {
publishCookieBannerVersion(input: $input) {
cookieBannerVersion {
id
version
state
}
}
}
`;
interface CookieBannerConfigLayoutProps {
queryRef: PreloadedQuery<CookieBannerConfigLayoutQuery>;
}
export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfigLayoutProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const data = usePreloadedQuery(cookieBannerConfigLayoutQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const banner = data.node;
const [commitActivate, isActivating] = useMutation<CookieBannerConfigLayoutActivateMutation>(activateMutation);
const [commitDeactivate, isDeactivating] = useMutation<CookieBannerConfigLayoutDeactivateMutation>(deactivateMutation);
const [commitPublish, isPublishing] = useMutation<CookieBannerConfigLayoutPublishMutation>(publishMutation);
const handleToggleState = () => {
if (banner.state === "ACTIVE") {
commitDeactivate({
variables: { input: { cookieBannerId: banner.id } },
onCompleted() {
toast({ title: __("Success"), description: __("Banner deactivated"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to deactivate"), error as GraphQLError), variant: "error" });
},
});
} else {
commitActivate({
variables: { input: { cookieBannerId: banner.id } },
onCompleted() {
toast({ title: __("Success"), description: __("Banner activated"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to activate"), error as GraphQLError), variant: "error" });
},
});
}
};
const handlePublish = () => {
commitPublish({
variables: { input: { cookieBannerId: banner.id } },
onCompleted() {
toast({ title: __("Success"), description: __("Version published"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to publish"), error as GraphQLError), variant: "error" });
},
});
};
const hasDraft = banner.latestVersion?.state === "DRAFT";
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("Cookie Banners"),
to: `/organizations/${organizationId}/cookie-banners`,
},
{
label: banner.name,
},
]}
/>
<PageHeader
title={banner.name}
description={banner.origin}
>
<Badge variant={banner.state === "ACTIVE" ? "success" : "danger"}>
{banner.state === "ACTIVE" ? __("Active") : __("Inactive")}
</Badge>
{hasDraft && (
<Button onClick={handlePublish} disabled={isPublishing}>
{isPublishing ? __("Publishing...") : __("Publish Changes")}
</Button>
)}
<Button
variant="secondary"
onClick={handleToggleState}
disabled={isActivating || isDeactivating}
>
{banner.state === "ACTIVE" ? __("Deactivate") : __("Activate")}
</Button>
</PageHeader>
<Tabs>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/settings`}>
<IconSettingsGear2 size={20} />
{__("Settings")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
<IconPageTextLine size={20} />
{__("Snippet")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/theme`}>
<IconImage size={20} />
{__("Theme")}
</TabLink>
</Tabs>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,43 @@
// 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 { CookieBannerConfigLayoutQuery } from "#/__generated__/core/CookieBannerConfigLayoutQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import CookieBannerConfigLayout, { cookieBannerConfigLayoutQuery } from "./CookieBannerConfigLayout";
export default function CookieBannerConfigLayoutLoader() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const [queryRef, loadQuery] = useQueryLoader<CookieBannerConfigLayoutQuery>(cookieBannerConfigLayoutQuery);
useEffect(() => {
if (cookieBannerId) {
loadQuery({ cookieBannerId });
}
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerConfigLayout queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,129 @@
// 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, Card, Field, Input, Label, Option, Select, useToast } from "@probo/ui";
import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { BannerSettingsFormMutation } from "#/__generated__/core/BannerSettingsFormMutation.graphql";
const updateBannerMutation = graphql`
mutation BannerSettingsFormMutation($input: UpdateCookieBannerInput!) {
updateCookieBanner(input: $input) {
cookieBanner {
id
name
origin
privacyPolicyUrl
consentExpiryDays
consentMode
}
}
}
`;
interface BannerSettingsFormProps {
banner: {
id: string;
name: string;
origin: string;
privacyPolicyUrl: string;
consentExpiryDays: number;
consentMode: string;
};
}
export function BannerSettingsForm({ banner }: BannerSettingsFormProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [commitMutation, isInFlight] = useMutation<BannerSettingsFormMutation>(updateBannerMutation);
const [name, setName] = useState(banner.name);
const [origin, setOrigin] = useState(banner.origin);
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
const [consentExpiryDays, setConsentExpiryDays] = useState(String(banner.consentExpiryDays));
const [consentMode, setConsentMode] = useState(banner.consentMode);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
commitMutation({
variables: {
input: {
cookieBannerId: banner.id,
name,
origin,
privacyPolicyUrl,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
},
},
onCompleted() {
toast({ title: __("Success"), description: __("Banner settings updated"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to update"), error as GraphQLError), variant: "error" });
},
});
};
return (
<div className="space-y-4">
<h3 className="font-medium">{__("Settings")}</h3>
<Card className="border p-4">
<form className="space-y-4" onSubmit={handleSubmit}>
<Field label={__("Name")}>
<Input value={name} onChange={e => setName(e.target.value)} required />
</Field>
<Field label={__("Origin URL")}>
<Input value={origin} onChange={e => setOrigin(e.target.value)} required />
</Field>
<Field label={__("Privacy Policy URL")}>
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
</Field>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{__("Consent Expiry (days)")}</Label>
<Input
type="number"
value={consentExpiryDays}
onChange={e => setConsentExpiryDays(e.target.value)}
min="1"
required
/>
</div>
<div className="space-y-2">
<Label>{__("Consent Mode")}</Label>
<Select value={consentMode} onValueChange={setConsentMode}>
<Option value="OPT_IN">{__("Opt-in")}</Option>
<Option value="OPT_OUT">{__("Opt-out")}</Option>
</Select>
</div>
</div>
<Button type="submit" disabled={isInFlight}>
{isInFlight ? __("Saving...") : __("Save")}
</Button>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,257 @@
// 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,
Checkbox,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Textarea,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CategoryDialogCreateMutation } from "#/__generated__/core/CategoryDialogCreateMutation.graphql";
import type { CategoryDialogUpdateMutation } from "#/__generated__/core/CategoryDialogUpdateMutation.graphql";
const createMutation = graphql`
mutation CategoryDialogCreateMutation(
$input: CreateCookieCategoryInput!
$connections: [ID!]!
) {
createCookieCategory(input: $input) {
cookieCategoryEdge @appendEdge(connections: $connections) {
node {
id
name
description
required
rank
cookies {
name
duration
description
}
createdAt
updatedAt
}
}
}
}
`;
const updateMutation = graphql`
mutation CategoryDialogUpdateMutation($input: UpdateCookieCategoryInput!) {
updateCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
name
duration
description
}
updatedAt
}
}
}
`;
interface CookieEntry {
name: string;
duration: string;
description: string;
}
interface CategoryDialogProps {
cookieBannerId: string;
connectionId: string;
category?: {
id: string;
name: string;
description: string;
required: boolean;
rank: number;
cookies: ReadonlyArray<CookieEntry>;
};
onOpenChange: (open: boolean) => void;
}
export function CategoryDialog({
cookieBannerId,
connectionId,
category,
onOpenChange,
}: CategoryDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const isEditing = !!category;
const [commitCreate, isCreating] = useMutation<CategoryDialogCreateMutation>(createMutation);
const [commitUpdate, isUpdating] = useMutation<CategoryDialogUpdateMutation>(updateMutation);
const [name, setName] = useState(category?.name ?? "");
const [description, setDescription] = useState(category?.description ?? "");
const [required, setRequired] = useState(category?.required ?? false);
const [cookies, setCookies] = useState<CookieEntry[]>(
category?.cookies ? [...category.cookies.map(c => ({ ...c }))] : [],
);
const addCookie = () => {
setCookies([...cookies, { name: "", duration: "", description: "" }]);
};
const removeCookie = (index: number) => {
setCookies(cookies.filter((_, i) => i !== index));
};
const updateCookie = (index: number, field: keyof CookieEntry, value: string) => {
setCookies(cookies.map((c, i) => (i === index ? { ...c, [field]: value } : c)));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const cookieItems = cookies.filter(c => c.name.trim() !== "");
if (isEditing) {
commitUpdate({
variables: {
input: {
cookieCategoryId: category.id,
name,
description,
cookies: cookieItems,
},
},
onCompleted() {
toast({ title: __("Success"), description: __("Category updated"), variant: "success" });
onOpenChange(false);
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to update category"), error as GraphQLError), variant: "error" });
},
});
} else {
commitCreate({
variables: {
input: {
cookieBannerId,
name,
description,
required,
rank: 0,
cookies: cookieItems.length > 0 ? cookieItems : null,
},
connections: [connectionId],
},
onCompleted() {
toast({ title: __("Success"), description: __("Category created"), variant: "success" });
onOpenChange(false);
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to create category"), error as GraphQLError), variant: "error" });
},
});
}
};
return (
<Dialog
defaultOpen
onClose={() => onOpenChange(false)}
title={isEditing ? __("Edit Category") : __("Add Category")}
className="max-w-lg"
>
<form onSubmit={handleSubmit}>
<DialogContent padded className="space-y-4">
<Field label={__("Name")}>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field label={__("Description")}>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} required rows={2} />
</Field>
{!isEditing && (
<div className="flex items-center gap-2">
<Checkbox
checked={required}
onCheckedChange={(v) => setRequired(v === true)}
id="required"
/>
<Label htmlFor="required">{__("Required")}</Label>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>{__("Cookies")}</Label>
<Button type="button" variant="secondary" onClick={addCookie}>
{__("Add Cookie")}
</Button>
</div>
{cookies.map((cookie, i) => (
<div key={i} className="rounded border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{__("Cookie")} {i + 1}</span>
<button
type="button"
onClick={() => removeCookie(i)}
className="text-xs text-red-500 hover:text-red-700"
>
{__("Remove")}
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder={__("Cookie name")}
value={cookie.name}
onChange={(e) => updateCookie(i, "name", e.target.value)}
/>
<Input
placeholder={__("Duration (e.g. 1 year)")}
value={cookie.duration}
onChange={(e) => updateCookie(i, "duration", e.target.value)}
/>
</div>
<Input
placeholder={__("Description")}
value={cookie.description}
onChange={(e) => updateCookie(i, "description", e.target.value)}
/>
</div>
))}
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating || isUpdating}>
{isCreating || isUpdating ? __("Saving...") : isEditing ? __("Update") : __("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,251 @@
// 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 { Badge, Button, Card, IconArrowDown, IconArrowUp, useToast } from "@probo/ui";
import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CategoryListDeleteMutation } from "#/__generated__/core/CategoryListDeleteMutation.graphql";
import type { CategoryListUpdateMutation } from "#/__generated__/core/CategoryListUpdateMutation.graphql";
import { CategoryDialog } from "./CategoryDialog";
import { CookieDialog } from "./CookieDialog";
const deleteCategoryMutation = graphql`
mutation CategoryListDeleteMutation(
$input: DeleteCookieCategoryInput!
$connections: [ID!]!
) {
deleteCookieCategory(input: $input) {
deletedCookieCategoryId @deleteEdge(connections: $connections)
}
}
`;
const updateCategoryMutation = graphql`
mutation CategoryListUpdateMutation($input: UpdateCookieCategoryInput!) {
updateCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
name
duration
description
}
updatedAt
}
}
}
`;
interface Category {
id: string;
name: string;
description: string;
required: boolean;
rank: number;
cookies: ReadonlyArray<{
name: string;
duration: string;
description: string;
}>;
}
interface CategoryListProps {
cookieBannerId: string;
categories: ReadonlyArray<Category>;
connectionId: string;
}
export function CategoryList({ cookieBannerId, categories, connectionId }: CategoryListProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [showCookieDialog, setShowCookieDialog] = useState(false);
const [commitDelete] = useMutation<CategoryListDeleteMutation>(deleteCategoryMutation);
const [commitUpdate] = useMutation<CategoryListUpdateMutation>(updateCategoryMutation);
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
const handleDelete = (categoryId: string) => {
commitDelete({
variables: {
input: { cookieCategoryId: categoryId },
connections: [connectionId],
},
onCompleted() {
toast({ title: __("Success"), description: __("Category deleted"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to delete category"), error as GraphQLError), variant: "error" });
},
});
};
const handleMoveUp = (index: number) => {
if (index === 0) return;
const current = sorted[index];
const above = sorted[index - 1];
commitUpdate({
variables: { input: { cookieCategoryId: current.id, rank: above.rank } },
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
},
});
commitUpdate({
variables: { input: { cookieCategoryId: above.id, rank: current.rank } },
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
},
});
};
const handleMoveDown = (index: number) => {
if (index >= sorted.length - 1) return;
const current = sorted[index];
const below = sorted[index + 1];
commitUpdate({
variables: { input: { cookieCategoryId: current.id, rank: below.rank } },
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
},
});
commitUpdate({
variables: { input: { cookieCategoryId: below.id, rank: current.rank } },
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
},
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">{__("Categories")}</h3>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setShowCookieDialog(true)}>
{__("Add Cookie")}
</Button>
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
{__("Add Category")}
</Button>
</div>
</div>
<Card className="divide-y divide-border-low rounded-lg border">
{sorted.map((category, index) => (
<div key={category.id} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium">{category.name}</span>
{category.required && (
<Badge variant="neutral">{__("Required")}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowUp size={14} />
</button>
<button
type="button"
onClick={() => handleMoveDown(index)}
disabled={index === sorted.length - 1}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowDown size={14} />
</button>
</div>
<Button
variant="secondary"
className="h-6 px-2 text-xs"
onClick={() => setEditingCategory(category)}
>
{__("Edit")}
</Button>
{!category.required && (
<Button
variant="danger"
className="h-6 px-2 text-xs"
onClick={() => handleDelete(category.id)}
>
{__("Delete")}
</Button>
)}
</div>
</div>
<p className="text-sm text-muted-foreground mb-2">{category.description}</p>
{category.cookies.length > 0 && (
<div className="mt-3 rounded bg-muted/50 p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">
{__("Cookies")}
{" "}
(
{category.cookies.length}
)
</div>
<div className="space-y-1">
{category.cookies.map((cookie, i) => (
<div key={i} className="flex items-baseline justify-between text-xs">
<code className="font-mono">{cookie.name}</code>
<span className="text-muted-foreground">{cookie.duration}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</Card>
{showCreateDialog && (
<CategoryDialog
cookieBannerId={cookieBannerId}
connectionId={connectionId}
onOpenChange={setShowCreateDialog}
/>
)}
{editingCategory && (
<CategoryDialog
cookieBannerId={cookieBannerId}
connectionId={connectionId}
category={editingCategory}
onOpenChange={(open) => { if (!open) setEditingCategory(null); }}
/>
)}
{showCookieDialog && (
<CookieDialog
categories={sorted}
onOpenChange={setShowCookieDialog}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
// 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,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Option,
Select,
useDialogRef,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieDialogUpdateMutation } from "#/__generated__/core/CookieDialogUpdateMutation.graphql";
const updateCategoryMutation = graphql`
mutation CookieDialogUpdateMutation($input: UpdateCookieCategoryInput!) {
updateCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
name
duration
description
}
updatedAt
}
}
}
`;
interface CookieEntry {
name: string;
duration: string;
description: string;
}
interface Category {
id: string;
name: string;
cookies: ReadonlyArray<CookieEntry>;
}
interface CookieDialogProps {
categories: ReadonlyArray<Category>;
onOpenChange: (open: boolean) => void;
}
export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const [commitUpdate, isUpdating] = useMutation<CookieDialogUpdateMutation>(updateCategoryMutation);
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? "");
const [name, setName] = useState("");
const [duration, setDuration] = useState("");
const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const category = categories.find(c => c.id === categoryId);
if (!category) return;
const existingCookies = category.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
commitUpdate({
variables: {
input: {
cookieCategoryId: categoryId,
cookies: [
...existingCookies,
{
name: name.trim(),
duration: duration.trim(),
description: description.trim(),
},
],
},
},
onCompleted() {
toast({ title: __("Success"), description: __("Cookie added"), variant: "success" });
dialogRef.current?.close();
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to add cookie"), error as GraphQLError), variant: "error" });
},
});
};
return (
<Dialog
ref={dialogRef}
defaultOpen
onClose={() => onOpenChange(false)}
title={__("Add Cookie")}
className="max-w-lg"
>
<form onSubmit={handleSubmit}>
<DialogContent padded className="space-y-4">
<div className="space-y-2">
<Label>{__("Category")}</Label>
<Select value={categoryId} onValueChange={id => setCategoryId(id)}>
{categories.map(cat => (
<Option key={cat.id} value={cat.id}>{cat.name}</Option>
))}
</Select>
</div>
<Field label={__("Cookie name")}>
<Input value={name} onChange={e => setName(e.target.value)} required />
</Field>
<Field label={__("Duration")}>
<Input
value={duration}
onChange={e => setDuration(e.target.value)}
required
placeholder={__("e.g. 1 year")}
/>
</Field>
<Field label={__("Description")}>
<Input value={description} onChange={e => setDescription(e.target.value)} required />
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? __("Adding...") : __("Add Cookie")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,88 @@
// 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 { useLazyLoadQuery } from "react-relay";
import { useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { CookieBannerSettingsPageQuery } from "#/__generated__/core/CookieBannerSettingsPageQuery.graphql";
import { BannerSettingsForm } from "../_components/BannerSettingsForm";
import { CategoryList } from "../_components/CategoryList";
const settingsPageQuery = graphql`
query CookieBannerSettingsPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
__typename
... on CookieBanner {
id
name
origin
privacyPolicyUrl
consentExpiryDays
consentMode
categories(first: 50, orderBy: { field: RANK, direction: ASC }) {
__id
edges {
node {
id
name
description
required
rank
cookies {
name
duration
description
}
createdAt
updatedAt
}
}
}
}
}
}
`;
export default function CookieBannerSettingsPage() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
if (!cookieBannerId) {
throw new Error("Missing :cookieBannerId param in route");
}
const data = useLazyLoadQuery<CookieBannerSettingsPageQuery>(
settingsPageQuery,
{ cookieBannerId },
);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const banner = data.node;
const connectionId = banner.categories.__id;
return (
<div className="space-y-8">
<BannerSettingsForm banner={banner} />
<CategoryList
cookieBannerId={banner.id}
categories={banner.categories.edges.map(e => e.node)}
connectionId={connectionId}
/>
</div>
);
}

View File

@@ -0,0 +1,170 @@
// 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 } from "@probo/ui";
import { useLazyLoadQuery } from "react-relay";
import { useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { CookieBannerSnippetPageQuery } from "#/__generated__/core/CookieBannerSnippetPageQuery.graphql";
import { CodeSnippets } from "./_components/CodeSnippets";
const snippetPageQuery = graphql`
query CookieBannerSnippetPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
__typename
... on CookieBanner {
id
origin
}
}
}
`;
export default function CookieBannerSnippetPage() {
const { __ } = useTranslate();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
if (!cookieBannerId) {
throw new Error("Missing :cookieBannerId param in route");
}
const data = useLazyLoadQuery<CookieBannerSnippetPageQuery>(
snippetPageQuery,
{ cookieBannerId },
);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const banner = data.node;
const baseUrl = `${window.location.origin}/api/cookie-banner/v1`;
return (
<div className="space-y-10">
<Step
number={1}
title={__("Add the cookie banner snippet")}
description={__("Include the Probo cookie banner on your website by adding one of the following snippets. The banner will automatically appear and collect visitor consent.")}
>
<CodeSnippets bannerId={banner.id} baseUrl={baseUrl} />
</Step>
<Step
number={2}
title={__("Tag third-party elements with consent categories")}
description={__("Mark scripts, iframes, and other third-party resources with a data-cookie-consent attribute so they only load after the visitor grants consent for the corresponding category. Replace src with data-src (or href with data-href) to prevent the browser from loading the resource before consent is given.")}
>
<div className="space-y-4">
<h4 className="text-sm font-medium">{__("Scripts")}</h4>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{`<!-- Before: loads immediately -->
<script src="https://analytics.example.com/tracker.js"></script>
<!-- After: loads only when "analytics" consent is granted -->
<script
type="text/plain"
data-cookie-consent="analytics"
data-src="https://analytics.example.com/tracker.js"
></script>`}</code>
</pre>
</Card>
<h4 className="text-sm font-medium">{__("Inline scripts")}</h4>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{`<script
type="text/plain"
data-cookie-consent="analytics"
data-type="text/javascript"
>
// This code runs only after consent
gtag('config', 'G-XXXXXXX');
</script>`}</code>
</pre>
</Card>
<h4 className="text-sm font-medium">{__("Iframes & embeds")}</h4>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{`<iframe
data-cookie-consent="marketing"
data-src="https://www.youtube.com/embed/VIDEO_ID"
width="560"
height="315"
></iframe>`}</code>
</pre>
</Card>
<h4 className="text-sm font-medium">{__("Images, video & audio")}</h4>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{`<img
data-cookie-consent="analytics"
data-src="https://tracker.example.com/pixel.gif"
width="1"
height="1"
/>`}</code>
</pre>
</Card>
<h4 className="text-sm font-medium">{__("Stylesheets")}</h4>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{`<link
rel="stylesheet"
data-cookie-consent="marketing"
data-href="https://widgets.example.com/styles.css"
/>`}</code>
</pre>
</Card>
<p className="text-sm text-txt-secondary">
{__("Supported elements: script, iframe, img, video, audio, embed, object, and link. The data-cookie-consent value must match a category name configured in your banner settings.")}
</p>
</div>
</Step>
</div>
);
}
function Step({
number,
title,
description,
children,
}: {
number: number;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-semibold text-invert">
{number}
</div>
<div className="min-w-0 flex-1 space-y-4">
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="mt-1 text-sm text-txt-secondary">{description}</p>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
// 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 { Button, Card, useToast } from "@probo/ui";
import { useState } from "react";
interface CodeSnippetsProps {
bannerId: string;
baseUrl: string;
}
export function CodeSnippets({ bannerId, baseUrl }: CodeSnippetsProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const tabs = [
{
label: __("Script Tag"),
code: `<script
src="https://cdn.jsdelivr.net/npm/@probo/cookie-banner/dist/cookie-banner.iife.js"
data-banner-id="${bannerId}"
data-base-url="${baseUrl}"
data-position="bottom-left"
></script>`,
},
{
label: __("ES Module"),
code: `import { registerThemedBanner } from "@probo/cookie-banner/themed-banner";
registerThemedBanner();
// In your HTML or template:
// <probo-cookie-banner
// banner-id="${bannerId}"
// base-url="${baseUrl}"
// position="bottom-left"
// ></probo-cookie-banner>`,
},
{
label: __("Headless"),
code: `import { registerComponents } from "@probo/cookie-banner";
registerComponents();
// Build your own UI with headless components:
// <probo-cookie-banner-root banner-id="${bannerId}" base-url="${baseUrl}">
// <probo-banner>
// <probo-accept-button><button>Accept all</button></probo-accept-button>
// <probo-reject-button><button>Reject all</button></probo-reject-button>
// <probo-customize-button><button>Customize</button></probo-customize-button>
// </probo-banner>
// <probo-settings-button position="bottom-left"></probo-settings-button>
// </probo-cookie-banner-root>`,
},
];
const [activeTab, setActiveTab] = useState(0);
const activeCode = tabs[activeTab].code;
const handleCopy = () => {
void navigator.clipboard.writeText(activeCode);
toast({
title: __("Copied"),
description: __("Code copied to clipboard"),
variant: "success",
});
};
return (
<Card className="rounded-lg border">
<div className="flex items-center justify-between border-b border-border-low px-1">
<div className="flex">
{tabs.map((tab, i) => (
<button
key={tab.label}
type="button"
onClick={() => setActiveTab(i)}
className={`cursor-pointer px-3 py-2.5 text-sm font-light border-b-2 border-border-low -mb-px transition-colors ${
i === activeTab
? "border-border-mid text-foreground font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.label}
</button>
))}
</div>
<Button variant="secondary" onClick={handleCopy}>
{__("Copy")}
</Button>
</div>
<pre className="overflow-x-auto p-4 text-sm font-mono bg-muted/30 rounded-b-lg text-invert bg-accent">
<code>{activeCode}</code>
</pre>
</Card>
);
}

View File

@@ -0,0 +1,23 @@
// 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 { ThemePreview } from "./_components/ThemePreview";
export default function CookieBannerThemePage() {
return (
<div className="space-y-8">
<ThemePreview />
</div>
);
}

View File

@@ -0,0 +1,277 @@
// 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 { Button, Card, Field, Input, useToast } from "@probo/ui";
import { useCallback, useMemo, useState } from "react";
type CSSVariable = {
key: string;
label: string;
defaultValue: string;
type: "color" | "text";
};
const CSS_VARIABLES: CSSVariable[] = [
{ key: "--probo-bg", label: "Background", defaultValue: "#ffffff", type: "color" },
{ key: "--probo-text", label: "Text", defaultValue: "#1a1a1a", type: "color" },
{ key: "--probo-text-secondary", label: "Text Secondary", defaultValue: "#555555", type: "color" },
{ key: "--probo-border", label: "Border", defaultValue: "#e0e0e0", type: "color" },
{ key: "--probo-accent", label: "Accent", defaultValue: "#1a1a1a", type: "color" },
{ key: "--probo-accent-text", label: "Accent Text", defaultValue: "#ffffff", type: "color" },
{ key: "--probo-radius", label: "Border Radius", defaultValue: "12px", type: "text" },
{ key: "--probo-btn-radius", label: "Button Radius", defaultValue: "8px", type: "text" },
{ key: "--probo-font-size", label: "Font Size", defaultValue: "14px", type: "text" },
{ key: "--probo-font-family", label: "Font Family", defaultValue: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", type: "text" },
{ key: "--probo-shadow", label: "Shadow", defaultValue: "0 4px 24px rgba(0, 0, 0, 0.12)", type: "text" },
];
function buildCSSSnippet(values: Record<string, string>): string {
const overrides = CSS_VARIABLES
.filter(v => values[v.key] !== v.defaultValue)
.map(v => ` ${v.key}: ${values[v.key]};`);
if (overrides.length === 0) {
return "/* Using default theme — no overrides needed */";
}
return `probo-cookie-banner {\n${overrides.join("\n")}\n}`;
}
export function ThemePreview() {
const { __ } = useTranslate();
const { toast } = useToast();
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const v of CSS_VARIABLES) {
initial[v.key] = v.defaultValue;
}
return initial;
});
const setValue = useCallback((key: string, value: string) => {
setValues(prev => ({ ...prev, [key]: value }));
}, []);
const handleReset = useCallback(() => {
const initial: Record<string, string> = {};
for (const v of CSS_VARIABLES) {
initial[v.key] = v.defaultValue;
}
setValues(initial);
}, []);
const cssSnippet = useMemo(() => buildCSSSnippet(values), [values]);
const handleCopyCSS = () => {
void navigator.clipboard.writeText(cssSnippet);
toast({
title: __("Copied"),
description: __("CSS snippet copied to clipboard"),
variant: "success",
});
};
const previewStyle = useMemo(() => {
const style: Record<string, string> = {};
for (const v of CSS_VARIABLES) {
style[v.key] = values[v.key];
}
return style;
}, [values]);
const colorVariables = CSS_VARIABLES.filter(v => v.type === "color");
const textVariables = CSS_VARIABLES.filter(v => v.type === "text");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-lg">{__("Theme")}</h3>
<Button variant="tertiary" onClick={handleReset}>
{__("Reset to defaults")}
</Button>
</div>
<Card className="border p-4">
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{colorVariables.map(v => (
<div key={v.key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-txt-primary">
{v.label}
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={values[v.key]}
onChange={e => setValue(v.key, e.target.value)}
className="h-8 w-10 shrink-0 cursor-pointer rounded border border-border-mid bg-transparent p-0.5"
/>
<Input
value={values[v.key]}
onChange={e => setValue(v.key, e.target.value)}
/>
</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-4">
{textVariables.map(v => (
<Field key={v.key} label={v.label}>
<Input
value={values[v.key]}
onChange={e => setValue(v.key, e.target.value)}
/>
</Field>
))}
</div>
</div>
</Card>
<h3 className="font-medium text-lg">{__("Preview")}</h3>
<Card className="border overflow-hidden">
<div
className="relative flex items-end justify-center bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-size-[20px_20px] p-8"
style={{ minHeight: 280, ...previewStyle }}
>
<BannerPreview />
</div>
</Card>
<div className="flex items-center justify-between">
<h3 className="font-medium text-lg">{__("CSS Snippet")}</h3>
<Button variant="secondary" onClick={handleCopyCSS}>
{__("Copy")}
</Button>
</div>
<Card className="border">
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
<code>{cssSnippet}</code>
</pre>
</Card>
</div>
);
}
function BannerPreview() {
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: 520,
width: "100%",
padding: 24,
}}
>
<p
style={{
fontSize: "calc(var(--probo-font-size, 14px) + 2px)",
fontWeight: 600,
margin: "0 0 8px",
}}
>
Cookie Preferences
</p>
<p
style={{
color: "var(--probo-text-secondary, #555555)",
margin: "0 0 20px",
}}
>
We use cookies to improve your experience and analyze site traffic.
{" "}
<a
href="#"
onClick={e => e.preventDefault()}
style={{
color: "var(--probo-accent, #1a1a1a)",
textDecoration: "underline",
}}
>
Privacy Policy
</a>
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
style={{
flex: 1,
minWidth: 0,
padding: "10px 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,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
Accept all
</button>
<button
type="button"
style={{
flex: 1,
minWidth: 0,
padding: "10px 16px",
borderRadius: "var(--probo-btn-radius, 8px)",
border: "1px solid var(--probo-border, #e0e0e0)",
background: "var(--probo-bg, #ffffff)",
color: "var(--probo-text, #1a1a1a)",
fontFamily: "inherit",
fontSize: "var(--probo-font-size, 14px)",
fontWeight: 500,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
Reject all
</button>
<button
type="button"
style={{
flex: 1,
minWidth: 0,
padding: "10px 16px",
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,
cursor: "pointer",
whiteSpace: "nowrap",
textDecoration: "underline",
}}
>
Customize
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
// 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 { Badge, Button, Card } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import type { CookieBannerOverviewPageQuery } from "#/__generated__/core/CookieBannerOverviewPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CookieBannerEmptyState } from "./_components/CookieBannerEmptyState";
export const cookieBannerOverviewPageQuery = graphql`
query CookieBannerOverviewPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
cookieBanners(first: 50, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
name
origin
state
createdAt
}
}
}
}
}
}
`;
interface CookieBannerOverviewPageProps {
queryRef: PreloadedQuery<CookieBannerOverviewPageQuery>;
}
export default function CookieBannerOverviewPage({ queryRef }: CookieBannerOverviewPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { organization } = usePreloadedQuery(cookieBannerOverviewPageQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("invalid type for node");
}
const banners = organization.cookieBanners.edges.map(e => e.node);
const newBannerHref = `/organizations/${organizationId}/cookie-banners/new`;
if (banners.length === 0) {
return (
<CookieBannerEmptyState>
<Button to={newBannerHref}>{__("Create your first banner")}</Button>
</CookieBannerEmptyState>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button to={newBannerHref}>{__("Create Banner")}</Button>
</div>
<Card className="divide-y divide-border rounded-lg border">
{banners.map(banner => (
<Link
key={banner.id}
to={`/organizations/${organizationId}/cookie-banners/${banner.id}`}
className="flex items-center justify-between gap-4 p-4 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-medium">{banner.name}</div>
<div className="text-sm text-muted-foreground truncate">{banner.origin}</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={banner.state === "ACTIVE" ? "success" : "danger"}>
{banner.state === "ACTIVE" ? __("Active") : __("Inactive")}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(banner.createdAt).toLocaleDateString()}
</span>
</div>
</Link>
))}
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
// 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 type { CookieBannerOverviewPageQuery } from "#/__generated__/core/CookieBannerOverviewPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import CookieBannerOverviewPage, { cookieBannerOverviewPageQuery } from "./CookieBannerOverviewPage";
export default function CookieBannerOverviewPageLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<CookieBannerOverviewPageQuery>(cookieBannerOverviewPageQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerOverviewPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,67 @@
// 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 { CookieIcon } from "@phosphor-icons/react";
import { useTranslate } from "@probo/i18n";
import type { ReactNode } from "react";
interface CookieBannerEmptyStateProps {
children?: ReactNode;
}
export function CookieBannerEmptyState({ children }: CookieBannerEmptyStateProps) {
const { __ } = useTranslate();
const steps = [
{
step: "1",
title: __("Create a banner"),
description: __("Set up your cookie consent banner with a name, origin URL, and privacy policy link."),
},
{
step: "2",
title: __("Configure categories"),
description: __("Organize your cookies into categories like Analytics, Advertising, and Functional."),
},
{
step: "3",
title: __("Install the SDK"),
description: __("Add a single script tag or import the ES module to start collecting consent."),
},
];
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<CookieIcon size={48} weight="duotone" className="mb-2 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">{__("No cookie banners yet")}</h2>
<p className="text-muted-foreground mb-8 max-w-md">
{__("Create your first cookie consent banner to start collecting GDPR-compliant consent from your website visitors.")}
</p>
<div className="grid gap-6 sm:grid-cols-3 mb-8 w-full max-w-2xl">
{steps.map(s => (
<div key={s.step} className="rounded-lg border border-border-mid p-4 text-left">
<div className="mb-2 flex size-8 items-center justify-center rounded-full bg-border-solid text-primary-foreground text-sm font-semibold">
{s.step}
</div>
<h3 className="font-medium mb-1">{s.title}</h3>
<p className="text-sm text-muted-foreground">{s.description}</p>
</div>
))}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,69 @@
// 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 { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { Fragment } from "react";
import { redirect } from "react-router";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
export const cookieBannerRoutes = [
{
path: "cookie-banners",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/CookieBannerLayout")),
children: [
{
index: true,
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/overview/CookieBannerOverviewPageLoader")),
},
{
path: "new",
Component: lazy(() => import("#/pages/organizations/cookie-banners/NewCookieBannerPage")),
},
],
},
{
path: "cookie-banners/:cookieBannerId",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayoutLoader")),
children: [
{
path: "",
loader: () => {
throw redirect("settings");
},
Component: Fragment,
},
{
path: "settings",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPage")),
},
{
path: "snippet",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/snippet/CookieBannerSnippetPage")),
},
{
path: "theme",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/theme/CookieBannerThemePage")),
},
],
},
] satisfies AppRoute[];

View File

@@ -30,6 +30,7 @@ import { PageSkeleton } from "./components/skeletons/PageSkeleton";
import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading";
import { peopleRoutes } from "./pages/iam/organizations/people/routes";
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
import { CurrentUser } from "./providers/CurrentUser";
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
import { assetRoutes } from "./routes/assetRoutes";
@@ -305,6 +306,7 @@ const routes = [
...statementsOfApplicabilityRoutes,
...accessReviewRoutes,
...compliancePageRoutes,
...cookieBannerRoutes,
...snapshotsRoutes,
{
path: "*",