diff --git a/apps/remix/app/app.css b/apps/remix/app/app.css index 790ce4abb..e239a4d41 100644 --- a/apps/remix/app/app.css +++ b/apps/remix/app/app.css @@ -1,5 +1,21 @@ @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 { :root { --font-sans: 'Inter'; diff --git a/apps/remix/app/components/embed/embed-authentication-required.tsx b/apps/remix/app/components/embed/embed-authentication-required.tsx new file mode 100644 index 000000000..5fd8bc69e --- /dev/null +++ b/apps/remix/app/components/embed/embed-authentication-required.tsx @@ -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 ( +
+
+ + + + + + To view this document you need to be signed into your account, please sign in to + continue. + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-client-loading.tsx b/apps/remix/app/components/embed/embed-client-loading.tsx new file mode 100644 index 000000000..d67af37a2 --- /dev/null +++ b/apps/remix/app/components/embed/embed-client-loading.tsx @@ -0,0 +1,7 @@ +export const EmbedClientLoading = () => { + return ( +
+ Loading... +
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx new file mode 100644 index 000000000..0c9d29f22 --- /dev/null +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -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(() => 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 ( + + ); + } + + return ( +
+ {(!hasFinishedInit || !hasDocumentLoaded) && } + +
+ {/* Viewer */} +
+ setHasDocumentLoaded(true)} + /> +
+ + {/* Widget */} +
+
+ {/* Header */} +
+
+

+ Sign document +

+ + +
+
+ +
+

+ Sign the document to complete the process. +

+ +
+
+ + {/* Form */} +
+
+
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + !isEmailLocked && setEmail(e.target.value.trim())} + /> +
+ +
+ + + + + { + setSignature(value); + }} + onValidityChange={(isValid) => { + setSignatureValid(isValid); + }} + allowTypedSignature={Boolean( + metadata && + 'typedSignatureEnabled' in metadata && + metadata.typedSignatureEnabled, + )} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+
+
+ +
+ +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + + {/* Fields */} + +
+ + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-document-completed.tsx b/apps/remix/app/components/embed/embed-document-completed.tsx new file mode 100644 index 000000000..1cfc07d3b --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-completed.tsx @@ -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 ( +
+

+ Document Completed! +

+ +
+ +
+ +

+ + The document is now completed, please follow any instructions provided within the parent + application. + +

+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx new file mode 100644 index 000000000..8675eabcf --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -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; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const EmbedDocumentFields = ({ + recipient, + fields, + metadata, + onSignField, + onUnsignField, +}: EmbedDocumentFieldsProps) => { + return ( + + {fields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.INITIALS, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, + }; + + return ( + + ); + }) + .with(FieldType.NUMBER, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, + }; + + return ( + + ); + }) + .with(FieldType.RADIO, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, + }; + + return ( + + ); + }) + .with(FieldType.CHECKBOX, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, + }; + + return ( + + ); + }) + .with(FieldType.DROPDOWN, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, + }; + + return ( + + ); + }) + .otherwise(() => null), + )} + + ); +}; diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx new file mode 100644 index 000000000..2aad8b292 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -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 ( + + ); + } + + return ( +
+ {(!hasFinishedInit || !hasDocumentLoaded) && } + +
+ {/* Viewer */} +
+ setHasDocumentLoaded(true)} + /> +
+ + {/* Widget */} +
+
+ {/* Header */} +
+
+

+ Sign document +

+ + +
+
+ +
+

+ Sign the document to complete the process. +

+ +
+
+ + {/* Form */} +
+
+
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ +
+ + + + + { + setSignature(value); + }} + onValidityChange={(isValid) => { + setSignatureValid(isValid); + }} + allowTypedSignature={Boolean( + metadata && + 'typedSignatureEnabled' in metadata && + metadata.typedSignatureEnabled, + )} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+
+
+ +
+ +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + + {/* Fields */} + +
+ + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-paywall.tsx b/apps/remix/app/components/embed/embed-paywall.tsx new file mode 100644 index 000000000..aa3af647f --- /dev/null +++ b/apps/remix/app/components/embed/embed-paywall.tsx @@ -0,0 +1,7 @@ +export const EmbedPaywall = () => { + return ( +
+

Paywall

+
+ ); +}; diff --git a/apps/remix/app/components/general/generic-error-layout.tsx b/apps/remix/app/components/general/generic-error-layout.tsx new file mode 100644 index 000000000..138bb4159 --- /dev/null +++ b/apps/remix/app/components/general/generic-error-layout.tsx @@ -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 ( +
+
+ + background pattern + +
+ +
+
+

{_(subHeading)}

+ +

{_(heading)}

+ +

{_(message)}

+ +
+ + + + + {children} +
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/partials/not-found.tsx b/apps/remix/app/components/partials/not-found.tsx deleted file mode 100644 index 4cea36763..000000000 --- a/apps/remix/app/components/partials/not-found.tsx +++ /dev/null @@ -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 ( -
-
- - background pattern - -
- -
-
-

- 404 Page not found -

- -

- Oops! Something went wrong. -

- -

- - The page you are looking for was moved, removed, renamed or might never have existed. - -

- -
- - - {children} -
-
-
-
- ); -} diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index fd685868a..546fe4f48 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; +import { GenericErrorLayout } from './components/general/generic-error-layout'; import { langCookie } from './storage/lang-cookie.server'; import { themeSessionResolver } from './storage/theme-session.server'; @@ -79,6 +80,7 @@ export function Layout({ children }: { children: React.ReactNode }) { + @@ -117,28 +119,7 @@ export default function App({ loaderData }: Route.ComponentProps) { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = 'Oops!'; - let details = 'An unexpected error occurred.'; - let stack: string | undefined; + const errorCode = isRouteErrorResponse(error) ? error.status : 500; - if (isRouteErrorResponse(error)) { - 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 ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} -
- ); + return ; } diff --git a/apps/remix/app/routes/embed+/_layout.tsx b/apps/remix/app/routes/embed+/_layout.tsx new file mode 100644 index 000000000..376e90351 --- /dev/null +++ b/apps/remix/app/routes/embed+/_layout.tsx @@ -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 ; +} + +export function ErrorBoundary() { + const error = useRouteError(); + + if (isRouteErrorResponse(error)) { + if (error.status === 401 && error.data.type === 'embed-authentication-required') { + return ( + + ); + } + + if (error.status === 403 && error.data.type === 'embed-paywall') { + return ; + } + } + + return
Not Found
; +} diff --git a/apps/remix/app/routes/embed+/direct.$url.tsx b/apps/remix/app/routes/embed+/direct.$url.tsx new file mode 100644 index 000000000..f3cea512c --- /dev/null +++ b/apps/remix/app/routes/embed+/direct.$url.tsx @@ -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(); + + return ( + + + + + + ); +} diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx new file mode 100644 index 000000000..c46507b90 --- /dev/null +++ b/apps/remix/app/routes/embed+/sign.$url.tsx @@ -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(); + + return ( + + + + + + ); +} diff --git a/apps/remix/app/types/embed-base-schemas.ts b/apps/remix/app/types/embed-base-schemas.ts new file mode 100644 index 000000000..f12a120a4 --- /dev/null +++ b/apps/remix/app/types/embed-base-schemas.ts @@ -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({}), +}); diff --git a/apps/remix/app/types/embed-direct-template-schema.ts b/apps/remix/app/types/embed-direct-template-schema.ts new file mode 100644 index 000000000..41754f950 --- /dev/null +++ b/apps/remix/app/types/embed-direct-template-schema.ts @@ -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; + +export type TDirectTemplateEmbedDataInputSchema = z.input; diff --git a/apps/remix/app/types/embed-document-sign-schema.ts b/apps/remix/app/types/embed-document-sign-schema.ts new file mode 100644 index 000000000..93830aa7c --- /dev/null +++ b/apps/remix/app/types/embed-document-sign-schema.ts @@ -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), +}); diff --git a/apps/remix/app/utils/css-vars.ts b/apps/remix/app/utils/css-vars.ts new file mode 100644 index 000000000..330c6695b --- /dev/null +++ b/apps/remix/app/utils/css-vars.ts @@ -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; + +export const toNativeCssVars = (vars: TCssVarsSchema) => { + const cssVars: Record = {}; + + 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); + } + } +}; diff --git a/apps/remix/public/fonts/caveat-regular.ttf b/apps/remix/public/fonts/caveat-regular.ttf new file mode 100644 index 000000000..96540955a Binary files /dev/null and b/apps/remix/public/fonts/caveat-regular.ttf differ diff --git a/apps/remix/public/fonts/caveat.ttf b/apps/remix/public/fonts/caveat.ttf new file mode 100644 index 000000000..d0a6c3ffc Binary files /dev/null and b/apps/remix/public/fonts/caveat.ttf differ diff --git a/apps/remix/public/fonts/inter-bold.ttf b/apps/remix/public/fonts/inter-bold.ttf new file mode 100644 index 000000000..8e82c70d1 Binary files /dev/null and b/apps/remix/public/fonts/inter-bold.ttf differ diff --git a/apps/remix/public/fonts/inter-regular.ttf b/apps/remix/public/fonts/inter-regular.ttf new file mode 100644 index 000000000..8d4eebf20 Binary files /dev/null and b/apps/remix/public/fonts/inter-regular.ttf differ diff --git a/apps/remix/public/fonts/inter-semibold.ttf b/apps/remix/public/fonts/inter-semibold.ttf new file mode 100644 index 000000000..c6aeeb16a Binary files /dev/null and b/apps/remix/public/fonts/inter-semibold.ttf differ diff --git a/apps/remix/public/fonts/noto-sans.ttf b/apps/remix/public/fonts/noto-sans.ttf new file mode 100644 index 000000000..fa4cff505 Binary files /dev/null and b/apps/remix/public/fonts/noto-sans.ttf differ diff --git a/apps/remix/vite.config.ts b/apps/remix/vite.config.ts index 7a27af093..cf26eddc6 100644 --- a/apps/remix/vite.config.ts +++ b/apps/remix/vite.config.ts @@ -25,7 +25,6 @@ export default defineConfig({ }, }, ssr: { - // , 'next/font/google' doesnot work noExternal: [ 'react-dropzone', 'recharts',