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 <bryan@probo.com>
This commit is contained in:
@@ -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<Complianc
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
|
||||
const compliancePageUrl = organization.compliancePage?.id
|
||||
? organization.customDomain?.domain
|
||||
? `https://${organization.customDomain.domain}`
|
||||
: `${window.location.origin}/trust/${organization.compliancePage.id}`
|
||||
: null;
|
||||
const compliancePageUrl = organization.compliancePage?.publicUrl || null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -78,12 +73,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
|
||||
{organization.compliancePage?.active && compliancePageUrl && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
compliancePageUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)}
|
||||
onClick={() => safeOpenUrl(compliancePageUrl)}
|
||||
>
|
||||
{__("Open")}
|
||||
</Button>
|
||||
@@ -99,10 +89,6 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
|
||||
<IconPencil className="size-4" />
|
||||
{__("Brand")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/compliance-page/domain`}>
|
||||
<IconStore className="size-4" />
|
||||
{__("Domain")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/compliance-page/references`}>
|
||||
<IconCheckmark1 className="size-4" />
|
||||
{__("References")}
|
||||
|
||||
@@ -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<CompliancePageBrandPageQuery> }) {
|
||||
const { queryRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { organization } = usePreloadedQuery<CompliancePageBrandPageQuery>(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<string | null>(null);
|
||||
const [darkLogoPreview, setDarkLogoPreview] = useState<string | null>(null);
|
||||
|
||||
const [updateBrand, isUpdating] = useMutationWithToasts<CompliancePageBrandPage_updateMutation>(
|
||||
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<HTMLInputElement> = (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<HTMLInputElement> = (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 (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-medium">{__("Branding")}</h2>
|
||||
{isUpdating && <Spinner />}
|
||||
</div>
|
||||
<CompliancePageProfileSection compliancePageRef={organization.compliancePage} />
|
||||
|
||||
<Card padded className="space-y-4">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="flex-1">
|
||||
<Label>{__("Logo")}</Label>
|
||||
<p className="text-sm text-txt-tertiary mb-3">
|
||||
{__("This logo will be displayed on your public compliance page.")}
|
||||
</p>
|
||||
<CompliancePageDomainsSection organizationRef={organization} />
|
||||
|
||||
{currentLogoUrl
|
||||
? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
|
||||
<img
|
||||
src={currentLogoUrl}
|
||||
alt={__("Compliance page logo")}
|
||||
className="h-16 max-w-xs object-contain"
|
||||
/>
|
||||
</div>
|
||||
<FileButton
|
||||
disabled={disabled}
|
||||
onChange={handleLogoChange}
|
||||
variant="secondary"
|
||||
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
|
||||
>
|
||||
{isUpdating ? __("Uploading...") : __("Change logo")}
|
||||
</FileButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => void handleRemoveLogo()}
|
||||
disabled={disabled}
|
||||
aria-label={__("Remove logo")}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Dropzone
|
||||
description={__("Upload logo image (PNG, JPG, SVG, WEBP up to 5MB)")}
|
||||
isUploading={isUpdating}
|
||||
onDrop={handleLogoDrop}
|
||||
accept={acceptImage}
|
||||
maxSize={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>{__("Dark mode logo")}</Label>
|
||||
<p className="text-sm text-txt-tertiary mb-3">
|
||||
{__("This logo will be used when dark mode is enabled.")}
|
||||
</p>
|
||||
<CompliancePageVisualIdentitySection compliancePageRef={organization.compliancePage} />
|
||||
|
||||
{currentDarkLogoUrl
|
||||
? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="border border-border-solid rounded-md p-4 bg-gray-900">
|
||||
<img
|
||||
src={currentDarkLogoUrl}
|
||||
alt={__("Compliance page dark logo")}
|
||||
className="h-16 max-w-xs object-contain"
|
||||
/>
|
||||
</div>
|
||||
<FileButton
|
||||
disabled={disabled}
|
||||
onChange={handleDarkLogoChange}
|
||||
variant="secondary"
|
||||
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
|
||||
>
|
||||
{isUpdating ? __("Uploading...") : __("Change dark logo")}
|
||||
</FileButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => void handleRemoveDarkLogo()}
|
||||
disabled={disabled}
|
||||
aria-label={__("Remove dark logo")}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Dropzone
|
||||
description={__("Upload dark logo image (PNG, JPG, SVG, WEBP up to 5MB)")}
|
||||
isUploading={isUpdating}
|
||||
onDrop={handleDarkLogoDrop}
|
||||
accept={acceptImage}
|
||||
maxSize={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">{__("Frameworks")}</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Select which frameworks to display as badges on your compliance page")}
|
||||
</p>
|
||||
</div>
|
||||
<CompliancePageFrameworkList compliancePageRef={organization.compliancePage} />
|
||||
</div>
|
||||
|
||||
<CompliancePageExternalUrlsSection trustCenterRef={organization.compliancePage} />
|
||||
<CompliancePageCustomLinksSection compliancePageRef={organization.compliancePage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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<CompliancePageCustomLinkDialogRef>(
|
||||
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<string | null>(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<CompliancePageCustomLinkDialog_createMutation>(
|
||||
createMutation,
|
||||
{ successMessage: __("Link added successfully."), errorToast: __("Failed to add link.") },
|
||||
);
|
||||
|
||||
const [update, isUpdating] = useMutation<CompliancePageCustomLinkDialog_updateMutation>(
|
||||
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<HTMLInputElement>) => {
|
||||
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<typeof schema>) => {
|
||||
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 (
|
||||
<Dialog ref={dialogRef} title={title} onClose={() => reset()}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-6">
|
||||
<Field
|
||||
{...register("url", { onChange: handleUrlChange })}
|
||||
label={__("URL")}
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
error={errors.url?.message}
|
||||
/>
|
||||
<Field
|
||||
{...register("name")}
|
||||
label={__("Name")}
|
||||
type="text"
|
||||
required
|
||||
placeholder={__("e.g. Twitter, LinkedIn")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
|
||||
{mode === "create" ? __("Add link") : __("Save changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, 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<CompliancePageCustomLinkDialogRef>(null);
|
||||
|
||||
const [compliancePage, refetch] = useRefetchableFragment<
|
||||
CompliancePageCustomLinkList_compliancePageRefetchQuery,
|
||||
CompliancePageCustomLinkList_compliancePageFragment$key
|
||||
>(compliancePageFragment, props.compliancePageRef);
|
||||
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [updateRank] = useMutation<CompliancePageCustomLinkList_updateRankMutation>(
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
{!readOnly && hasLinks && (
|
||||
<div className="flex justify-end">
|
||||
<Button icon={IconPlusLarge} onClick={handleCreate}>
|
||||
{__("Add link")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edges.map(({ node }, index) => (
|
||||
<CompliancePageCustomLinkListItem
|
||||
key={node.id}
|
||||
customLinkKey={node}
|
||||
connectionId={connectionId}
|
||||
readOnly={readOnly}
|
||||
isDragging={draggedIndex === index}
|
||||
isDropTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
onDragStart={() => setDraggedIndex(index)}
|
||||
onDragOver={e => handleDragOver(e, index)}
|
||||
onDrop={() => void handleDrop(index)}
|
||||
onDragEnd={() => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}}
|
||||
onEdit={() => dialogRef.current?.openEdit(node)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!hasLinks && !readOnly && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
|
||||
<p className="max-w-md text-center text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Add links to your social profiles, website, or other resources visitors can explore.",
|
||||
)}
|
||||
</p>
|
||||
<Button iconAfter={IconChevronRight} onClick={handleCreate}>
|
||||
{__("Add link")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edges.length > 1 && !readOnly && (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Drag and drop to change the displayed order")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CompliancePageCustomLinkDialog ref={dialogRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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<CompliancePageCustomLinkListItem_deleteMutation>(
|
||||
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 (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={draggable ? onDragStart : undefined}
|
||||
onDragOver={draggable ? onDragOver : undefined}
|
||||
onDrop={draggable ? onDrop : undefined}
|
||||
onDragEnd={draggable ? onDragEnd : undefined}
|
||||
onMouseDown={draggable ? () => setIsMouseDown(true) : undefined}
|
||||
onMouseUp={draggable ? () => setIsMouseDown(false) : undefined}
|
||||
onMouseLeave={draggable ? () => setIsMouseDown(false) : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Card padded>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SocialIcon
|
||||
socialName={detectSocialName(customLink.url)}
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="font-medium">{customLink.name}</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-txt-secondary">{customLink.url}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArrowLink}
|
||||
onClick={() => safeOpenUrl(customLink.url)}
|
||||
aria-label={__("Open link")}
|
||||
/>
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPencil}
|
||||
onClick={onEdit}
|
||||
aria-label={__("Edit link")}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
aria-label={__("Remove link")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { 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 (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("Custom links")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Social profiles and other links shown alongside your contact details.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CompliancePageCustomLinkList compliancePageRef={compliancePage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, 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 (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("Domains")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Your compliance page is always available on its default probopage.com subdomain. You can also serve it on one custom domain of your own.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{defaultDomain && (
|
||||
<CompliancePageDomainCard
|
||||
fKey={defaultDomain}
|
||||
compliancePageId={compliancePageId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customDomain
|
||||
? (
|
||||
<CompliancePageDomainCard
|
||||
fKey={customDomain}
|
||||
compliancePageId={compliancePageId}
|
||||
/>
|
||||
)
|
||||
: organization.canCreateCustomDomain && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
|
||||
<p className="max-w-md text-center text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Use your own domain to make your compliance page feel more professional.",
|
||||
)}
|
||||
</p>
|
||||
<NewCompliancePageDomainDialog compliancePageId={compliancePageId}>
|
||||
<Button iconAfter={IconChevronRight}>{__("Configure")}</Button>
|
||||
</NewCompliancePageDomainDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Field, 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<typeof profileSchema>;
|
||||
|
||||
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 (
|
||||
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("General information")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Description and contact details shown to visitors.")}
|
||||
</p>
|
||||
</div>
|
||||
{formState.isSubmitting && <Spinner />}
|
||||
</div>
|
||||
<Card padded className="space-y-4">
|
||||
<div>
|
||||
<Label>{__("Description")}</Label>
|
||||
<Textarea
|
||||
{...register("description")}
|
||||
readOnly={readOnly}
|
||||
name="description"
|
||||
placeholder={__("Brief description for visitors")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field
|
||||
{...register("websiteUrl")}
|
||||
readOnly={readOnly}
|
||||
name="websiteUrl"
|
||||
type="url"
|
||||
label={__("Website URL")}
|
||||
placeholder={__("https://example.com")}
|
||||
/>
|
||||
<Field
|
||||
{...register("email")}
|
||||
readOnly={readOnly}
|
||||
name="email"
|
||||
type="email"
|
||||
label={__("Email")}
|
||||
placeholder={__("contact@example.com")}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
{...register("headquarterAddress")}
|
||||
readOnly={readOnly}
|
||||
name="headquarterAddress"
|
||||
label={__("Headquarter Address")}
|
||||
placeholder={__("123 Main St, City, Country")}
|
||||
/>
|
||||
|
||||
{formState.isDirty && canUpdate && (
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button type="submit" disabled={formState.isSubmitting || isUpdating}>
|
||||
{formState.isSubmitting || isUpdating
|
||||
? __("Updating...")
|
||||
: __("Save changes")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
IconTrashCan,
|
||||
Label,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ChangeEventHandler, useState } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageVisualIdentitySection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageVisualIdentitySection_compliancePageFragment.graphql";
|
||||
import type { CompliancePageVisualIdentitySection_updateMutation } from "#/__generated__/core/CompliancePageVisualIdentitySection_updateMutation.graphql";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
const compliancePageFragment = graphql`
|
||||
fragment CompliancePageVisualIdentitySection_compliancePageFragment on TrustCenter {
|
||||
id
|
||||
logo {
|
||||
downloadUrl
|
||||
}
|
||||
darkLogo {
|
||||
downloadUrl
|
||||
}
|
||||
canUpdate: permission(action: "compliance-portal:portal:update")
|
||||
}
|
||||
`;
|
||||
|
||||
const updateMutation = graphql`
|
||||
mutation CompliancePageVisualIdentitySection_updateMutation($input: UpdateTrustCenterBrandInput!) {
|
||||
updateTrustCenterBrand(input: $input) {
|
||||
trustCenter {
|
||||
id
|
||||
logo {
|
||||
downloadUrl
|
||||
}
|
||||
darkLogo {
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const acceptImageMimeTypes = "image/png,image/jpeg,image/jpg,image/svg+xml,image/webp";
|
||||
const maxLogoBytes = 5 * 1024 * 1024;
|
||||
|
||||
export interface CompliancePageVisualIdentitySectionProps {
|
||||
compliancePageRef: CompliancePageVisualIdentitySection_compliancePageFragment$key;
|
||||
}
|
||||
|
||||
export function CompliancePageVisualIdentitySection(props: CompliancePageVisualIdentitySectionProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const compliancePage = useFragment(compliancePageFragment, props.compliancePageRef);
|
||||
const compliancePageId = compliancePage.id;
|
||||
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [darkLogoPreview, setDarkLogoPreview] = useState<string | null>(null);
|
||||
|
||||
const [updateBrand, isUpdating] = useMutation<CompliancePageVisualIdentitySection_updateMutation>(
|
||||
updateMutation,
|
||||
{
|
||||
successMessage: __("Compliance page branding updated successfully"),
|
||||
errorToast: __("Failed to update compliance page branding"),
|
||||
},
|
||||
);
|
||||
|
||||
const disabled = isUpdating || !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 isTooLarge = (file: File) => {
|
||||
if (file.size > maxLogoBytes) {
|
||||
toast({
|
||||
title: __("File size too large"),
|
||||
description: __("The file size is too large. Please upload a file smaller than 5MB."),
|
||||
variant: "error",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || isTooLarge(file)) return;
|
||||
|
||||
processLogoFile(file, setLogoPreview);
|
||||
|
||||
void updateBrand({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: compliancePageId,
|
||||
logoFile: null,
|
||||
},
|
||||
},
|
||||
uploadables: {
|
||||
"input.logoFile": file,
|
||||
},
|
||||
onCompleted: () => {
|
||||
setLogoPreview(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDarkLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || isTooLarge(file)) return;
|
||||
|
||||
processLogoFile(file, setDarkLogoPreview);
|
||||
|
||||
void updateBrand({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: compliancePageId,
|
||||
darkLogoFile: null,
|
||||
},
|
||||
},
|
||||
uploadables: {
|
||||
"input.darkLogoFile": file,
|
||||
},
|
||||
onCompleted: () => {
|
||||
setDarkLogoPreview(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLogo = async () => {
|
||||
await updateBrand({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: compliancePageId,
|
||||
logoFile: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setLogoPreview(null);
|
||||
};
|
||||
|
||||
const handleRemoveDarkLogo = async () => {
|
||||
await updateBrand({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: compliancePageId,
|
||||
darkLogoFile: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setDarkLogoPreview(null);
|
||||
};
|
||||
|
||||
const currentLogoUrl = logoPreview || compliancePage.logo?.downloadUrl;
|
||||
const currentDarkLogoUrl = darkLogoPreview || compliancePage.darkLogo?.downloadUrl;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("Visual identity")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Logos displayed on your public compliance page.")}
|
||||
</p>
|
||||
</div>
|
||||
{isUpdating && <Spinner />}
|
||||
</div>
|
||||
|
||||
<Card padded className="space-y-4">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="flex-1">
|
||||
<Label>{__("Logo")}</Label>
|
||||
<p className="text-sm text-txt-tertiary mb-3">
|
||||
{__("This logo will be displayed on your public compliance page.")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{currentLogoUrl
|
||||
? (
|
||||
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
|
||||
<img
|
||||
src={currentLogoUrl}
|
||||
alt={__("Compliance page logo")}
|
||||
className="h-16 max-w-xs object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-border-solid bg-surface-secondary text-xs text-txt-tertiary">
|
||||
{__("No logo")}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<FileButton
|
||||
disabled={disabled}
|
||||
onChange={handleLogoChange}
|
||||
variant="secondary"
|
||||
accept={acceptImageMimeTypes}
|
||||
>
|
||||
{isUpdating
|
||||
? __("Uploading...")
|
||||
: currentLogoUrl
|
||||
? __("Change logo")
|
||||
: __("Upload logo")}
|
||||
</FileButton>
|
||||
{!currentLogoUrl && (
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__("PNG, JPG, SVG, or WEBP up to 5MB")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{currentLogoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => void handleRemoveLogo()}
|
||||
disabled={disabled}
|
||||
aria-label={__("Remove logo")}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>{__("Dark mode logo")}</Label>
|
||||
<p className="text-sm text-txt-tertiary mb-3">
|
||||
{__("This logo will be used when dark mode is enabled.")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{currentDarkLogoUrl
|
||||
? (
|
||||
<div className="border border-border-solid rounded-md p-4 bg-gray-900">
|
||||
<img
|
||||
src={currentDarkLogoUrl}
|
||||
alt={__("Compliance page dark logo")}
|
||||
className="h-16 max-w-xs object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-border-solid bg-gray-900 text-xs text-txt-tertiary">
|
||||
{__("No logo")}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<FileButton
|
||||
disabled={disabled}
|
||||
onChange={handleDarkLogoChange}
|
||||
variant="secondary"
|
||||
accept={acceptImageMimeTypes}
|
||||
>
|
||||
{isUpdating
|
||||
? __("Uploading...")
|
||||
: currentDarkLogoUrl
|
||||
? __("Change dark logo")
|
||||
: __("Upload dark logo")}
|
||||
</FileButton>
|
||||
{!currentDarkLogoUrl && (
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__("PNG, JPG, SVG, or WEBP up to 5MB")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{currentDarkLogoUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => void handleRemoveDarkLogo()}
|
||||
disabled={disabled}
|
||||
aria-label={__("Remove dark logo")}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, IconPlusLarge } from "@probo/ui";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageDomainPageQuery } from "#/__generated__/core/CompliancePageDomainPageQuery.graphql";
|
||||
|
||||
import { CompliancePageDomainCard } from "./_components/CompliancePageDomainCard";
|
||||
import { NewCompliancePageDomainDialog } from "./_components/NewCompliancePageDomainDialog";
|
||||
|
||||
export const compliancePageDomainPageQuery = graphql`
|
||||
query CompliancePageDomainPageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
__typename
|
||||
... on Organization {
|
||||
canCreateCustomDomain: permission(action: "core:custom-domain:create")
|
||||
customDomain {
|
||||
...CompliancePageDomainCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageDomainPage(props: {
|
||||
queryRef: PreloadedQuery<CompliancePageDomainPageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const { organization } = usePreloadedQuery<CompliancePageDomainPageQuery>(
|
||||
compliancePageDomainPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
|
||||
{organization.customDomain
|
||||
? (
|
||||
<CompliancePageDomainCard fKey={organization.customDomain} />
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No custom domain configured")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__(
|
||||
"Add your own domain to make your compliance page more professional",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
{organization.canCreateCustomDomain && (
|
||||
<NewCompliancePageDomainDialog>
|
||||
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
|
||||
</NewCompliancePageDomainDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { CompliancePageDomainPageQuery } from "#/__generated__/core/CompliancePageDomainPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
CompliancePageDomainPage,
|
||||
compliancePageDomainPageQuery,
|
||||
} from "./CompliancePageDomainPage";
|
||||
|
||||
function CompliancePageDomainPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<CompliancePageDomainPageQuery>(
|
||||
compliancePageDomainPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({
|
||||
organizationId,
|
||||
});
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return <CompliancePageDomainPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function CompliancePageDomainPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<CompliancePageDomainPageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -34,54 +34,62 @@ import { DeleteCompliancePageDomainDialog } from "./DeleteCompliancePageDomainDi
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageDomainCardFragment on CustomDomain {
|
||||
id
|
||||
domain
|
||||
managed
|
||||
sslStatus
|
||||
provisioningError
|
||||
canDelete: permission(action: "core:custom-domain:delete")
|
||||
canDelete: permission(action: "compliance-portal:custom-domain:delete")
|
||||
...CompliancePageDomainDialogFragment
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageDomainCard(props: { fKey: CompliancePageDomainCardFragment$key }) {
|
||||
const { fKey } = props;
|
||||
export function CompliancePageDomainCard(props: {
|
||||
fKey: CompliancePageDomainCardFragment$key;
|
||||
compliancePageId: string;
|
||||
}) {
|
||||
const { fKey, compliancePageId } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const domain = useFragment<CompliancePageDomainCardFragment$key>(fragment, fKey);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium mb-1">{domain.domain}</div>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{domain.sslStatus === "ACTIVE"
|
||||
? __("Verified")
|
||||
: domain.provisioningError
|
||||
? domain.provisioningError
|
||||
: __("Pending verification")}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}
|
||||
>
|
||||
<Card padded>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{domain.domain}</span>
|
||||
{domain.managed && (
|
||||
<Badge variant="neutral">{__("Managed")}</Badge>
|
||||
)}
|
||||
<Badge variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}>
|
||||
{getCustomDomainStatusBadgeLabel(domain.sslStatus, __)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{domain.sslStatus === "ACTIVE"
|
||||
? __("Verified and serving traffic")
|
||||
: domain.provisioningError
|
||||
? domain.provisioningError
|
||||
: __("Pending DNS verification")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CompliancePageDomainDialog fKey={domain}>
|
||||
<Button variant="secondary">{__("View Details")}</Button>
|
||||
</CompliancePageDomainDialog>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<CompliancePageDomainDialog fKey={domain}>
|
||||
<Button variant="secondary">{__("View details")}</Button>
|
||||
</CompliancePageDomainDialog>
|
||||
|
||||
{domain.canDelete && (
|
||||
<DeleteCompliancePageDomainDialog domain={domain.domain}>
|
||||
<Button variant="danger">{__("Delete")}</Button>
|
||||
</DeleteCompliancePageDomainDialog>
|
||||
)}
|
||||
</div>
|
||||
{domain.canDelete && (
|
||||
<DeleteCompliancePageDomainDialog
|
||||
domain={domain.domain}
|
||||
customDomainId={domain.id}
|
||||
compliancePageId={compliancePageId}
|
||||
>
|
||||
<Button variant="danger">{__("Delete")}</Button>
|
||||
</DeleteCompliancePageDomainDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -33,8 +33,7 @@ import { type PropsWithChildren, useState } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DeleteCompliancePageDomainDialogMutation } from "#/__generated__/core/DeleteCompliancePageDomainDialogMutation.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
const deleteCustomDomainMutation = graphql`
|
||||
mutation DeleteCompliancePageDomainDialogMutation($input: DeleteCustomDomainInput!) {
|
||||
@@ -46,39 +45,36 @@ const deleteCustomDomainMutation = graphql`
|
||||
|
||||
type DeleteCompliancePageDomainDialogProps = PropsWithChildren<{
|
||||
domain: string;
|
||||
customDomainId: string;
|
||||
compliancePageId: string;
|
||||
}>;
|
||||
|
||||
export function DeleteCompliancePageDomainDialog(props: DeleteCompliancePageDomainDialogProps) {
|
||||
const { children, domain } = props;
|
||||
const { children, domain, customDomainId } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const [deleteCustomDomain, isDeleting]
|
||||
= useMutationWithToasts<DeleteCompliancePageDomainDialogMutation>(
|
||||
= useMutation<DeleteCompliancePageDomainDialogMutation>(
|
||||
deleteCustomDomainMutation,
|
||||
{
|
||||
successMessage: __("Domain deleted successfully"),
|
||||
errorMessage: __("Failed to delete domain"),
|
||||
errorToast: __("Failed to delete domain"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleDeleteDomain = async () => {
|
||||
return deleteCustomDomain({
|
||||
variables: {
|
||||
input: { organizationId },
|
||||
input: { customDomainId },
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
updater: (store) => {
|
||||
// Update the cache by setting customDomain to null
|
||||
const organizationRecord = store.get(organizationId);
|
||||
if (organizationRecord) {
|
||||
organizationRecord.setValue(null, "customDomain");
|
||||
}
|
||||
store.delete(customDomainId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -34,8 +34,7 @@ import { z } from "zod";
|
||||
|
||||
import type { NewCompliancePageDomainDialogMutation } from "#/__generated__/core/NewCompliancePageDomainDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
const createCustomDomainMutation = graphql`
|
||||
mutation NewCompliancePageDomainDialogMutation($input: CreateCustomDomainInput!) {
|
||||
@@ -43,6 +42,7 @@ const createCustomDomainMutation = graphql`
|
||||
customDomain {
|
||||
id
|
||||
domain
|
||||
managed
|
||||
sslStatus
|
||||
dnsRecords {
|
||||
type
|
||||
@@ -54,7 +54,8 @@ const createCustomDomainMutation = graphql`
|
||||
createdAt
|
||||
updatedAt
|
||||
sslExpiresAt
|
||||
canDelete: permission(action: "core:custom-domain:delete")
|
||||
canDelete: permission(action: "compliance-portal:custom-domain:delete")
|
||||
...CompliancePageDomainCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,10 +71,9 @@ const schema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
|
||||
const { children } = props;
|
||||
export function NewCompliancePageDomainDialog(props: PropsWithChildren<{ compliancePageId: string }>) {
|
||||
const { children, compliancePageId } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
@@ -87,11 +87,11 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
|
||||
);
|
||||
|
||||
const [createCustomDomain, isCreating]
|
||||
= useMutationWithToasts<NewCompliancePageDomainDialogMutation>(createCustomDomainMutation, {
|
||||
= useMutation<NewCompliancePageDomainDialogMutation>(createCustomDomainMutation, {
|
||||
successMessage: __(
|
||||
"Domain added successfully. Configure the DNS records to verify and activate your domain.",
|
||||
),
|
||||
errorMessage: __("Failed to add domain"),
|
||||
errorToast: __("Failed to add domain"),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
@@ -104,30 +104,26 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
|
||||
await createCustomDomain({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
trustCenterId: compliancePageId,
|
||||
domain: normalizedDomain,
|
||||
},
|
||||
},
|
||||
updater: (store, data) => {
|
||||
// Update the cache by setting the new customDomain on the organization
|
||||
const organizationRecord = store.get(organizationId);
|
||||
if (organizationRecord && data?.createCustomDomain?.customDomain) {
|
||||
const customDomainRecord = store.get(
|
||||
data.createCustomDomain.customDomain.id,
|
||||
);
|
||||
if (customDomainRecord) {
|
||||
organizationRecord.setLinkedRecord(
|
||||
customDomainRecord,
|
||||
"customDomain",
|
||||
);
|
||||
}
|
||||
const newDomainId = data?.createCustomDomain?.customDomain?.id;
|
||||
if (!newDomainId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const compliancePageRecord = store.get(compliancePageId);
|
||||
const newDomainRecord = store.get(newDomainId);
|
||||
if (compliancePageRecord && newDomainRecord) {
|
||||
compliancePageRecord.setLinkedRecord(newDomainRecord, "customDomain");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
reset();
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
|
||||
reset();
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -160,7 +156,7 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
|
||||
<strong>{__("Examples:")}</strong>
|
||||
{" "}
|
||||
compliance.example.com,
|
||||
trust.example.com
|
||||
compliance.example.com
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageOverviewPageQuery } from "#/__generated__/core/CompliancePageOverviewPageQuery.graphql";
|
||||
|
||||
import { CompliancePageFrameworksSection } from "./_components/CompliancePageFrameworksSection";
|
||||
import { CompliancePageNDASection } from "./_components/CompliancePageNDASection";
|
||||
import { CompliancePageSlackSection } from "./_components/CompliancePageSlackSection";
|
||||
import { CompliancePageStatusSection } from "./_components/CompliancePageStatusSection";
|
||||
@@ -32,10 +33,11 @@ export const compliancePageOverviewPageQuery = graphql`
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
compliancePage: trustCenter {
|
||||
canGetNDA: permission(action: "core:trust-center:get-nda")
|
||||
canGetNDA: permission(action: "compliance-portal:portal:get-nda")
|
||||
}
|
||||
}
|
||||
...CompliancePageStatusSectionFragment
|
||||
...CompliancePageFrameworksSectionFragment
|
||||
...CompliancePageNDASectionFragment
|
||||
...CompliancePageSlackSectionFragment
|
||||
}
|
||||
@@ -54,6 +56,8 @@ export function CompliancePageOverviewPage(props: { queryRef: PreloadedQuery<Com
|
||||
<div className="space-y-6">
|
||||
<CompliancePageStatusSection fragmentRef={organization} />
|
||||
|
||||
<CompliancePageFrameworksSection fragmentRef={organization} />
|
||||
|
||||
{organization.compliancePage?.canGetNDA && (
|
||||
<CompliancePageNDASection fragmentRef={organization} />
|
||||
)}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { detectSocialName, safeOpenUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconArrowLink,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
SocialIcon,
|
||||
Spinner,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef, useState, useTransition } from "react";
|
||||
import { useRefetchableFragment } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CompliancePageExternalUrlsSection_createMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_createMutation.graphql";
|
||||
import type { CompliancePageExternalUrlsSection_deleteMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_deleteMutation.graphql";
|
||||
import type { CompliancePageExternalUrlsSection_trustCenterFragment$key } from "#/__generated__/core/CompliancePageExternalUrlsSection_trustCenterFragment.graphql";
|
||||
import type { CompliancePageExternalUrlsSection_trustCenterRefetchQuery } from "#/__generated__/core/CompliancePageExternalUrlsSection_trustCenterRefetchQuery.graphql";
|
||||
import type { CompliancePageExternalUrlsSection_updateMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_updateMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const trustCenterFragment = graphql`
|
||||
fragment CompliancePageExternalUrlsSection_trustCenterFragment on TrustCenter
|
||||
@refetchable(queryName: "CompliancePageExternalUrlsSection_trustCenterRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: Int, defaultValue: 100 }
|
||||
after: { type: CursorKey, defaultValue: null }
|
||||
order: { type: ComplianceExternalURLOrder, defaultValue: { field: RANK, direction: ASC } }
|
||||
) {
|
||||
id
|
||||
canUpdate: permission(action: "core:trust-center:update")
|
||||
externalUrls(first: $first, after: $after, orderBy: $order)
|
||||
@connection(key: "CompliancePageExternalUrlsSection_externalUrls", filters: ["orderBy"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
url
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createMutation = graphql`
|
||||
mutation CompliancePageExternalUrlsSection_createMutation($input: CreateComplianceExternalURLInput!) {
|
||||
createComplianceExternalURL(input: $input) {
|
||||
complianceExternalUrlEdge {
|
||||
node {
|
||||
id
|
||||
name
|
||||
url
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const updateMutation = graphql`
|
||||
mutation CompliancePageExternalUrlsSection_updateMutation($input: UpdateComplianceExternalURLInput!) {
|
||||
updateComplianceExternalURL(input: $input) {
|
||||
complianceExternalUrl {
|
||||
id
|
||||
name
|
||||
url
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMutation = graphql`
|
||||
mutation CompliancePageExternalUrlsSection_deleteMutation($input: DeleteComplianceExternalURLInput!) {
|
||||
deleteComplianceExternalURL(input: $input) {
|
||||
deletedComplianceExternalUrlId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const urlSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
url: z.string().url("Please enter a valid URL"),
|
||||
});
|
||||
|
||||
type UrlFormData = z.infer<typeof urlSchema>;
|
||||
|
||||
type UrlNode = { id: string; name: string; url: string; rank: number };
|
||||
|
||||
type ExternalUrlDialogRef = {
|
||||
openCreate: (trustCenterId: string, connectionId: string) => void;
|
||||
openEdit: (node: UrlNode) => void;
|
||||
};
|
||||
|
||||
const ExternalUrlDialog = forwardRef<ExternalUrlDialogRef>(
|
||||
function ExternalUrlDialog(_, ref) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const [mode, setMode] = useState<"create" | "edit">("create");
|
||||
const [trustCenterId, setTrustCenterId] = useState("");
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [editNode, setEditNode] = useState<UrlNode | null>(null);
|
||||
|
||||
const [create, isCreating] = useMutationWithToasts<CompliancePageExternalUrlsSection_createMutation>(
|
||||
createMutation,
|
||||
{ successMessage: __("Link added successfully."), errorMessage: __("Failed to add link.") },
|
||||
);
|
||||
|
||||
const [update, isUpdating] = useMutationWithToasts<CompliancePageExternalUrlsSection_updateMutation>(
|
||||
updateMutation,
|
||||
{ successMessage: __("Link updated successfully."), errorMessage: __("Failed to update link.") },
|
||||
);
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useFormWithSchema(urlSchema, {
|
||||
defaultValues: { name: "", url: "" },
|
||||
});
|
||||
|
||||
const [nameAutoDetected, setNameAutoDetected] = useState(false);
|
||||
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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: (tId, cId) => {
|
||||
setMode("create");
|
||||
setTrustCenterId(tId);
|
||||
setConnectionId(cId);
|
||||
setEditNode(null);
|
||||
setNameAutoDetected(false);
|
||||
reset({ name: "", url: "" });
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
openEdit: (node) => {
|
||||
setMode("edit");
|
||||
setEditNode(node);
|
||||
setNameAutoDetected(false);
|
||||
reset({ name: node.name, url: node.url });
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
}));
|
||||
|
||||
const onSubmit = async (data: UrlFormData) => {
|
||||
if (mode === "create") {
|
||||
await create({
|
||||
variables: {
|
||||
input: { trustCenterId, name: data.name, url: data.url },
|
||||
},
|
||||
updater: (store) => {
|
||||
const payload = store.getRootField("createComplianceExternalURL");
|
||||
const edge = payload?.getLinkedRecord("complianceExternalUrlEdge");
|
||||
if (!edge) return;
|
||||
const connection = store.get(connectionId);
|
||||
if (!connection) return;
|
||||
ConnectionHandler.insertEdgeAfter(connection, edge);
|
||||
},
|
||||
onCompleted: (_, errs) => {
|
||||
if (!errs?.length) {
|
||||
reset();
|
||||
dialogRef.current?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (editNode) {
|
||||
await update({
|
||||
variables: { input: { id: editNode.id, name: data.name, url: data.url } },
|
||||
onCompleted: (_, errs) => {
|
||||
if (!errs?.length) {
|
||||
reset();
|
||||
dialogRef.current?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
const title = mode === "create" ? __("Add link") : __("Edit link");
|
||||
|
||||
return (
|
||||
<Dialog ref={dialogRef} title={title} onClose={() => reset()}>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-6">
|
||||
<Field
|
||||
{...register("url", { onChange: handleUrlChange })}
|
||||
label={__("URL")}
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
error={errors.url?.message}
|
||||
/>
|
||||
<Field
|
||||
{...register("name")}
|
||||
label={__("Name")}
|
||||
type="text"
|
||||
required
|
||||
placeholder={__("e.g. Twitter, LinkedIn")}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
|
||||
{mode === "create" ? __("Add link") : __("Save changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function ExternalUrlRow(props: {
|
||||
node: UrlNode;
|
||||
canEdit: boolean;
|
||||
connectionId: string;
|
||||
isDragging: boolean;
|
||||
isDropTarget: boolean;
|
||||
onDragStart: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
onDragEnd: () => void;
|
||||
onEdit: (node: UrlNode) => void;
|
||||
}) {
|
||||
const {
|
||||
node, canEdit, connectionId, isDragging, isDropTarget,
|
||||
onDragStart, onDragOver, onDrop, onDragEnd, onEdit,
|
||||
} = props;
|
||||
const { __ } = useTranslate();
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
|
||||
const [deleteUrl] = useMutationWithToasts<CompliancePageExternalUrlsSection_deleteMutation>(
|
||||
deleteMutation,
|
||||
{ successMessage: __("Link removed."), errorMessage: __("Failed to remove link.") },
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
void deleteUrl({
|
||||
variables: { input: { id: node.id } },
|
||||
updater: (store) => {
|
||||
const connection = store.get(connectionId);
|
||||
if (!connection) return;
|
||||
ConnectionHandler.deleteNode(connection, node.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const className = [
|
||||
isDragging && "opacity-50 cursor-grabbing",
|
||||
!isDragging && !isMouseDown && "cursor-grab",
|
||||
!isDragging && isMouseDown && "cursor-grabbing",
|
||||
isDropTarget && "!bg-primary-50 border-y-2 border-primary-500",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Tr
|
||||
draggable={canEdit}
|
||||
onDragStart={canEdit ? onDragStart : undefined}
|
||||
onDragOver={canEdit ? onDragOver : undefined}
|
||||
onDrop={canEdit ? onDrop : undefined}
|
||||
onDragEnd={canEdit ? onDragEnd : undefined}
|
||||
onMouseDown={canEdit ? () => setIsMouseDown(true) : undefined}
|
||||
onMouseUp={canEdit ? () => setIsMouseDown(false) : undefined}
|
||||
onMouseLeave={canEdit ? () => setIsMouseDown(false) : undefined}
|
||||
className={className}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-3 text-txt-secondary">
|
||||
<SocialIcon socialName={detectSocialName(node.url)} size={16} className="shrink-0" />
|
||||
<span className="font-medium">{node.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-txt-secondary truncate">{node.url}</span>
|
||||
</Td>
|
||||
<Td noLink width={canEdit ? 144 : 56} className="text-end">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" icon={IconArrowLink} onClick={() => safeOpenUrl(node.url)} />
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(node)} />
|
||||
<Button variant="danger" icon={IconTrashCan} onClick={handleDelete} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompliancePageExternalUrlsSection(props: {
|
||||
trustCenterRef: CompliancePageExternalUrlsSection_trustCenterFragment$key;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const [, startTransition] = useTransition();
|
||||
const dialogRef = useRef<ExternalUrlDialogRef>(null);
|
||||
|
||||
const [trustCenter, refetch] = useRefetchableFragment<
|
||||
CompliancePageExternalUrlsSection_trustCenterRefetchQuery,
|
||||
CompliancePageExternalUrlsSection_trustCenterFragment$key
|
||||
>(trustCenterFragment, props.trustCenterRef);
|
||||
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [updateRank] = useMutationWithToasts<CompliancePageExternalUrlsSection_updateMutation>(
|
||||
updateMutation,
|
||||
{ successMessage: __("Order updated."), errorMessage: __("Failed to update order.") },
|
||||
);
|
||||
|
||||
const edges = trustCenter.externalUrls.edges;
|
||||
const canEdit = trustCenter.canUpdate;
|
||||
|
||||
const connectionId = trustCenter.externalUrls.__id;
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogRef.current?.openCreate(trustCenter.id, connectionId);
|
||||
};
|
||||
|
||||
const handleEdit = (node: UrlNode) => {
|
||||
dialogRef.current?.openEdit(node);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">{__("Custom links")}</h3>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Add external URLs to display on your compliance page")}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button icon={IconPlusLarge} onClick={handleCreate}>
|
||||
{__("Add link")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("URL")}</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{edges.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="text-center text-txt-secondary">
|
||||
{__("No custom links yet")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{edges.map(({ node }, index) => (
|
||||
<ExternalUrlRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canEdit={canEdit}
|
||||
connectionId={connectionId}
|
||||
isDragging={draggedIndex === index}
|
||||
isDropTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
onDragStart={() => setDraggedIndex(index)}
|
||||
onDragOver={e => handleDragOver(e, index)}
|
||||
onDrop={() => void handleDrop(index)}
|
||||
onDragEnd={() => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{edges.length > 1 && canEdit && (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Drag and drop to change the displayed order")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ExternalUrlDialog ref={dialogRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,17 +19,14 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Field, FrameworkLogo, Option, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
|
||||
import { useCallback, useState, useTransition } from "react";
|
||||
import { useTransition } from "react";
|
||||
import { useRefetchableFragment } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageFrameworkList_compliancePageFragment$data, CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
|
||||
import type { CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
|
||||
import type { CompliancePageFrameworkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageRefetchQuery.graphql";
|
||||
import type { CompliancePageFrameworkList_createMutation } from "#/__generated__/core/CompliancePageFrameworkList_createMutation.graphql";
|
||||
import type { CompliancePageFrameworkList_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkList_deleteMutation.graphql";
|
||||
import type { CompliancePageFrameworkList_updateRankMutation } from "#/__generated__/core/CompliancePageFrameworkList_updateRankMutation.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CompliancePageFrameworkListItem } from "./CompliancePageFrameworkListItem";
|
||||
|
||||
const compliancePageFragment = graphql`
|
||||
fragment CompliancePageFrameworkList_compliancePageFragment on TrustCenter
|
||||
@@ -39,206 +36,24 @@ const compliancePageFragment = graphql`
|
||||
after: { type: CursorKey, defaultValue: null }
|
||||
order: { type: ComplianceFrameworkOrder, defaultValue: { field: RANK, direction: ASC } }
|
||||
) {
|
||||
id
|
||||
canUpdate: permission(action: "core:trust-center:update")
|
||||
...CompliancePageFrameworkListItem_compliancePage
|
||||
complianceFrameworks(first: $first, after: $after, orderBy: $order)
|
||||
@connection(key: "CompliancePageFrameworkList_complianceFrameworks", filters: ["orderBy"]) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
rank
|
||||
visibility
|
||||
framework {
|
||||
id
|
||||
name
|
||||
lightLogo {
|
||||
downloadUrl
|
||||
}
|
||||
darkLogo {
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
...CompliancePageFrameworkListItem_complianceFramework
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createMutation = graphql`
|
||||
mutation CompliancePageFrameworkList_createMutation($input: CreateComplianceFrameworkInput!) {
|
||||
createComplianceFramework(input: $input) {
|
||||
complianceFrameworkEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const updateRankMutation = graphql`
|
||||
mutation CompliancePageFrameworkList_updateRankMutation($input: UpdateComplianceFrameworkInput!) {
|
||||
updateComplianceFramework(input: $input) {
|
||||
complianceFramework {
|
||||
id
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMutation = graphql`
|
||||
mutation CompliancePageFrameworkList_deleteMutation($input: DeleteComplianceFrameworkInput!) {
|
||||
deleteComplianceFramework(input: $input) {
|
||||
deletedComplianceFrameworkId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Edge = CompliancePageFrameworkList_compliancePageFragment$data["complianceFrameworks"]["edges"][number];
|
||||
|
||||
function CompliancePageFrameworkListItem(props: {
|
||||
edge: Edge;
|
||||
compliancePage: CompliancePageFrameworkList_compliancePageFragment$data;
|
||||
draggedCfId: string | null;
|
||||
dragOverCfId: string | null;
|
||||
onDragStart: (cfId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent, cfId: string) => void;
|
||||
onDrop: (cfId: string) => void;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { edge, draggedCfId, dragOverCfId, onDragStart, onDragEnd, onDragOver, onDrop, onRefetch } = props;
|
||||
const { __ } = useTranslate();
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
|
||||
const compliancePage = props.compliancePage;
|
||||
const { id, visibility, framework } = edge.node;
|
||||
|
||||
const isPublic = visibility === "PUBLIC";
|
||||
const canDrag = isPublic && compliancePage.canUpdate;
|
||||
|
||||
const isDragging = draggedCfId === id;
|
||||
const isDropTarget = dragOverCfId === id && draggedCfId !== id;
|
||||
|
||||
const [createComplianceFramework, isCreating] = useMutationWithToasts<CompliancePageFrameworkList_createMutation>(
|
||||
createMutation,
|
||||
{
|
||||
successMessage: __("Framework visibility updated successfully."),
|
||||
errorMessage: __("Failed to update framework visibility"),
|
||||
},
|
||||
);
|
||||
|
||||
const [deleteComplianceFramework, isDeleting] = useMutationWithToasts<CompliancePageFrameworkList_deleteMutation>(
|
||||
deleteMutation,
|
||||
{
|
||||
successMessage: __("Framework visibility updated successfully."),
|
||||
errorMessage: __("Failed to update framework visibility"),
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = isCreating || isDeleting;
|
||||
|
||||
const handleVisibilityChange = useCallback(
|
||||
async (value: string) => {
|
||||
if (value === "PUBLIC" && !isPublic) {
|
||||
await createComplianceFramework({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: compliancePage.id,
|
||||
frameworkId: framework.id,
|
||||
},
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (!errors?.length) {
|
||||
onRefetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (value === "NONE" && isPublic) {
|
||||
await deleteComplianceFramework({
|
||||
variables: {
|
||||
input: { id },
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (!errors?.length) {
|
||||
onRefetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[compliancePage.id, framework.id, id, isPublic, onRefetch, createComplianceFramework, deleteComplianceFramework],
|
||||
);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: __("Public"), variant: "success" as const },
|
||||
{ value: "NONE", label: __("None"), variant: "neutral" as const },
|
||||
];
|
||||
|
||||
const rowClassName = [
|
||||
canDrag && isDragging && "opacity-50 cursor-grabbing",
|
||||
canDrag && !isDragging && !isMouseDown && "cursor-grab",
|
||||
canDrag && !isDragging && isMouseDown && "cursor-grabbing",
|
||||
isDropTarget && "!bg-primary-50 border-y-2 border-primary-500",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Tr
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? () => onDragStart(id) : undefined}
|
||||
onDragEnd={canDrag ? onDragEnd : undefined}
|
||||
onDragOver={canDrag ? e => onDragOver(e, id) : undefined}
|
||||
onDrop={
|
||||
canDrag
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
onDrop(id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={canDrag ? () => setIsMouseDown(true) : undefined}
|
||||
onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined}
|
||||
onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined}
|
||||
className={rowClassName}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-3">
|
||||
<FrameworkLogo
|
||||
className="size-8"
|
||||
lightLogoURL={framework.lightLogo?.downloadUrl}
|
||||
darkLogoURL={framework.darkLogo?.downloadUrl}
|
||||
name={framework.name}
|
||||
/>
|
||||
{framework.name}
|
||||
</div>
|
||||
</Td>
|
||||
<Td noLink width={130} className="pr-0">
|
||||
<Field
|
||||
type="select"
|
||||
value={visibility}
|
||||
onValueChange={value => void handleVisibilityChange(value)}
|
||||
disabled={isLoading || !compliancePage.canUpdate}
|
||||
className="w-[105px]"
|
||||
>
|
||||
{visibilityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Badge variant={option.variant}>{option.label}</Badge>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Field>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
export interface CompliancePageFrameworkListProps {
|
||||
compliancePageRef: CompliancePageFrameworkList_compliancePageFragment$key;
|
||||
}
|
||||
|
||||
export function CompliancePageFrameworkList(props: {
|
||||
compliancePageRef: CompliancePageFrameworkList_compliancePageFragment$key;
|
||||
}) {
|
||||
export function CompliancePageFrameworkList(props: CompliancePageFrameworkListProps) {
|
||||
const { __ } = useTranslate();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
@@ -247,134 +62,32 @@ export function CompliancePageFrameworkList(props: {
|
||||
CompliancePageFrameworkList_compliancePageFragment$key
|
||||
>(compliancePageFragment, props.compliancePageRef);
|
||||
|
||||
const [updateRank] = useMutationWithToasts<CompliancePageFrameworkList_updateRankMutation>(
|
||||
updateRankMutation,
|
||||
{
|
||||
successMessage: __("Order updated successfully"),
|
||||
errorMessage: __("Failed to update order"),
|
||||
},
|
||||
);
|
||||
const edges = compliancePage.complianceFrameworks.edges;
|
||||
|
||||
const [draggedCfId, setDraggedCfId] = useState<string | null>(null);
|
||||
const [dragOverCfId, setDragOverCfId] = useState<string | null>(null);
|
||||
|
||||
const allEdges = compliancePage.complianceFrameworks.edges;
|
||||
const publicEdges = allEdges.filter(e => e.node.visibility === "PUBLIC");
|
||||
|
||||
const handleDragStart = (cfId: string) => {
|
||||
setDraggedCfId(cfId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedCfId(null);
|
||||
setDragOverCfId(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, cfId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedCfId !== cfId) {
|
||||
setDragOverCfId(cfId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (targetCfId: string) => {
|
||||
if (!draggedCfId || draggedCfId === targetCfId) {
|
||||
setDraggedCfId(null);
|
||||
setDragOverCfId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEdge = publicEdges.find(e => e.node.id === targetCfId);
|
||||
if (!targetEdge) {
|
||||
setDraggedCfId(null);
|
||||
setDragOverCfId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedId = draggedCfId;
|
||||
|
||||
await updateRank({
|
||||
variables: {
|
||||
input: {
|
||||
id: draggedId,
|
||||
rank: targetEdge.node.rank,
|
||||
},
|
||||
},
|
||||
updater: (store) => {
|
||||
const trustCenterRecord = store.get(compliancePage.id);
|
||||
if (!trustCenterRecord) return;
|
||||
|
||||
const connection = ConnectionHandler.getConnection(
|
||||
trustCenterRecord,
|
||||
"CompliancePageFrameworkList_complianceFrameworks",
|
||||
{ orderBy: { field: "RANK", direction: "ASC" } },
|
||||
);
|
||||
if (!connection) return;
|
||||
|
||||
const edges = connection.getLinkedRecords("edges");
|
||||
if (!edges) return;
|
||||
|
||||
const fromIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId);
|
||||
const toIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === targetCfId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
const reordered = [...edges];
|
||||
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" });
|
||||
});
|
||||
},
|
||||
const handleRefetch = () => {
|
||||
startTransition(() => {
|
||||
refetch({}, { fetchPolicy: "store-and-network" });
|
||||
});
|
||||
|
||||
setDraggedCfId(null);
|
||||
setDragOverCfId(null);
|
||||
};
|
||||
|
||||
const hasMultiplePublic = publicEdges.length > 1;
|
||||
if (edges.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("No frameworks available")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Framework")}</Th>
|
||||
<Th>{__("Visibility")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{allEdges.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={2} className="text-center text-txt-secondary">
|
||||
{__("No frameworks available")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{allEdges.map(edge => (
|
||||
<CompliancePageFrameworkListItem
|
||||
key={edge.node.id}
|
||||
edge={edge}
|
||||
compliancePage={compliancePage}
|
||||
draggedCfId={draggedCfId}
|
||||
dragOverCfId={dragOverCfId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={cfId => void handleDrop(cfId)}
|
||||
onRefetch={() => startTransition(() => { refetch({}, { fetchPolicy: "store-and-network" }); })}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{hasMultiplePublic && compliancePage.canUpdate && (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Drag and drop public frameworks to change their displayed order")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{edges.map(edge => (
|
||||
<CompliancePageFrameworkListItem
|
||||
key={edge.node.id}
|
||||
complianceFrameworkKey={edge.node}
|
||||
compliancePageKey={compliancePage}
|
||||
onRefetch={handleRefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { FrameworkLogo, IconCheckmark1, Spinner } from "@probo/ui";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageFrameworkListItem_complianceFramework$key } from "#/__generated__/core/CompliancePageFrameworkListItem_complianceFramework.graphql";
|
||||
import type { CompliancePageFrameworkListItem_compliancePage$key } from "#/__generated__/core/CompliancePageFrameworkListItem_compliancePage.graphql";
|
||||
import type { CompliancePageFrameworkListItem_createMutation } from "#/__generated__/core/CompliancePageFrameworkListItem_createMutation.graphql";
|
||||
import type { CompliancePageFrameworkListItem_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkListItem_deleteMutation.graphql";
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
export const compliancePageFrameworkListItemFragment = graphql`
|
||||
fragment CompliancePageFrameworkListItem_complianceFramework on ComplianceFramework {
|
||||
id
|
||||
visibility
|
||||
framework {
|
||||
id
|
||||
name
|
||||
lightLogo {
|
||||
downloadUrl
|
||||
}
|
||||
darkLogo {
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const compliancePageFragment = graphql`
|
||||
fragment CompliancePageFrameworkListItem_compliancePage on TrustCenter {
|
||||
id
|
||||
canUpdate: permission(action: "compliance-portal:portal:update")
|
||||
}
|
||||
`;
|
||||
|
||||
const createMutation = graphql`
|
||||
mutation CompliancePageFrameworkListItem_createMutation($input: CreateComplianceFrameworkInput!) {
|
||||
createComplianceFramework(input: $input) {
|
||||
complianceFrameworkEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMutation = graphql`
|
||||
mutation CompliancePageFrameworkListItem_deleteMutation($input: DeleteComplianceFrameworkInput!) {
|
||||
deleteComplianceFramework(input: $input) {
|
||||
deletedComplianceFrameworkId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface CompliancePageFrameworkListItemProps {
|
||||
complianceFrameworkKey: CompliancePageFrameworkListItem_complianceFramework$key;
|
||||
compliancePageKey: CompliancePageFrameworkListItem_compliancePage$key;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function CompliancePageFrameworkListItem(props: CompliancePageFrameworkListItemProps) {
|
||||
const { complianceFrameworkKey, compliancePageKey, onRefetch } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const [optimisticPublic, setOptimisticPublic] = useState<boolean | null>(null);
|
||||
|
||||
const complianceFramework = useFragment(
|
||||
compliancePageFrameworkListItemFragment,
|
||||
complianceFrameworkKey,
|
||||
);
|
||||
const compliancePage = useFragment(compliancePageFragment, compliancePageKey);
|
||||
const canUpdate = compliancePage.canUpdate;
|
||||
const trustCenterId = compliancePage.id;
|
||||
const { id, visibility, framework } = complianceFramework;
|
||||
|
||||
const serverPublic = visibility === "PUBLIC";
|
||||
|
||||
if (optimisticPublic !== null && optimisticPublic === serverPublic) {
|
||||
setOptimisticPublic(null);
|
||||
}
|
||||
|
||||
const isPublic = optimisticPublic ?? serverPublic;
|
||||
|
||||
const [createComplianceFramework, isCreating] = useMutation<CompliancePageFrameworkListItem_createMutation>(
|
||||
createMutation,
|
||||
{
|
||||
successMessage: __("Framework visibility updated successfully."),
|
||||
errorToast: __("Failed to update framework visibility"),
|
||||
},
|
||||
);
|
||||
|
||||
const [deleteComplianceFramework, isDeleting] = useMutation<CompliancePageFrameworkListItem_deleteMutation>(
|
||||
deleteMutation,
|
||||
{
|
||||
successMessage: __("Framework visibility updated successfully."),
|
||||
errorToast: __("Failed to update framework visibility"),
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading = isCreating || isDeleting;
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (!canUpdate || isLoading) return;
|
||||
|
||||
const nextPublic = !isPublic;
|
||||
setOptimisticPublic(nextPublic);
|
||||
|
||||
try {
|
||||
if (isPublic) {
|
||||
await deleteComplianceFramework({
|
||||
variables: { input: { id } },
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) {
|
||||
setOptimisticPublic(null);
|
||||
return;
|
||||
}
|
||||
onRefetch();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await createComplianceFramework({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId,
|
||||
frameworkId: framework.id,
|
||||
},
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) {
|
||||
setOptimisticPublic(null);
|
||||
return;
|
||||
}
|
||||
onRefetch();
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setOptimisticPublic(null);
|
||||
}
|
||||
}, [
|
||||
canUpdate,
|
||||
isLoading,
|
||||
isPublic,
|
||||
deleteComplianceFramework,
|
||||
id,
|
||||
onRefetch,
|
||||
createComplianceFramework,
|
||||
trustCenterId,
|
||||
framework.id,
|
||||
]);
|
||||
|
||||
const className = [
|
||||
"relative flex flex-col items-center gap-3 rounded-lg border p-4 text-center",
|
||||
"transition-[background-color,border-color,box-shadow,opacity] duration-200 ease-in-out",
|
||||
isPublic
|
||||
? "border-primary-500 bg-primary-50 ring-2 ring-primary-500"
|
||||
: "border-border-solid bg-surface-secondary hover:border-border-medium hover:bg-surface-primary",
|
||||
canUpdate && !isLoading && "cursor-pointer",
|
||||
!canUpdate && "cursor-default",
|
||||
isLoading && "pointer-events-none",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUpdate}
|
||||
aria-pressed={isPublic}
|
||||
aria-busy={isLoading}
|
||||
aria-label={framework.name}
|
||||
onClick={() => void handleToggle()}
|
||||
className={className}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"absolute top-2 right-2 flex size-5 items-center justify-center rounded-full bg-primary-500 text-white",
|
||||
"transition-[opacity,transform] duration-200 ease-in-out",
|
||||
isPublic ? "scale-100 opacity-100" : "scale-75 opacity-0",
|
||||
].join(" ")}
|
||||
aria-hidden={!isPublic}
|
||||
>
|
||||
<IconCheckmark1 size={12} />
|
||||
</span>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"flex flex-col items-center gap-3 transition-opacity duration-200 ease-in-out",
|
||||
isLoading ? "opacity-50" : "opacity-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<FrameworkLogo
|
||||
className="size-12"
|
||||
lightLogoURL={framework.lightLogo?.downloadUrl}
|
||||
darkLogoURL={framework.darkLogo?.downloadUrl}
|
||||
name={framework.name}
|
||||
/>
|
||||
|
||||
<span className="text-sm font-medium">{framework.name}</span>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Spinner size={24} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageFrameworksSectionFragment$key } from "#/__generated__/core/CompliancePageFrameworksSectionFragment.graphql";
|
||||
|
||||
import { CompliancePageFrameworkList } from "./CompliancePageFrameworkList";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageFrameworksSectionFragment on Organization {
|
||||
compliancePage: trustCenter {
|
||||
...CompliancePageFrameworkList_compliancePageFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageFrameworksSection(props: {
|
||||
fragmentRef: CompliancePageFrameworksSectionFragment$key;
|
||||
}) {
|
||||
const { fragmentRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const organization = useFragment(fragment, fragmentRef);
|
||||
|
||||
if (!organization.compliancePage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{__("Compliance frameworks")}</h2>
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("Select which frameworks to show on your public compliance page.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CompliancePageFrameworkList compliancePageRef={organization.compliancePage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { safeOpenUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card } from "@probo/ui";
|
||||
import { type ChangeEventHandler, useRef } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageNDACard_compliancePage$key } from "#/__generated__/core/CompliancePageNDACard_compliancePage.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageNDACard_compliancePage on TrustCenter {
|
||||
nda {
|
||||
fileName
|
||||
downloadUrl
|
||||
}
|
||||
canUploadNDA: permission(action: "compliance-portal:portal:upload-nda")
|
||||
canDeleteNDA: permission(action: "compliance-portal:portal:delete-nda")
|
||||
}
|
||||
`;
|
||||
|
||||
export interface CompliancePageNDACardProps {
|
||||
compliancePageKey: CompliancePageNDACard_compliancePage$key;
|
||||
isBusy: boolean;
|
||||
isUploading: boolean;
|
||||
onFileChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function CompliancePageNDACard(props: CompliancePageNDACardProps) {
|
||||
const { compliancePageKey, isBusy, isUploading, onFileChange, onDelete } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const compliancePage = useFragment(fragment, compliancePageKey);
|
||||
const fileName = compliancePage.nda?.fileName;
|
||||
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padded>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium">{fileName}</span>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__(
|
||||
"Visitors must accept this agreement before accessing your compliance page.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (compliancePage.nda?.downloadUrl) {
|
||||
safeOpenUrl(compliancePage.nda.downloadUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Download")}
|
||||
</Button>
|
||||
|
||||
{compliancePage.canUploadNDA && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? __("Uploading...") : __("Replace")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{compliancePage.canDeleteNDA && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={isBusy}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,15 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Dropzone, IconTrashCan, Spinner, useToast } from "@probo/ui";
|
||||
import { Button, IconChevronRight, useConfirm, useToast } from "@probo/ui";
|
||||
import { type ChangeEventHandler, useRef } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageNDASectionFragment$key } from "#/__generated__/core/CompliancePageNDASectionFragment.graphql";
|
||||
import { useDeleteTrustCenterNDAMutation, useUploadTrustCenterNDAMutation } from "#/hooks/graph/TrustCenterGraph";
|
||||
import { useDeleteCompliancePageNDAMutation, useUploadCompliancePageNDAMutation } from "#/hooks/graph/CompliancePageGraph";
|
||||
|
||||
import { CompliancePageNDACard } from "./CompliancePageNDACard";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageNDASectionFragment on Organization {
|
||||
@@ -32,26 +35,31 @@ const fragment = graphql`
|
||||
id
|
||||
nda {
|
||||
fileName
|
||||
downloadUrl
|
||||
}
|
||||
canUploadNDA: permission(action: "core:trust-center:upload-nda")
|
||||
canDeleteNDA: permission(action: "core:trust-center:delete-nda")
|
||||
canUploadNDA: permission(action: "compliance-portal:portal:upload-nda")
|
||||
...CompliancePageNDACard_compliancePage
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDASectionFragment$key }) {
|
||||
export interface CompliancePageNDASectionProps {
|
||||
fragmentRef: CompliancePageNDASectionFragment$key;
|
||||
}
|
||||
|
||||
export function CompliancePageNDASection(props: CompliancePageNDASectionProps) {
|
||||
const { fragmentRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const organization = useFragment<CompliancePageNDASectionFragment$key>(fragment, fragmentRef);
|
||||
|
||||
const [uploadNDA, isUploadingNDA] = useUploadTrustCenterNDAMutation();
|
||||
const [deleteNDA, isDeletingNDA] = useDeleteTrustCenterNDAMutation();
|
||||
const [uploadNDA, isUploadingNDA] = useUploadCompliancePageNDAMutation();
|
||||
const [deleteNDA, isDeletingNDA] = useDeleteCompliancePageNDAMutation();
|
||||
|
||||
const handleNDAUpload = async (files: File[]) => {
|
||||
const handleNDAUpload = async (file: File) => {
|
||||
if (!organization.compliancePage?.id) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
@@ -61,10 +69,6 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
await uploadNDA({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -79,8 +83,36 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
|
||||
});
|
||||
};
|
||||
|
||||
const handleNDADelete = async () => {
|
||||
if (!organization.compliancePage?.id) {
|
||||
const handleNDAFileChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== "application/pdf") {
|
||||
toast({
|
||||
title: __("Unsupported file type"),
|
||||
description: __("Please upload a PDF file."),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: __("File size too large"),
|
||||
description: __("Please upload a file smaller than 10MB."),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void handleNDAUpload(file);
|
||||
};
|
||||
|
||||
const handleNDADelete = () => {
|
||||
const trustCenterId = organization.compliancePage?.id;
|
||||
if (!trustCenterId) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Compliance page not found"),
|
||||
@@ -89,107 +121,76 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(__("Are you sure you want to delete the NDA file?"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteNDA({
|
||||
variables: {
|
||||
input: {
|
||||
trustCenterId: organization.compliancePage.id,
|
||||
},
|
||||
confirm(
|
||||
() => deleteNDA({ variables: { input: { trustCenterId } } }),
|
||||
{
|
||||
title: __("Delete NDA"),
|
||||
message: __("Are you sure you want to delete the NDA file? This action cannot be undone."),
|
||||
label: __("Delete"),
|
||||
variant: "danger",
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const compliancePage = organization.compliancePage;
|
||||
const hasNDA = !!compliancePage?.nda?.fileName;
|
||||
const canUploadNDA = compliancePage?.canUploadNDA;
|
||||
const isBusy = isUploadingNDA || isDeletingNDA;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">
|
||||
{__("Non-Disclosure Agreement")}
|
||||
</h2>
|
||||
{(isUploadingNDA || isDeletingNDA) && <Spinner />}
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Require visitors to accept a Non-Disclosure Agreement before accessing your compliance page.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{!organization.compliancePage?.nda?.fileName
|
||||
&& organization.compliancePage?.canUploadNDA
|
||||
|
||||
<div className="space-y-3">
|
||||
{hasNDA && compliancePage
|
||||
? (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Upload a Non-Disclosure Agreement that visitors must accept before accessing your compliance page",
|
||||
)}
|
||||
</p>
|
||||
<CompliancePageNDACard
|
||||
compliancePageKey={compliancePage}
|
||||
isBusy={isBusy}
|
||||
isUploading={isUploadingNDA}
|
||||
onFileChange={handleNDAFileChange}
|
||||
onDelete={handleNDADelete}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<></>
|
||||
)}
|
||||
{organization.compliancePage?.nda?.fileName
|
||||
? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
{organization.compliancePage.nda?.fileName
|
||||
|| __("Non-Disclosure Agreement")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__(
|
||||
"Visitors will need to accept this NDA before accessing your compliance page",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (organization.compliancePage?.nda?.downloadUrl) {
|
||||
window.open(
|
||||
organization.compliancePage.nda.downloadUrl,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</Button>
|
||||
{organization.compliancePage?.canDeleteNDA && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => void handleNDADelete()}
|
||||
disabled={isDeletingNDA}
|
||||
/>
|
||||
: canUploadNDA
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
|
||||
<p className="max-w-md text-center text-sm text-txt-tertiary">
|
||||
{__(
|
||||
"Upload a PDF that visitors must accept before they can access your compliance page.",
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
<Button
|
||||
iconAfter={IconChevronRight}
|
||||
disabled={isBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploadingNDA ? __("Uploading...") : __("Upload NDA")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={handleNDAFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{organization.compliancePage?.canUploadNDA
|
||||
? (
|
||||
<Dropzone
|
||||
description={__("Upload PDF files up to 10MB")}
|
||||
isUploading={isUploadingNDA}
|
||||
onDrop={files => void handleNDAUpload(files)}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
}}
|
||||
maxSize={10}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("No NDA file uploaded")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("No NDA file uploaded")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { CompliancePageStatusSectionFragment$key } from "#/__generated__/core/CompliancePageStatusSectionFragment.graphql";
|
||||
import { useUpdateTrustCenterMutation } from "#/hooks/graph/TrustCenterGraph";
|
||||
import { useUpdateCompliancePageMutation } from "#/hooks/graph/CompliancePageGraph";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment CompliancePageStatusSectionFragment on Organization {
|
||||
@@ -32,7 +32,7 @@ const fragment = graphql`
|
||||
id
|
||||
active
|
||||
searchEngineIndexing
|
||||
canUpdate: permission(action: "core:trust-center:update")
|
||||
canUpdate: permission(action: "compliance-portal:portal:update")
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -50,7 +50,7 @@ export function CompliancePageStatusSection(props: {
|
||||
fragmentRef,
|
||||
);
|
||||
|
||||
const [updateCompliancePage, isUpdating] = useUpdateTrustCenterMutation();
|
||||
const [updateCompliancePage, isUpdating] = useUpdateCompliancePageMutation();
|
||||
|
||||
const handleToggleActive = async (active: boolean) => {
|
||||
if (!organization.compliancePage?.id) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// SOFTWARE.
|
||||
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
@@ -36,11 +37,9 @@ export const compliancePageRoutes = [
|
||||
},
|
||||
{
|
||||
path: "domain",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("#/pages/organizations/compliance-page/domain/CompliancePageDomainPageLoader"),
|
||||
),
|
||||
loader: () => {
|
||||
throw redirect("brand");
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "brand",
|
||||
|
||||
Reference in New Issue
Block a user