Add cookies page + refactor relay tree

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-20 13:17:35 +04:00
parent 619cdd0e6c
commit c271938525
12 changed files with 755 additions and 349 deletions

View File

@@ -19,6 +19,7 @@ import {
Breadcrumb,
Button,
IconImage,
IconListStack,
IconPageTextLine,
IconSettingsGear2,
PageHeader,
@@ -188,6 +189,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
<IconSettingsGear2 size={20} />
{__("Settings")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/cookies`}>
<IconListStack size={20} />
{__("Cookies")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
<IconPageTextLine size={20} />
{__("Snippet")}

View File

@@ -16,11 +16,23 @@ 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 { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { BannerSettingsForm_cookieBanner$key } from "#/__generated__/core/BannerSettingsForm_cookieBanner.graphql";
import type { BannerSettingsFormMutation } from "#/__generated__/core/BannerSettingsFormMutation.graphql";
const bannerSettingsFormFragment = graphql`
fragment BannerSettingsForm_cookieBanner on CookieBanner {
id
name
origin
privacyPolicyUrl
consentExpiryDays
consentMode
}
`;
const updateBannerMutation = graphql`
mutation BannerSettingsFormMutation($input: UpdateCookieBannerInput!) {
updateCookieBanner(input: $input) {
@@ -37,20 +49,15 @@ const updateBannerMutation = graphql`
`;
interface BannerSettingsFormProps {
banner: {
id: string;
name: string;
origin: string;
privacyPolicyUrl: string;
consentExpiryDays: number;
consentMode: string;
};
cookieBannerKey: BannerSettingsForm_cookieBanner$key;
}
export function BannerSettingsForm({ banner }: BannerSettingsFormProps) {
export function BannerSettingsForm({ cookieBannerKey }: BannerSettingsFormProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const banner = useFragment(bannerSettingsFormFragment, cookieBannerKey);
const [commitMutation, isInFlight] = useMutation<BannerSettingsFormMutation>(updateBannerMutation);
const [name, setName] = useState(banner.name);
@@ -70,7 +77,7 @@ export function BannerSettingsForm({ banner }: BannerSettingsFormProps) {
origin,
privacyPolicyUrl,
consentExpiryDays: parseInt(consentExpiryDays, 10),
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
consentMode: consentMode,
},
},
onCompleted() {
@@ -112,7 +119,7 @@ export function BannerSettingsForm({ banner }: BannerSettingsFormProps) {
</div>
<div className="space-y-2">
<Label>{__("Consent Mode")}</Label>
<Select value={consentMode} onValueChange={setConsentMode}>
<Select value={consentMode} onValueChange={v => setConsentMode(v as "OPT_IN" | "OPT_OUT")}>
<Option value="OPT_IN">{__("Opt-in")}</Option>
<Option value="OPT_OUT">{__("Opt-out")}</Option>
</Select>

View File

@@ -16,13 +16,11 @@ 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";
@@ -31,7 +29,6 @@ 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(
@@ -59,196 +56,71 @@ const createMutation = graphql`
}
`;
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 [name, setName] = useState("");
const [description, setDescription] = useState("");
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,
},
commitCreate({
variables: {
input: {
cookieBannerId,
name,
description,
required: false,
rank: 0,
},
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" });
},
});
}
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")}
title={__("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 />
<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} />
<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 type="submit" disabled={isCreating}>
{isCreating ? __("Saving...") : __("Create")}
</Button>
</DialogFooter>
</form>

View File

@@ -16,14 +16,32 @@ 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 { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CategoryList_cookieBanner$key } from "#/__generated__/core/CategoryList_cookieBanner.graphql";
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 categoryListFragment = graphql`
fragment CategoryList_cookieBanner on CookieBanner {
id
categories(first: 50, orderBy: { field: RANK, direction: ASC }) {
__id
edges {
node {
id
name
description
required
rank
}
}
}
}
`;
const deleteCategoryMutation = graphql`
mutation CategoryListDeleteMutation(
@@ -55,31 +73,18 @@ const updateCategoryMutation = graphql`
}
`;
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;
cookieBannerKey: CategoryList_cookieBanner$key;
}
export function CategoryList({ cookieBannerId, categories, connectionId }: CategoryListProps) {
export function CategoryList({ cookieBannerKey }: CategoryListProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [showCookieDialog, setShowCookieDialog] = useState(false);
const banner = useFragment(categoryListFragment, cookieBannerKey);
const connectionId = banner.categories.__id;
const categories = banner.categories.edges.map(e => e.node);
const [commitDelete] = useMutation<CategoryListDeleteMutation>(deleteCategoryMutation);
const [commitUpdate] = useMutation<CategoryListUpdateMutation>(updateCategoryMutation);
@@ -140,17 +145,14 @@ export function CategoryList({ cookieBannerId, categories, connectionId }: Categ
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>
<h3 className="font-medium">{__("Categories Sorting")}</h3>
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
{__("Add Category")}
</Button>
</div>
<p className="text-sm text-txt-secondary">
{__("Categories will be displayed in your cookie banner in the same order as below.")}
</p>
<Card className="divide-y divide-border-low rounded-lg border">
{sorted.map((category, index) => (
<div key={category.id} className="p-4">
@@ -180,13 +182,6 @@ export function CategoryList({ cookieBannerId, categories, connectionId }: Categ
<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"
@@ -199,53 +194,17 @@ export function CategoryList({ cookieBannerId, categories, connectionId }: Categ
</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}
cookieBannerId={banner.id}
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,98 @@
// 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, IconPlusSmall } from "@probo/ui";
import { useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieBannerCookiesPageQuery } from "#/__generated__/core/CookieBannerCookiesPageQuery.graphql";
import { CategoryDialog } from "../_components/CategoryDialog";
import { CategorySection } from "./_components/CategorySection";
export const cookieBannerCookiesPageQuery = graphql`
query CookieBannerCookiesPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) {
__typename
... on CookieBanner {
id
categories(first: 50, orderBy: { field: RANK, direction: ASC }) {
__id
edges {
node {
id
rank
...CategorySectionFragment
}
}
}
}
}
}
`;
interface CookieBannerCookiesPageProps {
queryRef: PreloadedQuery<CookieBannerCookiesPageQuery>;
}
export default function CookieBannerCookiesPage({
queryRef,
}: CookieBannerCookiesPageProps) {
const { __ } = useTranslate();
const data = usePreloadedQuery(cookieBannerCookiesPageQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const banner = data.node;
const connectionId = banner.categories.__id;
const categories = banner.categories.edges.map(e => e.node);
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
const [showCreateDialog, setShowCreateDialog] = useState(false);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="font-medium">{__("Cookies")}</h3>
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
<IconPlusSmall size={16} />
{__("Add Category")}
</Button>
</div>
{sorted.length === 0 && (
<Card className="border p-8 text-center text-muted-foreground">
{__("No categories yet. Add a category to start managing cookies.")}
</Card>
)}
{sorted.map(category => (
<CategorySection key={category.id} categoryKey={category} />
))}
{showCreateDialog && (
<CategoryDialog
cookieBannerId={banner.id}
connectionId={connectionId}
onOpenChange={setShowCreateDialog}
/>
)}
</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 { CookieBannerCookiesPageQuery } from "#/__generated__/core/CookieBannerCookiesPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import CookieBannerCookiesPage, { cookieBannerCookiesPageQuery } from "./CookieBannerCookiesPage";
export default function CookieBannerCookiesPageLoader() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const [queryRef, loadQuery] = useQueryLoader<CookieBannerCookiesPageQuery>(cookieBannerCookiesPageQuery);
useEffect(() => {
if (cookieBannerId) {
loadQuery({ cookieBannerId });
}
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerCookiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,433 @@
// 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,
IconPencil,
IconPlusSmall,
IconTrashCan,
Input,
Tbody,
Td,
Textarea,
Th,
Thead,
Tr,
useToast,
} from "@probo/ui";
import { useState } from "react";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql";
export const categorySectionFragment = graphql`
fragment CategorySectionFragment on CookieCategory {
id
name
description
required
rank
cookies {
name
duration
description
}
}
`;
const updateCategoryMutation = graphql`
mutation CategorySectionUpdateMutation(
$input: UpdateCookieCategoryInput!
) {
updateCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
name
duration
description
}
updatedAt
}
}
}
`;
interface CookieEntry {
name: string;
duration: string;
description: string;
}
interface CategorySectionProps {
categoryKey: CategorySectionFragment$key;
}
export function CategorySection({ categoryKey }: CategorySectionProps) {
const category = useFragment(categorySectionFragment, categoryKey);
const { __ } = useTranslate();
const { toast } = useToast();
const [commitUpdate, isUpdating]
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
const [isEditingCategory, setIsEditingCategory] = useState(false);
const [editName, setEditName] = useState(category.name);
const [editDescription, setEditDescription] = useState(category.description);
const [editingCookieIndex, setEditingCookieIndex] = useState<number | null>(
null,
);
const [isAddingCookie, setIsAddingCookie] = useState(false);
const [cookieForm, setCookieForm] = useState<CookieEntry>({
name: "",
duration: "",
description: "",
});
const doUpdate = (
input: Record<string, unknown>,
onSuccess?: () => void,
) => {
commitUpdate({
variables: {
input: {
cookieCategoryId: category.id,
...input,
},
},
onCompleted() {
toast({
title: __("Success"),
description: __("Category updated"),
variant: "success",
});
onSuccess?.();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update category"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
const handleSaveCategory = () => {
doUpdate({ name: editName, description: editDescription }, () => {
setIsEditingCategory(false);
});
};
const handleCancelCategoryEdit = () => {
setEditName(category.name);
setEditDescription(category.description);
setIsEditingCategory(false);
};
const handleStartEditCookie = (index: number) => {
const c = category.cookies[index];
setCookieForm({
name: c.name,
duration: c.duration,
description: c.description,
});
setEditingCookieIndex(index);
setIsAddingCookie(false);
};
const handleSaveEditCookie = () => {
if (editingCookieIndex === null) return;
const newCookies = category.cookies.map((c, i) =>
i === editingCookieIndex
? { ...cookieForm }
: { name: c.name, duration: c.duration, description: c.description },
);
doUpdate({ cookies: newCookies }, () => {
setEditingCookieIndex(null);
setCookieForm({ name: "", duration: "", description: "" });
});
};
const handleCancelEditCookie = () => {
setEditingCookieIndex(null);
setCookieForm({ name: "", duration: "", description: "" });
};
const handleDeleteCookie = (index: number) => {
const newCookies = category.cookies
.filter((_, i) => i !== index)
.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
doUpdate({ cookies: newCookies });
};
const handleStartAddCookie = () => {
setCookieForm({ name: "", duration: "", description: "" });
setIsAddingCookie(true);
setEditingCookieIndex(null);
};
const handleSaveNewCookie = () => {
if (!cookieForm.name.trim()) return;
const newCookies = [
...category.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
})),
{ ...cookieForm },
];
doUpdate({ cookies: newCookies }, () => {
setIsAddingCookie(false);
setCookieForm({ name: "", duration: "", description: "" });
});
};
const handleCancelAddCookie = () => {
setIsAddingCookie(false);
setCookieForm({ name: "", duration: "", description: "" });
};
return (
<Card className="border overflow-hidden">
<div className="p-4">
{isEditingCategory
? (
<div className="space-y-3">
<Input
value={editName}
onChange={e => setEditName(e.target.value)}
placeholder={__("Category name")}
/>
<Textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
placeholder={__("Category description")}
rows={2}
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSaveCategory}
disabled={isUpdating}
>
{isUpdating ? __("Saving...") : __("Save")}
</Button>
<Button
variant="secondary"
onClick={handleCancelCategoryEdit}
>
{__("Cancel")}
</Button>
</div>
</div>
)
: (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">{category.name}</span>
{category.required && (
<Badge variant="neutral">{__("Required")}</Badge>
)}
</div>
<Button
variant="secondary"
onClick={() => setIsEditingCategory(true)}
>
<IconPencil size={14} />
{__("Edit")}
</Button>
</div>
)}
{!isEditingCategory && (
<p className="mt-1 text-sm text-muted-foreground">
{category.description}
</p>
)}
</div>
<table className="w-full text-left">
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Duration")}</Th>
<Th>{__("Description")}</Th>
<Th className="w-20" />
</Tr>
</Thead>
<Tbody>
{category.cookies.map((cookie, index) =>
editingCookieIndex === index
? (
<Tr key={index}>
<Td>
<Input
value={cookieForm.name}
onChange={e =>
setCookieForm({ ...cookieForm, name: e.target.value })}
placeholder={__("Cookie name")}
/>
</Td>
<Td>
<Input
value={cookieForm.duration}
onChange={e =>
setCookieForm({
...cookieForm,
duration: e.target.value,
})}
placeholder={__("e.g. 1 year")}
/>
</Td>
<Td>
<Input
value={cookieForm.description}
onChange={e =>
setCookieForm({
...cookieForm,
description: e.target.value,
})}
placeholder={__("Description")}
/>
</Td>
<Td>
<div className="flex items-center gap-1">
<Button
onClick={handleSaveEditCookie}
disabled={isUpdating}
>
{__("Save")}
</Button>
<Button
variant="secondary"
onClick={handleCancelEditCookie}
>
{__("Cancel")}
</Button>
</div>
</Td>
</Tr>
)
: (
<Tr key={index}>
<Td>
<code className="text-sm font-mono">{cookie.name}</code>
</Td>
<Td className="text-sm text-muted-foreground">
{cookie.duration}
</Td>
<Td className="text-sm text-muted-foreground">
{cookie.description}
</Td>
<Td>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleStartEditCookie(index)}
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil size={14} />
</button>
<button
type="button"
onClick={() => handleDeleteCookie(index)}
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-red-500"
>
<IconTrashCan size={14} />
</button>
</div>
</Td>
</Tr>
),
)}
{isAddingCookie && (
<Tr>
<Td className="pr-3">
<Input
value={cookieForm.name}
onChange={e =>
setCookieForm({ ...cookieForm, name: e.target.value })}
placeholder={__("Cookie name")}
/>
</Td>
<Td className="pr-3">
<Input
value={cookieForm.duration}
onChange={e =>
setCookieForm({ ...cookieForm, duration: e.target.value })}
placeholder={__("e.g. 1 year")}
/>
</Td>
<Td className="pr-3">
<Input
value={cookieForm.description}
onChange={e =>
setCookieForm({
...cookieForm,
description: e.target.value,
})}
placeholder={__("Description")}
/>
</Td>
<Td>
<div className="flex items-center gap-2">
<Button
onClick={handleSaveNewCookie}
disabled={isUpdating}
>
{__("Save")}
</Button>
<Button
variant="secondary"
onClick={handleCancelAddCookie}
>
{__("Cancel")}
</Button>
</div>
</Td>
</Tr>
)}
</Tbody>
</table>
{!isAddingCookie && (
<div className="p-3 border-t border-border-low">
<Button
variant="secondary"
onClick={handleStartAddCookie}
>
<IconPlusSmall size={14} />
{__("Add Cookie")}
</Button>
</div>
)}
</Card>
);
}

View File

@@ -12,8 +12,7 @@
// 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 { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieBannerSettingsPageQuery } from "#/__generated__/core/CookieBannerSettingsPageQuery.graphql";
@@ -21,68 +20,35 @@ import type { CookieBannerSettingsPageQuery } from "#/__generated__/core/CookieB
import { BannerSettingsForm } from "../_components/BannerSettingsForm";
import { CategoryList } from "../_components/CategoryList";
const settingsPageQuery = graphql`
export const cookieBannerSettingsPageQuery = 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
}
}
}
...BannerSettingsForm_cookieBanner
...CategoryList_cookieBanner
}
}
}
`;
export default function CookieBannerSettingsPage() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
if (!cookieBannerId) {
throw new Error("Missing :cookieBannerId param in route");
}
interface CookieBannerSettingsPageProps {
queryRef: PreloadedQuery<CookieBannerSettingsPageQuery>;
}
const data = useLazyLoadQuery<CookieBannerSettingsPageQuery>(
settingsPageQuery,
{ cookieBannerId },
);
export default function CookieBannerSettingsPage({
queryRef,
}: CookieBannerSettingsPageProps) {
const data = usePreloadedQuery(cookieBannerSettingsPageQuery, queryRef);
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}
/>
<BannerSettingsForm cookieBannerKey={data.node} />
<CategoryList cookieBannerKey={data.node} />
</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 { CookieBannerSettingsPageQuery } from "#/__generated__/core/CookieBannerSettingsPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import CookieBannerSettingsPage, { cookieBannerSettingsPageQuery } from "./CookieBannerSettingsPage";
export default function CookieBannerSettingsPageLoader() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const [queryRef, loadQuery] = useQueryLoader<CookieBannerSettingsPageQuery>(cookieBannerSettingsPageQuery);
useEffect(() => {
if (cookieBannerId) {
loadQuery({ cookieBannerId });
}
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerSettingsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -14,44 +14,12 @@
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 type { ReactNode } from "react";
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">
@@ -60,7 +28,7 @@ export default function CookieBannerSnippetPage() {
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} />
<CodeSnippets />
</Step>
<Step
@@ -72,7 +40,8 @@ export default function CookieBannerSnippetPage() {
<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 -->
<code>
{`<!-- Before: loads immediately -->
<script src="https://analytics.example.com/tracker.js"></script>
<!-- After: loads only when "analytics" consent is granted -->
@@ -80,56 +49,65 @@ export default function CookieBannerSnippetPage() {
type="text/plain"
data-cookie-consent="analytics"
data-src="https://analytics.example.com/tracker.js"
></script>`}</code>
></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
<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>
</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
<code>
{`<iframe
data-cookie-consent="marketing"
data-src="https://www.youtube.com/embed/VIDEO_ID"
width="560"
height="315"
></iframe>`}</code>
></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
<code>
{`<img
data-cookie-consent="analytics"
data-src="https://tracker.example.com/pixel.gif"
width="1"
height="1"
/>`}</code>
/>`}
</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
<code>
{`<link
rel="stylesheet"
data-cookie-consent="marketing"
data-href="https://widgets.example.com/styles.css"
/>`}</code>
/>`}
</code>
</pre>
</Card>
@@ -142,17 +120,14 @@ export default function CookieBannerSnippetPage() {
);
}
function Step({
number,
title,
description,
children,
}: {
interface StepProps {
number: number;
title: string;
description: string;
children: React.ReactNode;
}) {
children: ReactNode;
}
function Step({ number, title, description, children }: StepProps) {
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">

View File

@@ -15,22 +15,21 @@
import { useTranslate } from "@probo/i18n";
import { Button, Card, useToast } from "@probo/ui";
import { useState } from "react";
import { useParams } from "react-router";
interface CodeSnippetsProps {
bannerId: string;
baseUrl: string;
}
export function CodeSnippets({ bannerId, baseUrl }: CodeSnippetsProps) {
export function CodeSnippets() {
const { __ } = useTranslate();
const { toast } = useToast();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const baseUrl = `${window.location.origin}/api/cookie-banner/v1`;
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-banner-id="${cookieBannerId}"
data-base-url="${baseUrl}"
data-position="bottom-left"
></script>`,
@@ -43,7 +42,7 @@ registerThemedBanner();
// In your HTML or template:
// <probo-cookie-banner
// banner-id="${bannerId}"
// banner-id="${cookieBannerId}"
// base-url="${baseUrl}"
// position="bottom-left"
// ></probo-cookie-banner>`,
@@ -55,7 +54,7 @@ registerThemedBanner();
registerComponents();
// Build your own UI with headless components:
// <probo-cookie-banner-root banner-id="${bannerId}" base-url="${baseUrl}">
// <probo-cookie-banner-root banner-id="${cookieBannerId}" 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>

View File

@@ -45,6 +45,7 @@ export const cookieBannerRoutes = [
{
path: "",
loader: () => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw redirect("settings");
},
Component: Fragment,
@@ -52,7 +53,12 @@ export const cookieBannerRoutes = [
{
path: "settings",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPage")),
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPageLoader")),
},
{
path: "cookies",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader")),
},
{
path: "snippet",