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 (
+
+
+
+
+
+
+
+
+
+
{_(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 (
-
-
-
-
-
-
-
-
-
-
- 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',