diff --git a/apps/console/src/pages/organizations/compliance-page/CompliancePageLayout.tsx b/apps/console/src/pages/organizations/compliance-page/CompliancePageLayout.tsx index 51f720563..7448037e6 100644 --- a/apps/console/src/pages/organizations/compliance-page/CompliancePageLayout.tsx +++ b/apps/console/src/pages/organizations/compliance-page/CompliancePageLayout.tsx @@ -18,6 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import { safeOpenUrl } from "@probo/helpers"; import { usePageTitle } from "@probo/hooks"; import { useTranslate } from "@probo/i18n"; import { Badge, Button, IconBell2, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconShield, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui"; @@ -33,12 +34,10 @@ export const compliancePageLayoutQuery = graphql` organization: node(id: $organizationId) { __typename ... on Organization { - customDomain { - domain - } compliancePage: trustCenter { id active + publicUrl } } } @@ -58,11 +57,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery @@ -78,12 +73,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery - window.open( - compliancePageUrl, - "_blank", - "noopener,noreferrer", - )} + onClick={() => safeOpenUrl(compliancePageUrl)} > {__("Open")} @@ -99,10 +89,6 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery {__("Brand")} - - - {__("Domain")} - {__("References")} diff --git a/apps/console/src/pages/organizations/compliance-page/brand/CompliancePageBrandPage.tsx b/apps/console/src/pages/organizations/compliance-page/brand/CompliancePageBrandPage.tsx index a74b4ad90..532a8ab28 100644 --- a/apps/console/src/pages/organizations/compliance-page/brand/CompliancePageBrandPage.tsx +++ b/apps/console/src/pages/organizations/compliance-page/brand/CompliancePageBrandPage.tsx @@ -18,61 +18,26 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { acceptImage } from "@probo/helpers"; -import { useTranslate } from "@probo/i18n"; -import { - Button, - Card, - Dropzone, - FileButton, - IconTrashCan, - Label, - Spinner, - useToast, -} from "@probo/ui"; -import { type ChangeEventHandler, useState } from "react"; import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { graphql } from "relay-runtime"; -import type { CompliancePageBrandPage_updateMutation } from "#/__generated__/core/CompliancePageBrandPage_updateMutation.graphql"; import type { CompliancePageBrandPageQuery } from "#/__generated__/core/CompliancePageBrandPageQuery.graphql"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; -import { CompliancePageExternalUrlsSection } from "../overview/_components/CompliancePageExternalUrlsSection"; -import { CompliancePageFrameworkList } from "../overview/_components/CompliancePageFrameworkList"; +import { CompliancePageCustomLinksSection } from "./_components/CompliancePageCustomLinksSection"; +import { CompliancePageDomainsSection } from "./_components/CompliancePageDomainsSection"; +import { CompliancePageProfileSection } from "./_components/CompliancePageProfileSection"; +import { CompliancePageVisualIdentitySection } from "./_components/CompliancePageVisualIdentitySection"; export const compliancePageBrandPageQuery = graphql` query CompliancePageBrandPageQuery($organizationId: ID!) { organization: node(id: $organizationId) { __typename ... on Organization { + ...CompliancePageDomainsSection_organizationFragment compliancePage: trustCenter @required(action: THROW) { - id - logo { - downloadUrl - } - darkLogo { - downloadUrl - } - canUpdate: permission(action: "core:trust-center:update") - ...CompliancePageFrameworkList_compliancePageFragment - ...CompliancePageExternalUrlsSection_trustCenterFragment - } - } - } - } -`; - -const updateTrustCenterBrandMutation = graphql` - mutation CompliancePageBrandPage_updateMutation($input: UpdateTrustCenterBrandInput!) { - updateTrustCenterBrand(input: $input) { - trustCenter { - id - logo { - downloadUrl - } - darkLogo { - downloadUrl + ...CompliancePageProfileSection_compliancePageFragment + ...CompliancePageVisualIdentitySection_compliancePageFragment + ...CompliancePageCustomLinksSection_compliancePageFragment } } } @@ -82,290 +47,20 @@ const updateTrustCenterBrandMutation = graphql` export function CompliancePageBrandPage(props: { queryRef: PreloadedQuery }) { const { queryRef } = props; - const { __ } = useTranslate(); - const { toast } = useToast(); - const { organization } = usePreloadedQuery(compliancePageBrandPageQuery, queryRef); if (organization.__typename !== "Organization") { throw new Error("invalid type for node"); } - const trustCenterId = organization.compliancePage.id; - const logoDownloadUrl = organization.compliancePage.logo?.downloadUrl; - const darkLogoDownloadUrl = organization.compliancePage.darkLogo?.downloadUrl; - - const [logoPreview, setLogoPreview] = useState(null); - const [darkLogoPreview, setDarkLogoPreview] = useState(null); - - const [updateBrand, isUpdating] = useMutationWithToasts( - updateTrustCenterBrandMutation, - { - successMessage: __("Compliance page branding updated successfully"), - errorMessage: __("Failed to update compliance page branding"), - }, - ); - const disabled = isUpdating || !organization.compliancePage.canUpdate; - - const processLogoFile = (file: File, setPreview: (url: string) => void) => { - const reader = new FileReader(); - reader.onload = () => { - setPreview(reader.result as string); - }; - reader.readAsDataURL(file); - }; - - const handleLogoChange: ChangeEventHandler = (e) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (file.size > 5 * 1024 * 1024) { - toast({ - title: __("File size too large"), - description: __("The file size is too large. Please upload a file smaller than 5MB."), - variant: "error", - }); - return; - } - - processLogoFile(file, setLogoPreview); - - void updateBrand({ - variables: { - input: { - trustCenterId, - logoFile: null, - }, - }, - uploadables: { - "input.logoFile": file, - }, - onCompleted: () => { - setLogoPreview(null); - }, - }); - }; - - const handleDarkLogoChange: ChangeEventHandler = (e) => { - const file = e.target.files?.[0]; - if (!file) return; - - if (file.size > 5 * 1024 * 1024) { - toast({ - title: __("File size too large"), - description: __("The file size is too large. Please upload a file smaller than 5MB."), - variant: "error", - }); - return; - } - - processLogoFile(file, setDarkLogoPreview); - - void updateBrand({ - variables: { - input: { - trustCenterId, - darkLogoFile: null, - }, - }, - uploadables: { - "input.darkLogoFile": file, - }, - onCompleted: () => { - setDarkLogoPreview(null); - }, - }); - }; - - const handleLogoDrop = (files: File[]) => { - const file = files[0]; - if (!file) return; - - processLogoFile(file, setLogoPreview); - - void updateBrand({ - variables: { - input: { - trustCenterId, - logoFile: null, - }, - }, - uploadables: { - "input.logoFile": file, - }, - onCompleted: () => { - setLogoPreview(null); - }, - }); - }; - - const handleDarkLogoDrop = (files: File[]) => { - const file = files[0]; - if (!file) return; - - processLogoFile(file, setDarkLogoPreview); - - void updateBrand({ - variables: { - input: { - trustCenterId, - darkLogoFile: null, - }, - }, - uploadables: { - "input.darkLogoFile": file, - }, - onCompleted: () => { - setDarkLogoPreview(null); - }, - }); - }; - - const handleRemoveLogo = async () => { - await updateBrand({ - variables: { - input: { - trustCenterId, - logoFile: null, - }, - }, - onSuccess: () => { - setLogoPreview(null); - }, - }); - }; - - const handleRemoveDarkLogo = async () => { - await updateBrand({ - variables: { - input: { - trustCenterId, - darkLogoFile: null, - }, - }, - onSuccess: () => { - setDarkLogoPreview(null); - }, - }); - }; - - const currentLogoUrl = logoPreview || logoDownloadUrl; - const currentDarkLogoUrl = darkLogoPreview || darkLogoDownloadUrl; - return (
-
-
-

{__("Branding")}

- {isUpdating && } -
+ - -
-
- -

- {__("This logo will be displayed on your public compliance page.")} -

+ - {currentLogoUrl - ? ( -
-
- {__("Compliance -
- - {isUpdating ? __("Uploading...") : __("Change logo")} - -
- ) - : ( - - )} -
-
- -

- {__("This logo will be used when dark mode is enabled.")} -

+ - {currentDarkLogoUrl - ? ( -
-
- {__("Compliance -
- - {isUpdating ? __("Uploading...") : __("Change dark logo")} - -
- ) - : ( - - )} -
-
-
-
- -
-
-

{__("Frameworks")}

-

- {__("Select which frameworks to display as badges on your compliance page")} -

-
- -
- - +
); } diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkDialog.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkDialog.tsx new file mode 100644 index 000000000..08960099d --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkDialog.tsx @@ -0,0 +1,201 @@ +// 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 { detectSocialName } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + Spinner, + useDialogRef, +} from "@probo/ui"; +import { forwardRef, useImperativeHandle, useState } from "react"; +import { ConnectionHandler, graphql, readInlineData } from "relay-runtime"; +import { z } from "zod"; + +import type { CompliancePageCustomLinkDialog_createMutation } from "#/__generated__/core/CompliancePageCustomLinkDialog_createMutation.graphql"; +import type { CompliancePageCustomLinkDialog_customLink$key } from "#/__generated__/core/CompliancePageCustomLinkDialog_customLink.graphql"; +import type { CompliancePageCustomLinkDialog_updateMutation } from "#/__generated__/core/CompliancePageCustomLinkDialog_updateMutation.graphql"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; +import { useMutation } from "#/lib/relay/useMutation"; + +const customLinkFragment = graphql` + fragment CompliancePageCustomLinkDialog_customLink on ComplianceCustomLink @inline { + id + name + url + } +`; + +const createMutation = graphql` + mutation CompliancePageCustomLinkDialog_createMutation($input: CreateComplianceCustomLinkInput!) { + createComplianceCustomLink(input: $input) { + complianceCustomLinkEdge { + node { + id + name + url + rank + ...CompliancePageCustomLinkListItem_customLink + ...CompliancePageCustomLinkDialog_customLink + } + } + } + } +`; + +const updateMutation = graphql` + mutation CompliancePageCustomLinkDialog_updateMutation($input: UpdateComplianceCustomLinkInput!) { + updateComplianceCustomLink(input: $input) { + complianceCustomLink { + id + name + url + rank + ...CompliancePageCustomLinkListItem_customLink + ...CompliancePageCustomLinkDialog_customLink + } + } + } +`; + +export interface CompliancePageCustomLinkDialogRef { + openCreate: (compliancePageId: string, connectionId: string) => void; + openEdit: (customLinkKey: CompliancePageCustomLinkDialog_customLink$key) => void; +} + +export const CompliancePageCustomLinkDialog = forwardRef( + function CompliancePageCustomLinkDialog(_, ref) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [mode, setMode] = useState<"create" | "edit">("create"); + const [compliancePageId, setCompliancePageId] = useState(""); + const [connectionId, setConnectionId] = useState(""); + const [editId, setEditId] = useState(null); + + const schema = z.object({ + name: z.string().min(1, __("Name is required")), + url: z.string().url(__("Please enter a valid URL")), + }); + + const [create, isCreating] = useMutation( + createMutation, + { successMessage: __("Link added successfully."), errorToast: __("Failed to add link.") }, + ); + + const [update, isUpdating] = useMutation( + updateMutation, + { successMessage: __("Link updated successfully."), errorToast: __("Failed to update link.") }, + ); + + const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useFormWithSchema(schema, { + defaultValues: { name: "", url: "" }, + }); + + const [nameAutoDetected, setNameAutoDetected] = useState(false); + + const handleUrlChange = (e: React.ChangeEvent) => { + const url = e.target.value; + const detected = detectSocialName(url); + if (detected && (nameAutoDetected || watch("name") === "")) { + setValue("name", detected, { shouldValidate: true }); + setNameAutoDetected(true); + } else if (!detected && nameAutoDetected) { + setValue("name", "", { shouldValidate: false }); + setNameAutoDetected(false); + } + }; + + useImperativeHandle(ref, () => ({ + openCreate: (pageId, cId) => { + setMode("create"); + setCompliancePageId(pageId); + setConnectionId(cId); + setEditId(null); + setNameAutoDetected(false); + reset({ name: "", url: "" }); + dialogRef.current?.open(); + }, + openEdit: (customLinkKey) => { + const customLink = readInlineData(customLinkFragment, customLinkKey); + setMode("edit"); + setEditId(customLink.id); + setNameAutoDetected(false); + reset({ name: customLink.name, url: customLink.url }); + dialogRef.current?.open(); + }, + })); + + const onSubmit = async (data: z.infer) => { + if (mode === "create") { + await create({ + variables: { + input: { trustCenterId: compliancePageId, name: data.name, url: data.url }, + }, + updater: (store) => { + const payload = store.getRootField("createComplianceCustomLink"); + const edge = payload?.getLinkedRecord("complianceCustomLinkEdge"); + if (!edge) return; + const connection = store.get(connectionId); + if (!connection) return; + ConnectionHandler.insertEdgeAfter(connection, edge); + }, + }); + } else if (editId) { + await update({ + variables: { input: { id: editId, name: data.name, url: data.url } }, + }); + } + + reset(); + dialogRef.current?.close(); + }; + + const isSubmitting = isCreating || isUpdating; + const title = mode === "create" ? __("Add link") : __("Edit link"); + + return ( + reset()}> +
void handleSubmit(onSubmit)(e)}> + + + + + + + +
+
+ ); + }, +); diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkList.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkList.tsx new file mode 100644 index 000000000..ffe84c0c1 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkList.tsx @@ -0,0 +1,201 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { Button, IconChevronRight, IconPlusLarge } from "@probo/ui"; +import { useCallback, useRef, useState, useTransition } from "react"; +import { useRefetchableFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { CompliancePageCustomLinkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageCustomLinkList_compliancePageFragment.graphql"; +import type { CompliancePageCustomLinkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageCustomLinkList_compliancePageRefetchQuery.graphql"; +import type { CompliancePageCustomLinkList_updateRankMutation } from "#/__generated__/core/CompliancePageCustomLinkList_updateRankMutation.graphql"; +import { useMutation } from "#/lib/relay/useMutation"; + +import { CompliancePageCustomLinkDialog, type CompliancePageCustomLinkDialogRef } from "./CompliancePageCustomLinkDialog"; +import { CompliancePageCustomLinkListItem } from "./CompliancePageCustomLinkListItem"; + +const compliancePageFragment = graphql` + fragment CompliancePageCustomLinkList_compliancePageFragment on TrustCenter + @refetchable(queryName: "CompliancePageCustomLinkList_compliancePageRefetchQuery") + @argumentDefinitions( + first: { type: Int, defaultValue: 100 } + after: { type: CursorKey, defaultValue: null } + order: { type: ComplianceCustomLinkOrder, defaultValue: { field: RANK, direction: ASC } } + ) { + id + canUpdate: permission(action: "compliance-portal:portal:update") + customLinks(first: $first, after: $after, orderBy: $order) + @connection(key: "CompliancePageCustomLinkList_customLinks", filters: ["orderBy"]) { + __id + edges { + node { + id + name + url + rank + ...CompliancePageCustomLinkListItem_customLink + ...CompliancePageCustomLinkDialog_customLink + } + } + } + } +`; + +const updateRankMutation = graphql` + mutation CompliancePageCustomLinkList_updateRankMutation($input: UpdateComplianceCustomLinkInput!) { + updateComplianceCustomLink(input: $input) { + complianceCustomLink { + id + rank + } + } + } +`; + +export interface CompliancePageCustomLinkListProps { + compliancePageRef: CompliancePageCustomLinkList_compliancePageFragment$key; +} + +export function CompliancePageCustomLinkList(props: CompliancePageCustomLinkListProps) { + const { __ } = useTranslate(); + const [, startTransition] = useTransition(); + const dialogRef = useRef(null); + + const [compliancePage, refetch] = useRefetchableFragment< + CompliancePageCustomLinkList_compliancePageRefetchQuery, + CompliancePageCustomLinkList_compliancePageFragment$key + >(compliancePageFragment, props.compliancePageRef); + + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const [updateRank] = useMutation( + updateRankMutation, + { successMessage: __("Order updated."), errorToast: __("Failed to update order.") }, + ); + + const edges = compliancePage.customLinks.edges; + const readOnly = !compliancePage.canUpdate; + const connectionId = compliancePage.customLinks.__id; + const hasLinks = edges.length > 0; + + const handleCreate = () => { + dialogRef.current?.openCreate(compliancePage.id, connectionId); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex !== index) setDragOverIndex(index); + }; + + const handleDrop = useCallback( + async (targetIndex: number) => { + if (draggedIndex === null || draggedIndex === targetIndex) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } + + const draggedEdge = edges[draggedIndex]; + const targetRank = edges[targetIndex].node.rank; + const draggedId = draggedEdge.node.id; + + await updateRank({ + variables: { + input: { + id: draggedId, + name: draggedEdge.node.name, + url: draggedEdge.node.url, + rank: targetRank, + }, + }, + updater: (store) => { + const connection = store.get(connectionId); + if (!connection) return; + const storeEdges = connection.getLinkedRecords("edges"); + if (!storeEdges) return; + const fromIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId); + const toIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === edges[targetIndex].node.id); + if (fromIdx === -1 || toIdx === -1) return; + const reordered = [...storeEdges]; + const [moved] = reordered.splice(fromIdx, 1); + reordered.splice(toIdx, 0, moved); + connection.setLinkedRecords(reordered, "edges"); + }, + onCompleted: (_, errors) => { + startTransition(() => { + refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" }); + }); + }, + }); + + setDraggedIndex(null); + setDragOverIndex(null); + }, + [draggedIndex, edges, connectionId, updateRank, refetch, startTransition], + ); + + return ( +
+ {!readOnly && hasLinks && ( +
+ +
+ )} + + {edges.map(({ node }, index) => ( + setDraggedIndex(index)} + onDragOver={e => handleDragOver(e, index)} + onDrop={() => void handleDrop(index)} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + onEdit={() => dialogRef.current?.openEdit(node)} + /> + ))} + + {!hasLinks && !readOnly && ( +
+

+ {__( + "Add links to your social profiles, website, or other resources visitors can explore.", + )} +

+ +
+ )} + + {edges.length > 1 && !readOnly && ( +

+ {__("Drag and drop to change the displayed order")} +

+ )} + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkListItem.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkListItem.tsx new file mode 100644 index 000000000..881d90d64 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkListItem.tsx @@ -0,0 +1,163 @@ +// 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 { detectSocialName, safeOpenUrl } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Card, + IconArrowLink, + IconPencil, + IconTrashCan, + SocialIcon, +} from "@probo/ui"; +import { useState } from "react"; +import { useFragment } from "react-relay"; +import { ConnectionHandler, graphql } from "relay-runtime"; + +import type { CompliancePageCustomLinkListItem_customLink$key } from "#/__generated__/core/CompliancePageCustomLinkListItem_customLink.graphql"; +import type { CompliancePageCustomLinkListItem_deleteMutation } from "#/__generated__/core/CompliancePageCustomLinkListItem_deleteMutation.graphql"; +import { useMutation } from "#/lib/relay/useMutation"; + +const customLinkFragment = graphql` + fragment CompliancePageCustomLinkListItem_customLink on ComplianceCustomLink { + id + name + url + } +`; + +const deleteMutation = graphql` + mutation CompliancePageCustomLinkListItem_deleteMutation($input: DeleteComplianceCustomLinkInput!) { + deleteComplianceCustomLink(input: $input) { + deletedComplianceCustomLinkId + } + } +`; + +export interface CompliancePageCustomLinkListItemProps { + customLinkKey: CompliancePageCustomLinkListItem_customLink$key; + connectionId: string; + readOnly: boolean; + isDragging: boolean; + isDropTarget: boolean; + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: () => void; + onDragEnd: () => void; + onEdit: () => void; +} + +export function CompliancePageCustomLinkListItem(props: CompliancePageCustomLinkListItemProps) { + const { + customLinkKey, + connectionId, + readOnly, + isDragging, + isDropTarget, + onDragStart, + onDragOver, + onDrop, + onDragEnd, + onEdit, + } = props; + + const { __ } = useTranslate(); + const [isMouseDown, setIsMouseDown] = useState(false); + + const customLink = useFragment(customLinkFragment, customLinkKey); + + const [deleteLink] = useMutation( + deleteMutation, + { successMessage: __("Link removed."), errorToast: __("Failed to remove link.") }, + ); + + const handleDelete = () => { + void deleteLink({ + variables: { input: { id: customLink.id } }, + updater: (store) => { + const connection = store.get(connectionId); + if (!connection) return; + ConnectionHandler.deleteNode(connection, customLink.id); + }, + }); + }; + + const draggable = !readOnly; + + const className = [ + isDragging && "opacity-50 cursor-grabbing", + !isDragging && draggable && !isMouseDown && "cursor-grab", + !isDragging && draggable && isMouseDown && "cursor-grabbing", + isDropTarget && "ring-2 ring-primary-500", + ] + .filter(Boolean) + .join(" "); + + return ( +
setIsMouseDown(true) : undefined} + onMouseUp={draggable ? () => setIsMouseDown(false) : undefined} + onMouseLeave={draggable ? () => setIsMouseDown(false) : undefined} + className={className} + > + +
+
+
+ + {customLink.name} +
+

{customLink.url}

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinksSection.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinksSection.tsx new file mode 100644 index 000000000..7f735eb09 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinksSection.tsx @@ -0,0 +1,50 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { CompliancePageCustomLinksSection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageCustomLinksSection_compliancePageFragment.graphql"; + +import { CompliancePageCustomLinkList } from "./CompliancePageCustomLinkList"; + +const compliancePageFragment = graphql` + fragment CompliancePageCustomLinksSection_compliancePageFragment on TrustCenter { + ...CompliancePageCustomLinkList_compliancePageFragment + } +`; + +export interface CompliancePageCustomLinksSectionProps { + compliancePageRef: CompliancePageCustomLinksSection_compliancePageFragment$key; +} + +export function CompliancePageCustomLinksSection(props: CompliancePageCustomLinksSectionProps) { + const { __ } = useTranslate(); + + const compliancePage = useFragment(compliancePageFragment, props.compliancePageRef); + + return ( +
+
+

{__("Custom links")}

+

+ {__("Social profiles and other links shown alongside your contact details.")} +

+
+ + +
+ ); +} diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageDomainsSection.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageDomainsSection.tsx new file mode 100644 index 000000000..7f37aed51 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageDomainsSection.tsx @@ -0,0 +1,93 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { Button, IconChevronRight } from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { CompliancePageDomainsSection_organizationFragment$key } from "#/__generated__/core/CompliancePageDomainsSection_organizationFragment.graphql"; + +import { CompliancePageDomainCard } from "../../domain/_components/CompliancePageDomainCard"; +import { NewCompliancePageDomainDialog } from "../../domain/_components/NewCompliancePageDomainDialog"; + +const organizationFragment = graphql` + fragment CompliancePageDomainsSection_organizationFragment on Organization { + canCreateCustomDomain: permission(action: "compliance-portal:custom-domain:create") + compliancePage: trustCenter @required(action: THROW) { + id + defaultDomain { + id + ...CompliancePageDomainCardFragment + } + customDomain { + id + ...CompliancePageDomainCardFragment + } + } + } +`; + +export function CompliancePageDomainsSection(props: { + organizationRef: CompliancePageDomainsSection_organizationFragment$key; +}) { + const { __ } = useTranslate(); + + const organization = useFragment(organizationFragment, props.organizationRef); + const compliancePageId = organization.compliancePage.id; + const defaultDomain = organization.compliancePage.defaultDomain; + const customDomain = organization.compliancePage.customDomain; + + return ( +
+
+

{__("Domains")}

+

+ {__( + "Your compliance page is always available on its default probopage.com subdomain. You can also serve it on one custom domain of your own.", + )} +

+
+ +
+ {defaultDomain && ( + + )} + + {customDomain + ? ( + + ) + : organization.canCreateCustomDomain && ( +
+

+ {__( + "Use your own domain to make your compliance page feel more professional.", + )} +

+ + + +
+ )} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageProfileSection.tsx b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageProfileSection.tsx new file mode 100644 index 000000000..11e72c8e6 --- /dev/null +++ b/apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageProfileSection.tsx @@ -0,0 +1,142 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { Button, Card, Field, Label, Spinner, Textarea } from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; +import { z } from "zod"; + +import type { CompliancePageProfileSection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageProfileSection_compliancePageFragment.graphql"; +import { useUpdateCompliancePageMutation } from "#/hooks/graph/CompliancePageGraph"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const compliancePageFragment = graphql` + fragment CompliancePageProfileSection_compliancePageFragment on TrustCenter { + id + description + websiteUrl + email + headquarterAddress + canUpdate: permission(action: "compliance-portal:portal:update") + } +`; + +const profileSchema = z.object({ + description: z.string().optional(), + websiteUrl: z.string().optional(), + email: z.string().optional(), + headquarterAddress: z.string().optional(), +}); + +type ProfileFormData = z.infer; + +export function CompliancePageProfileSection(props: { + compliancePageRef: CompliancePageProfileSection_compliancePageFragment$key; +}) { + const { __ } = useTranslate(); + + const { canUpdate, ...compliancePage } = useFragment( + compliancePageFragment, + props.compliancePageRef, + ); + + const [updateCompliancePage, isUpdating] = useUpdateCompliancePageMutation(); + + const { formState, handleSubmit, register } = useFormWithSchema(profileSchema, { + defaultValues: { + description: compliancePage.description || "", + websiteUrl: compliancePage.websiteUrl || "", + email: compliancePage.email || "", + headquarterAddress: compliancePage.headquarterAddress || "", + }, + }); + + const readOnly = formState.isSubmitting || !canUpdate; + + const onSubmit = handleSubmit(async (data: ProfileFormData) => { + await updateCompliancePage({ + variables: { + input: { + trustCenterId: compliancePage.id, + description: data.description || null, + websiteUrl: data.websiteUrl || null, + email: data.email || null, + headquarterAddress: data.headquarterAddress || null, + }, + }, + }); + }); + + return ( +
void onSubmit(e)} className="space-y-4"> +
+
+

{__("General information")}

+

+ {__("Description and contact details shown to visitors.")} +

+
+ {formState.isSubmitting && } +
+ +
+ +