From de367f032f5e4f709216a6e66816faabaf8c8cdc Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 10 Jul 2026 15:17:08 +0200 Subject: [PATCH] Restructure console brand and overview pages Decompose the brand page into profile, domains, visual identity, and custom link sections, and move frameworks and the NDA card onto the overview page as dedicated fragment components. Remove the standalone domain page and redirect its route to brand, where domains now live. Signed-off-by: Bryan Frimin --- .../compliance-page/CompliancePageLayout.tsx | 22 +- .../brand/CompliancePageBrandPage.tsx | 329 +----------- .../CompliancePageCustomLinkDialog.tsx | 201 ++++++++ .../CompliancePageCustomLinkList.tsx | 201 ++++++++ .../CompliancePageCustomLinkListItem.tsx | 163 ++++++ .../CompliancePageCustomLinksSection.tsx | 50 ++ .../CompliancePageDomainsSection.tsx | 93 ++++ .../CompliancePageProfileSection.tsx | 142 +++++ .../CompliancePageVisualIdentitySection.tsx | 308 +++++++++++ .../domain/CompliancePageDomainPage.tsx | 90 ---- .../domain/CompliancePageDomainPageLoader.tsx | 59 --- .../_components/CompliancePageDomainCard.tsx | 68 +-- .../DeleteCompliancePageDomainDialog.tsx | 20 +- .../NewCompliancePageDomainDialog.tsx | 48 +- .../overview/CompliancePageOverviewPage.tsx | 6 +- .../CompliancePageExternalUrlsSection.tsx | 483 ------------------ .../CompliancePageFrameworkList.tsx | 349 ++----------- .../CompliancePageFrameworkListItem.tsx | 225 ++++++++ .../CompliancePageFrameworksSection.tsx | 56 ++ .../_components/CompliancePageNDACard.tsx | 115 +++++ .../_components/CompliancePageNDASection.tsx | 209 ++++---- .../CompliancePageStatusSection.tsx | 6 +- .../organizations/compliance-page/routes.ts | 9 +- 23 files changed, 1786 insertions(+), 1466 deletions(-) create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkDialog.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkList.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinkListItem.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageCustomLinksSection.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageDomainsSection.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageProfileSection.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/brand/_components/CompliancePageVisualIdentitySection.tsx delete mode 100644 apps/console/src/pages/organizations/compliance-page/domain/CompliancePageDomainPage.tsx delete mode 100644 apps/console/src/pages/organizations/compliance-page/domain/CompliancePageDomainPageLoader.tsx delete mode 100644 apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageExternalUrlsSection.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageFrameworkListItem.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageFrameworksSection.tsx create mode 100644 apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageNDACard.tsx 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 && } +
+ +
+ +