mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 19:51:32 +10:00
fix: add embed
This commit is contained in:
@ -0,0 +1,35 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export type EmbedAuthenticationRequiredProps = {
|
||||
email?: string;
|
||||
returnTo: string;
|
||||
};
|
||||
|
||||
export const EmbedAuthenticationRequired = ({
|
||||
email,
|
||||
returnTo,
|
||||
}: EmbedAuthenticationRequiredProps) => {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
||||
<div className="flex w-full max-w-md flex-col">
|
||||
<Logo className="h-8" />
|
||||
|
||||
<Alert className="mt-8" variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
To view this document you need to be signed into your account, please sign in to
|
||||
continue.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
apps/remix/app/components/embed/embed-client-loading.tsx
Normal file
7
apps/remix/app/components/embed/embed-client-loading.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const EmbedClientLoading = () => {
|
||||
return (
|
||||
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,501 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
updatedAt,
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const {
|
||||
fullName,
|
||||
email,
|
||||
signature,
|
||||
signatureValid,
|
||||
setFullName,
|
||||
setEmail,
|
||||
setSignature,
|
||||
setSignatureValid,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(false);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [isEmailLocked, setIsEmailLocked] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
localFields.filter((field) => !field.inserted),
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
const onSignField = (payload: TSignFieldWithTokenMutationSchema) => {
|
||||
setLocalFields((fields) =>
|
||||
fields.map((field) => {
|
||||
if (field.id !== payload.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const newField: DirectTemplateLocalField = structuredClone({
|
||||
...field,
|
||||
customText: payload.value,
|
||||
inserted: true,
|
||||
signedValue: payload,
|
||||
});
|
||||
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
newField.signature = {
|
||||
id: 1,
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||
} satisfies Signature;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
newField.customText = DateTime.now()
|
||||
.setZone(metadata?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
}
|
||||
|
||||
return newField;
|
||||
}),
|
||||
);
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-signed',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setShowPendingFieldTooltip(false);
|
||||
};
|
||||
|
||||
const onUnsignField = (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
||||
setLocalFields((fields) =>
|
||||
fields.map((field) => {
|
||||
if (field.id !== payload.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return structuredClone({
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
signedValue: undefined,
|
||||
signature: undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-unsigned',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setShowPendingFieldTooltip(false);
|
||||
};
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(localFields);
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const onCompleteClick = async () => {
|
||||
try {
|
||||
if (hasSignatureField && !signatureValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validateFieldsInserted(localFields);
|
||||
|
||||
if (!valid) {
|
||||
setShowPendingFieldTooltip(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
|
||||
|
||||
if (directTemplateExternalId) {
|
||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||
}
|
||||
|
||||
localFields.forEach((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
documentId,
|
||||
token: documentToken,
|
||||
recipientId,
|
||||
} = await createDocumentFromDirectTemplate({
|
||||
directTemplateToken: token,
|
||||
directTemplateExternalId,
|
||||
directRecipientName: fullName,
|
||||
directRecipientEmail: email,
|
||||
templateUpdatedAt: updatedAt,
|
||||
signedFieldValues: localFields.map((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
|
||||
return field.signedValue;
|
||||
}),
|
||||
});
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-completed',
|
||||
data: {
|
||||
token: documentToken,
|
||||
documentId,
|
||||
recipientId,
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasCompletedDocument(true);
|
||||
} catch (err) {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-error',
|
||||
data: String(err),
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to submit this document at this time. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
const data = ZDirectTemplateEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (data.email) {
|
||||
setEmail(data.email);
|
||||
setIsEmailLocked(!!data.lockEmail);
|
||||
}
|
||||
|
||||
if (data.name) {
|
||||
setFullName(data.name);
|
||||
setIsNameLocked(!!data.lockName);
|
||||
}
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-ready',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
name={fullName}
|
||||
signature={{
|
||||
id: 1,
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
disabled={isEmailLocked}
|
||||
value={email}
|
||||
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={isThrottled}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
recipient={recipient}
|
||||
fields={localFields}
|
||||
metadata={metadata}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
apps/remix/app/components/embed/embed-document-completed.tsx
Normal file
37
apps/remix/app/components/embed/embed-document-completed.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
import type { Signature } from '@documenso/prisma/client';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
export type EmbedDocumentCompletedPageProps = {
|
||||
name?: string;
|
||||
signature?: Signature;
|
||||
};
|
||||
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Document Completed!</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 w-full max-w-md">
|
||||
<SigningCard3D
|
||||
className="mx-auto w-full"
|
||||
name={name || 'Documenso'}
|
||||
signature={signature}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-8 max-w-[50ch] text-center text-sm">
|
||||
<Trans>
|
||||
The document is now completed, please follow any instructions provided within the parent
|
||||
application.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
184
apps/remix/app/components/embed/embed-document-fields.tsx
Normal file
184
apps/remix/app/components/embed/embed-document-fields.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type Field, FieldType } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
|
||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
|
||||
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
|
||||
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
|
||||
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
|
||||
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
|
||||
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
|
||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EmbedDocumentFields = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: EmbedDocumentFieldsProps) => {
|
||||
return (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<DocumentSigningSignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<DocumentSigningInitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<DocumentSigningNameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DocumentSigningDateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={metadata?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<DocumentSigningEmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentSigningTextField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentSigningNumberField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentSigningRadioField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentSigningCheckboxField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentSigningDropdownField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
);
|
||||
};
|
||||
375
apps/remix/app/components/embed/embed-document-signing-page.tsx
Normal file
375
apps/remix/app/components/embed/embed-document-signing-page.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
fullName,
|
||||
email,
|
||||
signature,
|
||||
signatureValid,
|
||||
setFullName,
|
||||
setSignature,
|
||||
setSignatureValid,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter((field) => !field.inserted),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(fields);
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const onCompleteClick = async () => {
|
||||
try {
|
||||
if (hasSignatureField && !signatureValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validateFieldsInserted(fields);
|
||||
|
||||
if (!valid) {
|
||||
setShowPendingFieldTooltip(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await completeDocumentWithToken({
|
||||
documentId,
|
||||
token,
|
||||
});
|
||||
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-completed',
|
||||
data: {
|
||||
token,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasCompletedDocument(true);
|
||||
} catch (err) {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-error',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to submit this document at this time. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-ready',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
name={fullName}
|
||||
signature={{
|
||||
id: 1,
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
apps/remix/app/components/embed/embed-paywall.tsx
Normal file
7
apps/remix/app/components/embed/embed-paywall.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const EmbedPaywall = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Paywall</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
apps/remix/app/components/general/generic-error-layout.tsx
Normal file
97
apps/remix/app/components/general/generic-error-layout.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type GenericErrorLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
errorCode?: number;
|
||||
};
|
||||
|
||||
export const ErrorLayoutCodes: Record<
|
||||
number,
|
||||
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
|
||||
> = {
|
||||
404: {
|
||||
subHeading: msg`404 Page not found`,
|
||||
heading: msg`Oops! Something went wrong.`,
|
||||
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
|
||||
},
|
||||
500: {
|
||||
subHeading: msg`500 Internal Server Error`,
|
||||
heading: msg`Oops! Something went wrong.`,
|
||||
message: msg`An unexpected error occurred.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { subHeading, heading, message } =
|
||||
ErrorLayoutCodes[errorCode || 404] ?? ErrorLayoutCodes[404];
|
||||
|
||||
return (
|
||||
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||
<div className="absolute -inset-24 -z-10">
|
||||
<motion.div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||
>
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-ml-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex h-full min-h-screen items-center justify-center px-6 py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">{_(heading)}</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">{_(message)}</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void navigate(-1);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={formatDocumentsPath(team?.url)}>
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,73 +0,0 @@
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type NotFoundPartialProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function NotFoundPartial({ children }: NotFoundPartialProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||
<div className="absolute -inset-24 -z-10">
|
||||
<motion.div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||
>
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">
|
||||
<Trans>404 Page not found</Trans>
|
||||
</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">
|
||||
<Trans>Oops! Something went wrong.</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>
|
||||
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void navigate(-1);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user