diff --git a/apps/console/package.json b/apps/console/package.json index 932dd8df2..af5229143 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -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", diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index dc466d41f..f09d0d70b 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -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 && ( + + )} {organization.canUpdateOrganization && ( . +// +// 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 ( +
+ + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/NewCookieBannerPage.tsx b/apps/console/src/pages/organizations/cookie-banners/NewCookieBannerPage.tsx new file mode 100644 index 000000000..0cba2e4ba --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/NewCookieBannerPage.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(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) => { + 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 ( +
+ + + {__("Back")} + + + +
+ + setName(e.target.value)} + placeholder={__("My Website")} + required + /> + + + + setOrigin(e.target.value)} + placeholder="https://example.com" + required + /> + + + + setPrivacyPolicyUrl(e.target.value)} + placeholder="https://example.com/privacy" + required + /> + + +
+
+ + setConsentExpiryDays(e.target.value)} + min="1" + required + /> +
+ +
+ + +
+
+ + +
+
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx new file mode 100644 index 000000000..f4f9d5b09 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; +} + +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(activateMutation); + const [commitDeactivate, isDeactivating] = useMutation(deactivateMutation); + const [commitPublish, isPublishing] = useMutation(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 ( +
+ + + + + {banner.state === "ACTIVE" ? __("Active") : __("Inactive")} + + {hasDraft && ( + + )} + + + + + + + {__("Settings")} + + + + {__("Snippet")} + + + + {__("Theme")} + + + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayoutLoader.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayoutLoader.tsx new file mode 100644 index 000000000..9bf2b2d7d --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayoutLoader.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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); + + useEffect(() => { + if (cookieBannerId) { + loadQuery({ cookieBannerId }); + } + }, [loadQuery, cookieBannerId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/_components/BannerSettingsForm.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/BannerSettingsForm.tsx new file mode 100644 index 000000000..38171c72b --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/BannerSettingsForm.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(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 ( +
+

{__("Settings")}

+ +
+ + setName(e.target.value)} required /> + + + + setOrigin(e.target.value)} required /> + + + + setPrivacyPolicyUrl(e.target.value)} required /> + + +
+
+ + setConsentExpiryDays(e.target.value)} + min="1" + required + /> +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryDialog.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryDialog.tsx new file mode 100644 index 000000000..77dcb0b54 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryDialog.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; + }; + onOpenChange: (open: boolean) => void; +} + +export function CategoryDialog({ + cookieBannerId, + connectionId, + category, + onOpenChange, +}: CategoryDialogProps) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const isEditing = !!category; + + const [commitCreate, isCreating] = useMutation(createMutation); + const [commitUpdate, isUpdating] = useMutation(updateMutation); + + const [name, setName] = useState(category?.name ?? ""); + const [description, setDescription] = useState(category?.description ?? ""); + const [required, setRequired] = useState(category?.required ?? false); + const [cookies, setCookies] = useState( + 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 ( + onOpenChange(false)} + title={isEditing ? __("Edit Category") : __("Add Category")} + className="max-w-lg" + > +
+ + + setName(e.target.value)} required /> + + + +