mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: support embedded authoring for creation (#1741)
Adds support for creating documents and templates using our embed components. Support is super primitive at the moment and is being polished.
This commit is contained in:
66
apps/remix/app/routes/embed+/v1.authoring+/_layout.tsx
Normal file
66
apps/remix/app/routes/embed+/v1.authoring+/_layout.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { TrpcProvider, trpc } from '@documenso/trpc/react';
|
||||
|
||||
import { EmbedClientLoading } from '~/components/embed/embed-client-loading';
|
||||
import { ZBaseEmbedAuthoringSchema } from '~/types/embed-authoring-base-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
export default function AuthoringLayout() {
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const {
|
||||
mutateAsync: verifyEmbeddingPresignToken,
|
||||
isPending: isVerifyingEmbeddingPresignToken,
|
||||
data: isVerified,
|
||||
} = trpc.embeddingPresign.verifyEmbeddingPresignToken.useMutation();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, css, cssVars, darkModeDisabled } = result.data;
|
||||
|
||||
if (darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
injectCss({
|
||||
css,
|
||||
cssVars,
|
||||
});
|
||||
|
||||
void verifyEmbeddingPresignToken({ token }).then((result) => {
|
||||
if (result.success) {
|
||||
setToken(token);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error verifying embedding presign token:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isVerifyingEmbeddingPresignToken) {
|
||||
return <EmbedClientLoading />;
|
||||
}
|
||||
|
||||
if (typeof isVerified !== 'undefined' && !isVerified.success) {
|
||||
return <div>Invalid embedding presign token</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrpcProvider headers={{ authorization: `Bearer ${token}` }}>
|
||||
<Outlet />
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export default function EmbeddingAuthoringCompletedPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get templateId and externalId from URL search params
|
||||
const templateId = searchParams.get('templateId');
|
||||
const documentId = searchParams.get('documentId');
|
||||
const externalId = searchParams.get('externalId');
|
||||
|
||||
const id = Number(templateId || documentId);
|
||||
const type = templateId ? 'template' : 'document';
|
||||
|
||||
// Send postMessage to parent window with the details
|
||||
useEffect(() => {
|
||||
if (!id || !window.parent || window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: `${type}-created`,
|
||||
[`${type}Id`]: id,
|
||||
externalId,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}, [id, type, externalId]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center p-6 text-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<CheckCircle2 className="text-primary mx-auto h-16 w-16" />
|
||||
|
||||
<h1 className="mt-6 text-2xl font-bold">
|
||||
{type === 'template' ? <Trans>Template Created</Trans> : <Trans>Document Created</Trans>}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{type === 'template' ? (
|
||||
<Trans>Your template has been created successfully</Trans>
|
||||
) : (
|
||||
<Trans>Your document has been created successfully</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
apps/remix/app/routes/embed+/v1.authoring+/document.create.tsx
Normal file
184
apps/remix/app/routes/embed+/v1.authoring+/document.create.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import {
|
||||
ConfigureFieldsView,
|
||||
type TConfigureFieldsFormSchema,
|
||||
} from '~/components/embed/authoring/configure-fields-view';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
} from '~/types/embed-authoring-base-schema';
|
||||
|
||||
export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||
const [externalId, setExternalId] = useState<string | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const { mutateAsync: createEmbeddingDocument } =
|
||||
trpc.embeddingPresign.createEmbeddingDocument.useMutation();
|
||||
|
||||
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||
// Store the configuration data and move to the field placement stage
|
||||
setConfiguration(data);
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||
// Return to the configuration view but keep the data
|
||||
setFields(data);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||
try {
|
||||
if (!configuration || !configuration.documentData) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _('Error'),
|
||||
description: _('Please configure the document first'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
const documentData = await putPdfFile({
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
});
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const documentExternalId = externalId || configuration.meta.externalId;
|
||||
|
||||
const createResult = await createEmbeddingDocument({
|
||||
title: configuration.title,
|
||||
documentDataId: documentData.id,
|
||||
externalId: documentExternalId,
|
||||
meta: {
|
||||
...configuration.meta,
|
||||
drawSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled:
|
||||
configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
role: signer.role,
|
||||
fields: fields
|
||||
.filter((field) => field.signerEmail === signer.email)
|
||||
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map<any>((f) => ({
|
||||
...f,
|
||||
pageX: f.pageX,
|
||||
pageY: f.pageY,
|
||||
width: f.pageWidth,
|
||||
height: f.pageHeight,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _('Success'),
|
||||
description: _('Document created successfully'),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the document details
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'document-created',
|
||||
documentId: createResult.documentId,
|
||||
externalId: documentExternalId,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
// Navigate to the completion page instead of the document details page
|
||||
await navigate(
|
||||
`/embed/v1/authoring/create-completed?documentId=${createResult.documentId}&externalId=${documentExternalId}#${hash}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error creating document:', err);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _('Error'),
|
||||
description: _('Failed to create document'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeatures(result.data.features);
|
||||
|
||||
// Extract externalId from the parsed data if available
|
||||
if (result.data.externalId) {
|
||||
setExternalId(result.data.externalId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing embedding params:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||
<ConfigureDocumentProvider isTemplate={false} features={features ?? {}}>
|
||||
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||
<ConfigureDocumentView
|
||||
defaultValues={configuration ?? undefined}
|
||||
onSubmit={handleConfigurePageViewSubmit}
|
||||
/>
|
||||
|
||||
<ConfigureFieldsView
|
||||
configData={configuration!}
|
||||
defaultValues={fields ?? undefined}
|
||||
onBack={handleBackToConfig}
|
||||
onSubmit={handleConfigureFieldsSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</ConfigureDocumentProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/remix/app/routes/embed+/v1.authoring+/template.create.tsx
Normal file
175
apps/remix/app/routes/embed+/v1.authoring+/template.create.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ConfigureDocumentProvider } from '~/components/embed/authoring/configure-document-context';
|
||||
import { ConfigureDocumentView } from '~/components/embed/authoring/configure-document-view';
|
||||
import type { TConfigureEmbedFormSchema } from '~/components/embed/authoring/configure-document-view.types';
|
||||
import {
|
||||
ConfigureFieldsView,
|
||||
type TConfigureFieldsFormSchema,
|
||||
} from '~/components/embed/authoring/configure-fields-view';
|
||||
import {
|
||||
type TBaseEmbedAuthoringSchema,
|
||||
ZBaseEmbedAuthoringSchema,
|
||||
} from '~/types/embed-authoring-base-schema';
|
||||
|
||||
export default function EmbeddingAuthoringTemplateCreatePage() {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [configuration, setConfiguration] = useState<TConfigureEmbedFormSchema | null>(null);
|
||||
const [fields, setFields] = useState<TConfigureFieldsFormSchema | null>(null);
|
||||
const [features, setFeatures] = useState<TBaseEmbedAuthoringSchema['features'] | null>(null);
|
||||
const [externalId, setExternalId] = useState<string | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
const { mutateAsync: createEmbeddingTemplate } =
|
||||
trpc.embeddingPresign.createEmbeddingTemplate.useMutation();
|
||||
|
||||
const handleConfigurePageViewSubmit = (data: TConfigureEmbedFormSchema) => {
|
||||
// Store the configuration data and move to the field placement stage
|
||||
setConfiguration(data);
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleBackToConfig = (data: TConfigureFieldsFormSchema) => {
|
||||
// Return to the configuration view but keep the data
|
||||
setFields(data);
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleConfigureFieldsSubmit = async (data: TConfigureFieldsFormSchema) => {
|
||||
try {
|
||||
console.log('configuration', configuration);
|
||||
console.log('data', data);
|
||||
if (!configuration || !configuration.documentData) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _('Error'),
|
||||
description: _('Please configure the template first'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = data.fields;
|
||||
|
||||
const documentData = await putPdfFile({
|
||||
arrayBuffer: async () => Promise.resolve(configuration.documentData!.data.buffer),
|
||||
name: configuration.documentData.name,
|
||||
type: configuration.documentData.type,
|
||||
});
|
||||
|
||||
// Use the externalId from the URL fragment if available
|
||||
const metaWithExternalId = {
|
||||
...configuration.meta,
|
||||
externalId: externalId || configuration.meta.externalId,
|
||||
};
|
||||
|
||||
const createResult = await createEmbeddingTemplate({
|
||||
title: configuration.title,
|
||||
documentDataId: documentData.id,
|
||||
meta: metaWithExternalId,
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
role: signer.role,
|
||||
fields: fields
|
||||
.filter((field) => field.signerEmail === signer.email)
|
||||
// There's a gnarly discriminated union that makes this hard to satisfy, we're casting for the second
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map<any>((field) => ({
|
||||
...field,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _('Success'),
|
||||
description: _('Template created successfully'),
|
||||
});
|
||||
|
||||
// Send a message to the parent window with the template details
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'template-created',
|
||||
templateId: createResult.templateId,
|
||||
externalId: metaWithExternalId.externalId,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
// Navigate to the completion page instead of the template details page
|
||||
await navigate(
|
||||
`/embed/v1/authoring/create-completed?templateId=${createResult.templateId}&externalId=${metaWithExternalId.externalId}#${hash}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error creating template:', err);
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: _('Error'),
|
||||
description: _('Failed to create template'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
try {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const result = ZBaseEmbedAuthoringSchema.safeParse(
|
||||
JSON.parse(decodeURIComponent(atob(hash))),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeatures(result.data.features);
|
||||
|
||||
// Extract externalId from the parsed data if available
|
||||
if (result.data.externalId) {
|
||||
setExternalId(result.data.externalId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing embedding params:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg p-6">
|
||||
<ConfigureDocumentProvider isTemplate={true} features={features ?? {}}>
|
||||
<Stepper currentStep={currentStep} setCurrentStep={setCurrentStep}>
|
||||
<ConfigureDocumentView
|
||||
defaultValues={configuration ?? undefined}
|
||||
onSubmit={handleConfigurePageViewSubmit}
|
||||
/>
|
||||
|
||||
<ConfigureFieldsView
|
||||
configData={configuration!}
|
||||
defaultValues={fields ?? undefined}
|
||||
onBack={handleBackToConfig}
|
||||
onSubmit={handleConfigureFieldsSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</ConfigureDocumentProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user