mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
fix: add embed
This commit is contained in:
@ -1,5 +1,21 @@
|
|||||||
@import '@documenso/ui/styles/theme.css';
|
@import '@documenso/ui/styles/theme.css';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
src: url('/public/fonts/inter-regular.ttf') format('ttf');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Caveat';
|
||||||
|
src: url('/public/fonts/caveat.ttf') format('ttf');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'Inter';
|
--font-sans: 'Inter';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -19,6 +19,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
|||||||
|
|
||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
import { langCookie } from './storage/lang-cookie.server';
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
import { themeSessionResolver } from './storage/theme-session.server';
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
@ -117,28 +119,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
let message = 'Oops!';
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
let details = 'An unexpected error occurred.';
|
|
||||||
let stack: string | undefined;
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
return <GenericErrorLayout errorCode={errorCode} />;
|
||||||
message = error.status === 404 ? '404' : 'Error';
|
|
||||||
details =
|
|
||||||
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
|
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
|
||||||
details = error.message;
|
|
||||||
stack = error.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto p-4 pt-16">
|
|
||||||
<h1>{message}</h1>
|
|
||||||
<p>{details}</p>
|
|
||||||
{stack && (
|
|
||||||
<pre className="w-full overflow-x-auto p-4">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/remix/app/routes/embed+/_layout.tsx
Normal file
26
apps/remix/app/routes/embed+/_layout.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
||||||
|
|
||||||
|
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||||
|
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||||
|
return (
|
||||||
|
<EmbedAuthenticationRequired email={error.data.email} returnTo={error.data.returnTo} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 403 && error.data.type === 'embed-paywall') {
|
||||||
|
return <EmbedPaywall />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>Not Found</div>;
|
||||||
|
}
|
||||||
145
apps/remix/app/routes/embed+/direct.$url.tsx
Normal file
145
apps/remix/app/routes/embed+/direct.$url.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
|
||||||
|
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||||
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/direct.$url';
|
||||||
|
|
||||||
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
|
if (!params.url) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.url;
|
||||||
|
|
||||||
|
const template = await getTemplateByDirectLinkToken({
|
||||||
|
token,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
// `template.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!template || !template.directLink) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !template.teamId) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = getRequiredSessionContext(context);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||||
|
isDocumentPlatform(template),
|
||||||
|
isUserEnterprise({
|
||||||
|
userId: template.userId,
|
||||||
|
teamId: template.teamId ?? undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||||
|
.with(null, () => true)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email,
|
||||||
|
returnTo: `/embed/direct/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directTemplateRecipientId } = template.directLink;
|
||||||
|
|
||||||
|
const recipient = template.recipients.find(
|
||||||
|
(recipient) => recipient.id === directTemplateRecipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||||
|
|
||||||
|
const team = template.teamId
|
||||||
|
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
template,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
isPlatformDocument,
|
||||||
|
isEnterpriseDocument,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedDirectTemplatePage() {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
template,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
isPlatformDocument,
|
||||||
|
isEnterpriseDocument,
|
||||||
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={template.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EmbedDirectTemplateClientPage
|
||||||
|
token={token}
|
||||||
|
updatedAt={template.updatedAt}
|
||||||
|
documentData={template.templateDocumentData}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
metadata={template.templateMeta}
|
||||||
|
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||||
|
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||||
|
/>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/remix/app/routes/embed+/sign.$url.tsx
Normal file
147
apps/remix/app/routes/embed+/sign.$url.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||||
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/sign.$url';
|
||||||
|
|
||||||
|
export async function loader({ params, context }: Route.LoaderArgs) {
|
||||||
|
if (!params.url) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.url;
|
||||||
|
|
||||||
|
const { user } = getRequiredSessionContext(context);
|
||||||
|
|
||||||
|
const [document, fields, recipient] = await Promise.all([
|
||||||
|
getDocumentAndSenderByToken({
|
||||||
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
|
}).catch(() => null),
|
||||||
|
getFieldsForToken({ token }),
|
||||||
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// `document.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!document || !recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !document.teamId) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||||
|
isDocumentPlatform(document),
|
||||||
|
isUserEnterprise({
|
||||||
|
userId: document.userId,
|
||||||
|
teamId: document.teamId ?? undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||||
|
.with(null, () => true)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email || recipient.email,
|
||||||
|
returnTo: `/embed/sign/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = document.teamId
|
||||||
|
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
isPlatformDocument,
|
||||||
|
isEnterpriseDocument,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedSignDocumentPage() {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
isPlatformDocument,
|
||||||
|
isEnterpriseDocument,
|
||||||
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider
|
||||||
|
email={recipient.email}
|
||||||
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={document.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EmbedSignDocumentClientPage
|
||||||
|
token={token}
|
||||||
|
documentId={document.id}
|
||||||
|
documentData={document.documentData}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
metadata={document.documentMeta}
|
||||||
|
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||||
|
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||||
|
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||||
|
/>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/remix/app/types/embed-base-schemas.ts
Normal file
12
apps/remix/app/types/embed-base-schemas.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZCssVarsSchema } from '../utils/css-vars';
|
||||||
|
|
||||||
|
export const ZBaseEmbedDataSchema = z.object({
|
||||||
|
darkModeDisabled: z.boolean().optional().default(false),
|
||||||
|
css: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined),
|
||||||
|
cssVars: ZCssVarsSchema.optional().default({}),
|
||||||
|
});
|
||||||
20
apps/remix/app/types/embed-direct-template-schema.ts
Normal file
20
apps/remix/app/types/embed-direct-template-schema.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||||
|
|
||||||
|
export const ZDirectTemplateEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||||
|
email: z
|
||||||
|
.union([z.literal(''), z.string().email()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined),
|
||||||
|
lockEmail: z.boolean().optional().default(false),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined),
|
||||||
|
lockName: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDirectTemplateEmbedDataSchema = z.infer<typeof ZDirectTemplateEmbedDataSchema>;
|
||||||
|
|
||||||
|
export type TDirectTemplateEmbedDataInputSchema = z.input<typeof ZDirectTemplateEmbedDataSchema>;
|
||||||
16
apps/remix/app/types/embed-document-sign-schema.ts
Normal file
16
apps/remix/app/types/embed-document-sign-schema.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||||
|
|
||||||
|
export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||||
|
email: z
|
||||||
|
.union([z.literal(''), z.string().email()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined),
|
||||||
|
lockEmail: z.boolean().optional().default(false),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || undefined),
|
||||||
|
lockName: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
78
apps/remix/app/utils/css-vars.ts
Normal file
78
apps/remix/app/utils/css-vars.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { colord } from 'colord';
|
||||||
|
import { toKebabCase } from 'remeda';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZCssVarsSchema = z
|
||||||
|
.object({
|
||||||
|
background: z.string().optional().describe('Base background color'),
|
||||||
|
foreground: z.string().optional().describe('Base text color'),
|
||||||
|
muted: z.string().optional().describe('Muted/subtle background color'),
|
||||||
|
mutedForeground: z.string().optional().describe('Muted/subtle text color'),
|
||||||
|
popover: z.string().optional().describe('Popover/dropdown background color'),
|
||||||
|
popoverForeground: z.string().optional().describe('Popover/dropdown text color'),
|
||||||
|
card: z.string().optional().describe('Card background color'),
|
||||||
|
cardBorder: z.string().optional().describe('Card border color'),
|
||||||
|
cardBorderTint: z.string().optional().describe('Card border tint/highlight color'),
|
||||||
|
cardForeground: z.string().optional().describe('Card text color'),
|
||||||
|
fieldCard: z.string().optional().describe('Field card background color'),
|
||||||
|
fieldCardBorder: z.string().optional().describe('Field card border color'),
|
||||||
|
fieldCardForeground: z.string().optional().describe('Field card text color'),
|
||||||
|
widget: z.string().optional().describe('Widget background color'),
|
||||||
|
widgetForeground: z.string().optional().describe('Widget text color'),
|
||||||
|
border: z.string().optional().describe('Default border color'),
|
||||||
|
input: z.string().optional().describe('Input field border color'),
|
||||||
|
primary: z.string().optional().describe('Primary action/button color'),
|
||||||
|
primaryForeground: z.string().optional().describe('Primary action/button text color'),
|
||||||
|
secondary: z.string().optional().describe('Secondary action/button color'),
|
||||||
|
secondaryForeground: z.string().optional().describe('Secondary action/button text color'),
|
||||||
|
accent: z.string().optional().describe('Accent/highlight color'),
|
||||||
|
accentForeground: z.string().optional().describe('Accent/highlight text color'),
|
||||||
|
destructive: z.string().optional().describe('Destructive/danger action color'),
|
||||||
|
destructiveForeground: z.string().optional().describe('Destructive/danger text color'),
|
||||||
|
ring: z.string().optional().describe('Focus ring color'),
|
||||||
|
radius: z.string().optional().describe('Border radius size in REM units'),
|
||||||
|
warning: z.string().optional().describe('Warning/alert color'),
|
||||||
|
})
|
||||||
|
.describe('Custom CSS variables for theming');
|
||||||
|
|
||||||
|
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
|
||||||
|
|
||||||
|
export const toNativeCssVars = (vars: TCssVarsSchema) => {
|
||||||
|
const cssVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
const { radius, ...colorVars } = vars;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(colorVars)) {
|
||||||
|
if (value) {
|
||||||
|
const color = colord(value);
|
||||||
|
const { h, s, l } = color.toHsl();
|
||||||
|
|
||||||
|
cssVars[`--${toKebabCase(key)}`] = `${h} ${s} ${l}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radius) {
|
||||||
|
cssVars[`--radius`] = `${radius}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssVars;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
|
||||||
|
const { css, cssVars } = options;
|
||||||
|
|
||||||
|
if (css) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = css;
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cssVars) {
|
||||||
|
const nativeVars = toNativeCssVars(cssVars);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(nativeVars)) {
|
||||||
|
document.documentElement.style.setProperty(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
apps/remix/public/fonts/caveat-regular.ttf
Normal file
BIN
apps/remix/public/fonts/caveat-regular.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/caveat.ttf
Normal file
BIN
apps/remix/public/fonts/caveat.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/inter-bold.ttf
Normal file
BIN
apps/remix/public/fonts/inter-bold.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/inter-regular.ttf
Normal file
BIN
apps/remix/public/fonts/inter-regular.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/inter-semibold.ttf
Normal file
BIN
apps/remix/public/fonts/inter-semibold.ttf
Normal file
Binary file not shown.
BIN
apps/remix/public/fonts/noto-sans.ttf
Normal file
BIN
apps/remix/public/fonts/noto-sans.ttf
Normal file
Binary file not shown.
@ -25,7 +25,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
// , 'next/font/google' doesnot work
|
|
||||||
noExternal: [
|
noExternal: [
|
||||||
'react-dropzone',
|
'react-dropzone',
|
||||||
'recharts',
|
'recharts',
|
||||||
|
|||||||
Reference in New Issue
Block a user