diff --git a/.env.example b/.env.example
index fb22bbedf..3dc0985cb 100644
--- a/.env.example
+++ b/.env.example
@@ -6,7 +6,7 @@ NEXTAUTH_SECRET="secret"
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
-# [[APP]]
+# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js
index 2783e4063..e74f7d545 100644
--- a/apps/marketing/next.config.js
+++ b/apps/marketing/next.config.js
@@ -2,7 +2,7 @@
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
-const { parsed: env } = require('dotenv').config({
+require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
});
@@ -10,9 +10,13 @@ const { parsed: env } = require('dotenv').config({
const config = {
experimental: {
serverActions: true,
+ serverActionsBodySizeLimit: '10mb',
},
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
+ env: {
+ NEXT_PUBLIC_PROJECT: 'marketing',
+ },
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
diff --git a/apps/marketing/package.json b/apps/marketing/package.json
index 8e61ad51f..8ee8d3808 100644
--- a/apps/marketing/package.json
+++ b/apps/marketing/package.json
@@ -26,7 +26,9 @@
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
+ "posthog-js": "^1.77.3",
"react": "18.2.0",
+ "react-confetti": "^6.1.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx
index 688c484d9..36241e8e2 100644
--- a/apps/marketing/src/app/(marketing)/layout.tsx
+++ b/apps/marketing/src/app/(marketing)/layout.tsx
@@ -1,4 +1,8 @@
-import React from 'react';
+'use client';
+
+import React, { useEffect, useState } from 'react';
+
+import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header';
@@ -8,15 +12,31 @@ export type MarketingLayoutProps = {
};
export default function MarketingLayout({ children }: MarketingLayoutProps) {
+ const [scrollY, setScrollY] = useState(0);
+
+ useEffect(() => {
+ const onScroll = () => {
+ setScrollY(window.scrollY);
+ };
+
+ window.addEventListener('scroll', onScroll);
+
+ return () => window.removeEventListener('scroll', onScroll);
+ }, []);
+
return (
-
+
5,
+ })}
+ >
{children}
-
+
);
}
diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx
index 2d5bc2aa4..37d390223 100644
--- a/apps/marketing/src/app/(marketing)/open/page.tsx
+++ b/apps/marketing/src/app/(marketing)/open/page.tsx
@@ -66,7 +66,7 @@ export default async function OpenPage() {
.then((res) => ZStargazersLiveResponse.parse(res));
return (
-
+
Open Startup
diff --git a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
index 4de3de38e..38eec0938 100644
--- a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
+++ b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
@@ -37,7 +37,7 @@ export default async function OSSFriendsPage() {
diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx
index 925f2af66..5d9e623da 100644
--- a/apps/marketing/src/app/(marketing)/pricing/page.tsx
+++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx
@@ -20,7 +20,7 @@ export type PricingPageProps = {
export default function PricingPage() {
return (
-
+
Pricing
diff --git a/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx b/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx
new file mode 100644
index 000000000..6e02a470f
--- /dev/null
+++ b/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx
@@ -0,0 +1,30 @@
+import { notFound } from 'next/navigation';
+
+import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
+import { DocumentStatus } from '@documenso/prisma/client';
+
+import { SinglePlayerModeSuccess } from '~/components/(marketing)/single-player-mode/single-player-mode-success';
+
+export type SinglePlayerModeSuccessPageProps = {
+ params: {
+ token?: string;
+ };
+};
+
+export default async function SinglePlayerModeSuccessPage({
+ params: { token },
+}: SinglePlayerModeSuccessPageProps) {
+ if (!token) {
+ return notFound();
+ }
+
+ const document = await getDocumentAndRecipientByToken({
+ token,
+ }).catch(() => null);
+
+ if (!document || document.status !== DocumentStatus.COMPLETED) {
+ return notFound();
+ }
+
+ return
;
+}
diff --git a/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
new file mode 100644
index 000000000..3c76ebac0
--- /dev/null
+++ b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
+import { base64 } from '@documenso/lib/universal/base64';
+import { putFile } from '@documenso/lib/universal/upload/put-file';
+import { Field, Prisma, Recipient } from '@documenso/prisma/client';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
+import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
+import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
+import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
+import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
+import {
+ DocumentFlowFormContainer,
+ DocumentFlowFormContainerHeader,
+} from '@documenso/ui/primitives/document-flow/document-flow-root';
+import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
+import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
+
+type SinglePlayerModeStep = 'fields' | 'sign';
+
+export default function SinglePlayerModePage() {
+ const analytics = useAnalytics();
+ const router = useRouter();
+
+ const { toast } = useToast();
+
+ const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
+
+ const [step, setStep] = useState
('fields');
+ const [fields, setFields] = useState([]);
+
+ const documentFlow: Record = {
+ fields: {
+ title: 'Add document',
+ description: 'Upload a document and add fields.',
+ stepIndex: 1,
+ onBackStep: uploadedFile
+ ? () => {
+ setUploadedFile(null);
+ setFields([]);
+ }
+ : undefined,
+ onNextStep: () => setStep('sign'),
+ },
+ sign: {
+ title: 'Sign',
+ description: 'Enter your details.',
+ stepIndex: 2,
+ onBackStep: () => setStep('fields'),
+ },
+ };
+
+ const currentDocumentFlow = documentFlow[step];
+
+ useEffect(() => {
+ analytics.startSessionRecording('marketing_session_recording_spm');
+
+ return () => {
+ analytics.stopSessionRecording();
+ };
+ }, [analytics]);
+
+ /**
+ * Insert the selected fields into the local state.
+ */
+ const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
+ if (!uploadedFile) {
+ return;
+ }
+
+ setFields(
+ data.fields.map((field, i) => ({
+ id: i,
+ documentId: -1,
+ recipientId: -1,
+ type: field.type,
+ page: field.pageNumber,
+ positionX: new Prisma.Decimal(field.pageX),
+ positionY: new Prisma.Decimal(field.pageY),
+ width: new Prisma.Decimal(field.pageWidth),
+ height: new Prisma.Decimal(field.pageHeight),
+ customText: '',
+ inserted: false,
+ })),
+ );
+
+ analytics.capture('Marketing: SPM - Fields added');
+
+ documentFlow.fields.onNextStep?.();
+ };
+
+ /**
+ * Upload, create, sign and send the document.
+ */
+ const onSignSubmit = async (data: TAddSignatureFormSchema) => {
+ if (!uploadedFile) {
+ return;
+ }
+
+ try {
+ const putFileData = await putFile(uploadedFile.file);
+
+ const documentToken = await createSinglePlayerDocument({
+ documentData: {
+ type: putFileData.type,
+ data: putFileData.data,
+ },
+ documentName: uploadedFile.file.name,
+ signer: data,
+ fields: fields.map((field) => ({
+ page: field.page,
+ type: field.type,
+ positionX: field.positionX.toNumber(),
+ positionY: field.positionY.toNumber(),
+ width: field.width.toNumber(),
+ height: field.height.toNumber(),
+ })),
+ });
+
+ analytics.capture('Marketing: SPM - Document signed', {
+ signer: data.email,
+ });
+
+ router.push(`/single-player-mode/${documentToken}/success`);
+ } catch {
+ toast({
+ title: 'Something went wrong',
+ description: 'Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const placeholderRecipient: Recipient = {
+ id: -1,
+ documentId: -1,
+ email: '',
+ name: '',
+ token: '',
+ expired: null,
+ signedAt: null,
+ readStatus: 'OPENED',
+ signingStatus: 'NOT_SIGNED',
+ sendStatus: 'NOT_SENT',
+ };
+
+ const onFileDrop = async (file: File) => {
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ const base64String = base64.encode(new Uint8Array(arrayBuffer));
+
+ setUploadedFile({
+ file,
+ fileBase64: `data:application/pdf;base64,${base64String}`,
+ });
+
+ analytics.capture('Marketing: SPM - Document uploaded');
+ } catch {
+ toast({
+ title: 'Something went wrong',
+ description: 'Please try again later.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+
Single Player Mode
+
+
+ View our{' '}
+
+ community plan
+ {' '}
+ for exclusive features, including the ability to collaborate with multiple signers.
+
+
+
+
+
+ {uploadedFile ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
e.preventDefault()}>
+
+
+ {/* Add fields to PDF page. */}
+ {step === 'fields' && (
+
+ )}
+
+ {/* Enter user details and signature. */}
+ {step === 'sign' && (
+ field.type === 'NAME'))}
+ requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
+ />
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx
index 0f0c18187..f99050512 100644
--- a/apps/marketing/src/app/layout.tsx
+++ b/apps/marketing/src/app/layout.tsx
@@ -1,13 +1,20 @@
-import { Inter } from 'next/font/google';
+import { Suspense } from 'react';
+import { Caveat, Inter } from 'next/font/google';
+
+import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
+import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
+import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
+import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
+const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
@@ -33,9 +40,15 @@ export const metadata = {
},
};
-export default function RootLayout({ children }: { children: React.ReactNode }) {
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ const flags = await getAllAnonymousFlags();
+
return (
-
+
@@ -43,10 +56,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+
+
+
+
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/apps/marketing/src/app/not-found.tsx b/apps/marketing/src/app/not-found.tsx
index 9cfba7af9..0adc2e0ae 100644
--- a/apps/marketing/src/app/not-found.tsx
+++ b/apps/marketing/src/app/not-found.tsx
@@ -26,7 +26,7 @@ export default function NotFound() {
diff --git a/apps/marketing/src/assets/signing-celebration.png b/apps/marketing/src/assets/signing-celebration.png
new file mode 100644
index 000000000..a3fb5bc65
Binary files /dev/null and b/apps/marketing/src/assets/signing-celebration.png differ
diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx
index e1e8d7da8..92e871d67 100644
--- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx
+++ b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx
@@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
+import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = {
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
- const { toast } = useToast();
+ const analytics = useAnalytics();
const event = usePlausible();
+ const { toast } = useToast();
+
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
@@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
]);
event('claim-plan-pricing');
+ analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
+ analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({
title: 'Something went wrong',
diff --git a/apps/marketing/src/components/(marketing)/confetti-screen.tsx b/apps/marketing/src/components/(marketing)/confetti-screen.tsx
new file mode 100644
index 000000000..9843a0df0
--- /dev/null
+++ b/apps/marketing/src/components/(marketing)/confetti-screen.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+
+import Confetti from 'react-confetti';
+import { createPortal } from 'react-dom';
+
+import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
+import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
+
+export default function ConfettiScreen({
+ numberOfPieces: numberOfPiecesProp = 200,
+ ...props
+}: React.ComponentPropsWithoutRef & { duration?: number }) {
+ const isMounted = useIsMounted();
+ const { width, height } = useWindowSize();
+
+ const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
+
+ useEffect(() => {
+ if (!props.duration) {
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ setNumberOfPieces(0);
+ }, props.duration);
+
+ return () => clearTimeout(timer);
+ }, [props.duration]);
+
+ if (!isMounted) {
+ return null;
+ }
+
+ return createPortal(
+ ,
+ document.body,
+ );
+}
diff --git a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
index 4fd885f05..d4d3df89d 100644
--- a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
+++ b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx
@@ -22,7 +22,7 @@ export const FasterSmarterBeautifulBento = ({
diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx
index 5b929c485..853aab536 100644
--- a/apps/marketing/src/components/(marketing)/footer.tsx
+++ b/apps/marketing/src/components/(marketing)/footer.tsx
@@ -20,6 +20,7 @@ const SOCIAL_LINKS = [
const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' },
+ { href: '/single-player-mode', text: 'Single Player Mode' },
{ href: '/blog', text: 'Blog' },
{ href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
diff --git a/apps/marketing/src/components/(marketing)/header.tsx b/apps/marketing/src/components/(marketing)/header.tsx
index 6dc017f37..117f47319 100644
--- a/apps/marketing/src/components/(marketing)/header.tsx
+++ b/apps/marketing/src/components/(marketing)/header.tsx
@@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { HamburgerMenu } from './mobile-hamburger';
@@ -15,17 +16,32 @@ export type HeaderProps = HTMLAttributes;
export const Header = ({ className, ...props }: HeaderProps) => {
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
+ const { getFlag } = useFeatureFlags();
+
+ const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
+
return (
- setIsHamburgerMenuOpen(false)}>
-
-
+
+ setIsHamburgerMenuOpen(false)}>
+
+
+
+ {isSinglePlayerModeMarketingEnabled && (
+
+ Try now!
+
+ )}
+
{
const event = usePlausible();
+ const { getFlag } = useFeatureFlags();
+
+ const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
+
const onSignUpClick = () => {
const el = document.getElementById('email');
@@ -80,7 +86,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
@@ -109,7 +115,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
onClick={onSignUpClick}
>
Get the Community Plan
-
+
$30/mo. forever!
@@ -122,23 +128,45 @@ export const Hero = ({ className, ...props }: HeroProps) => {
-
-
-
-
-
+ {match(heroMarketingCTA)
+ .with('spm', () => (
+
+
+
+ Introducing Single Player Mode
+
+
+
+ Self sign for free!
+
+
+
+ ))
+ .with('productHunt', () => (
+
+
+
+
+
+ ))
+ .otherwise(() => null)}
diff --git a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx
index 15335d9a5..ea05ae7a6 100644
--- a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx
+++ b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx
@@ -23,7 +23,7 @@ export const ShareConnectPaidWidgetBento = ({
diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts
new file mode 100644
index 000000000..f2bc074ea
--- /dev/null
+++ b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts
@@ -0,0 +1,234 @@
+'use server';
+
+import { createElement } from 'react';
+
+import { DateTime } from 'luxon';
+import { PDFDocument } from 'pdf-lib';
+import { match } from 'ts-pattern';
+import { z } from 'zod';
+
+import { mailer } from '@documenso/email/mailer';
+import { render } from '@documenso/email/render';
+import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
+import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
+import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
+import { alphaid } from '@documenso/lib/universal/id';
+import { getFile } from '@documenso/lib/universal/upload/get-file';
+import { prisma } from '@documenso/prisma';
+import {
+ DocumentDataType,
+ DocumentStatus,
+ FieldType,
+ Prisma,
+ ReadStatus,
+ SendStatus,
+ SigningStatus,
+} from '@documenso/prisma/client';
+
+const ZCreateSinglePlayerDocumentSchema = z.object({
+ documentData: z.object({
+ data: z.string(),
+ type: z.nativeEnum(DocumentDataType),
+ }),
+ documentName: z.string(),
+ signer: z.object({
+ email: z.string().email().min(1),
+ name: z.string(),
+ signature: z.string(),
+ }),
+ fields: z.array(
+ z.object({
+ page: z.number(),
+ type: z.nativeEnum(FieldType),
+ positionX: z.number(),
+ positionY: z.number(),
+ width: z.number(),
+ height: z.number(),
+ }),
+ ),
+});
+
+export type TCreateSinglePlayerDocumentSchema = z.infer;
+
+/**
+ * Create and self signs a document.
+ *
+ * Returns the document token.
+ */
+export const createSinglePlayerDocument = async (
+ value: TCreateSinglePlayerDocumentSchema,
+): Promise => {
+ const { signer, fields, documentData, documentName } =
+ ZCreateSinglePlayerDocumentSchema.parse(value);
+
+ const document = await getFile({
+ data: documentData.data,
+ type: documentData.type,
+ });
+
+ const doc = await PDFDocument.load(document);
+ const createdAt = new Date();
+
+ const isBase64 = signer.signature.startsWith('data:image/png;base64,');
+ const signatureImageAsBase64 = isBase64 ? signer.signature : null;
+ const typedSignature = !isBase64 ? signer.signature : null;
+
+ // Update the document with the fields inserted.
+ for (const field of fields) {
+ const isSignatureField = field.type === FieldType.SIGNATURE;
+
+ await insertFieldInPDF(doc, {
+ ...mapField(field, signer),
+ Signature: isSignatureField
+ ? {
+ created: createdAt,
+ signatureImageAsBase64,
+ typedSignature,
+ // Dummy data.
+ id: -1,
+ recipientId: -1,
+ fieldId: -1,
+ }
+ : null,
+ // Dummy data.
+ id: -1,
+ documentId: -1,
+ recipientId: -1,
+ });
+ }
+
+ const pdfBytes = await doc.save();
+
+ const documentToken = await prisma.$transaction(
+ async (tx) => {
+ const documentToken = alphaid();
+
+ // Fetch service user who will be the owner of the document.
+ const serviceUser = await tx.user.findFirstOrThrow({
+ where: {
+ email: SERVICE_USER_EMAIL,
+ },
+ });
+
+ const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
+
+ const { id: documentDataId } = await tx.documentData.create({
+ data: {
+ type: DocumentDataType.BYTES_64,
+ data: documentDataBytes,
+ initialData: documentDataBytes,
+ },
+ });
+
+ // Create document.
+ const document = await tx.document.create({
+ data: {
+ title: documentName,
+ status: DocumentStatus.COMPLETED,
+ documentDataId,
+ userId: serviceUser.id,
+ createdAt,
+ },
+ });
+
+ // Create recipient.
+ const recipient = await tx.recipient.create({
+ data: {
+ documentId: document.id,
+ name: signer.name,
+ email: signer.email,
+ token: documentToken,
+ signedAt: createdAt,
+ readStatus: ReadStatus.OPENED,
+ signingStatus: SigningStatus.SIGNED,
+ sendStatus: SendStatus.SENT,
+ },
+ });
+
+ // Create fields and signatures.
+ await Promise.all(
+ fields.map(async (field) => {
+ const insertedField = await tx.field.create({
+ data: {
+ documentId: document.id,
+ recipientId: recipient.id,
+ ...mapField(field, signer),
+ },
+ });
+
+ if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
+ await tx.signature.create({
+ data: {
+ fieldId: insertedField.id,
+ signatureImageAsBase64,
+ typedSignature,
+ recipientId: recipient.id,
+ },
+ });
+ }
+ }),
+ );
+
+ return documentToken;
+ },
+ {
+ maxWait: 5000,
+ timeout: 30000,
+ },
+ );
+
+ // Todo: Handle `downloadLink`
+ const template = createElement(DocumentSelfSignedEmailTemplate, {
+ downloadLink: `${process.env.NEXT_PUBLIC_MARKETING_URL}/single-player-mode/${documentToken}`,
+ documentName: documentName,
+ assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
+ });
+
+ // Send email to signer.
+ await mailer.sendMail({
+ to: {
+ address: signer.email,
+ name: signer.name,
+ },
+ from: {
+ name: FROM_NAME,
+ address: FROM_ADDRESS,
+ },
+ subject: 'Document signed',
+ html: render(template),
+ text: render(template, { plainText: true }),
+ });
+
+ return documentToken;
+};
+
+/**
+ * Map the fields provided by the user to fields compatible with Prisma.
+ *
+ * Signature fields are handled separately.
+ *
+ * @param field The field passed in by the user.
+ * @param signer The details of the person who is signing this document.
+ * @returns A field compatible with Prisma.
+ */
+const mapField = (
+ field: TCreateSinglePlayerDocumentSchema['fields'][number],
+ signer: TCreateSinglePlayerDocumentSchema['signer'],
+) => {
+ const customText = match(field.type)
+ .with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
+ .with(FieldType.EMAIL, () => signer.email)
+ .with(FieldType.NAME, () => signer.name)
+ .otherwise(() => '');
+
+ return {
+ type: field.type,
+ page: field.page,
+ positionX: new Prisma.Decimal(field.positionX),
+ positionY: new Prisma.Decimal(field.positionY),
+ width: new Prisma.Decimal(field.width),
+ height: new Prisma.Decimal(field.height),
+ customText,
+ inserted: true,
+ };
+};
diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx
new file mode 100644
index 000000000..70bf58926
--- /dev/null
+++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import { Share } from 'lucide-react';
+
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
+import { base64 } from '@documenso/lib/universal/base64';
+import { getFile } from '@documenso/lib/universal/upload/get-file';
+import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
+import DocumentDialog from '@documenso/ui/components/document/document-dialog';
+import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
+import { SigningCard3D } from '@documenso/ui/components/signing-card';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import signingCelebration from '~/assets/signing-celebration.png';
+import ConfettiScreen from '~/components/(marketing)/confetti-screen';
+
+import { DocumentStatus } from '.prisma/client';
+
+interface SinglePlayerModeSuccessProps {
+ className?: string;
+ document: DocumentWithRecipient;
+}
+
+export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerModeSuccessProps) => {
+ const { getFlag } = useFeatureFlags();
+
+ const isConfettiEnabled = getFlag('marketing_spm_confetti');
+
+ const [showDocumentDialog, setShowDocumentDialog] = useState(false);
+ const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
+ const [documentFile, setDocumentFile] = useState(null);
+
+ const { toast } = useToast();
+
+ const onShowDocumentClick = async () => {
+ if (isFetchingDocumentFile) {
+ return;
+ }
+
+ setIsFetchingDocumentFile(true);
+
+ try {
+ const data = await getFile(document.documentData);
+
+ setDocumentFile(base64.encode(data));
+
+ setShowDocumentDialog(true);
+ } catch {
+ toast({
+ title: 'Something went wrong.',
+ description: 'We were unable to retrieve the document at this time. Please try again.',
+ variant: 'destructive',
+ duration: 7500,
+ });
+ }
+
+ setIsFetchingDocumentFile(false);
+ };
+
+ useEffect(() => {
+ window.scrollTo({ top: 0 });
+ }, []);
+
+ return (
+
+ {isConfettiEnabled && (
+
+ )}
+
+
+ You have signed
+ {document.title}
+
+
+
+
+
+
+
+ {/* TODO: Hook this up */}
+
+
+
+
+
+
+
+
+
+
+ Create a{' '}
+
+ free account
+ {' '}
+ to access your signed documents at any time
+
+
+
+
+ );
+};
diff --git a/apps/marketing/src/pages/api/feature-flag/all.ts b/apps/marketing/src/pages/api/feature-flag/all.ts
new file mode 100644
index 000000000..f4d0aa3e9
--- /dev/null
+++ b/apps/marketing/src/pages/api/feature-flag/all.ts
@@ -0,0 +1,7 @@
+import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
+
+export const config = {
+ runtime: 'edge',
+};
+
+export default handlerFeatureFlagAll;
diff --git a/apps/marketing/src/pages/api/feature-flag/get.ts b/apps/marketing/src/pages/api/feature-flag/get.ts
new file mode 100644
index 000000000..938dfbbcd
--- /dev/null
+++ b/apps/marketing/src/pages/api/feature-flag/get.ts
@@ -0,0 +1,7 @@
+import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
+
+export const config = {
+ runtime: 'edge',
+};
+
+export default handlerFeatureFlagGet;
diff --git a/apps/marketing/src/providers/posthog.tsx b/apps/marketing/src/providers/posthog.tsx
new file mode 100644
index 000000000..a4019bfb5
--- /dev/null
+++ b/apps/marketing/src/providers/posthog.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { useEffect } from 'react';
+
+import { usePathname, useSearchParams } from 'next/navigation';
+
+import posthog from 'posthog-js';
+
+import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
+
+export function PostHogPageview() {
+ const postHogConfig = extractPostHogConfig();
+
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ if (typeof window !== 'undefined' && postHogConfig) {
+ posthog.init(postHogConfig.key, {
+ api_host: postHogConfig.host,
+ disable_session_recording: true,
+ });
+ }
+
+ useEffect(() => {
+ if (!postHogConfig || !pathname) {
+ return;
+ }
+
+ let url = window.origin + pathname;
+ if (searchParams && searchParams.toString()) {
+ url = url + `?${searchParams.toString()}`;
+ }
+ posthog.capture('$pageview', {
+ $current_url: url,
+ });
+ }, [pathname, searchParams, postHogConfig]);
+
+ return null;
+}
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index be51b51fc..c4359060b 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -2,7 +2,7 @@
const path = require('path');
const { version } = require('./package.json');
-const { parsed: env } = require('dotenv').config({
+require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
});
@@ -22,6 +22,7 @@ const config = {
],
env: {
APP_VERSION: version,
+ NEXT_PUBLIC_PROJECT: 'web',
},
modularizeImports: {
'lucide-react': {
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index b07e5f848..7c30dc411 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -55,21 +55,18 @@ export const EditDocumentForm = ({
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 1,
- onSubmit: () => onAddSignersFormSubmit,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
- onSubmit: () => onAddFieldsFormSubmit,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
- onSubmit: () => onAddSubjectFormSubmit,
},
};
diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
index 7e6694491..28c8b8122 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
@@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
+import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date';
-import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() {
const user = await getRequiredServerComponentSession();
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
index af9b2ab06..a89f1bb3f 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
@@ -8,10 +8,12 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
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 { DocumentStatus, FieldType } from '@documenso/prisma/client';
+import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
+import { SigningCard } from '@documenso/ui/components/signing-card';
+
+import signingCelebration from '~/assets/signing-celebration.png';
-import { DownloadButton } from './download-button';
import { ShareButton } from './share-button';
-import { SigningCard } from './signing-card';
export type CompletedSigningPageProps = {
params: {
@@ -53,7 +55,7 @@ export default async function CompletedSigningPage({
return (
{/* Card with recipient */}
-
+
{match(document.status)
@@ -90,7 +92,7 @@ export default async function CompletedSigningPage({
-
- Want so send slick signing links like this one?{' '}
+ Want to send slick signing links like this one?{' '}
Check out Documenso.
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx
deleted file mode 100644
index 791c61231..000000000
--- a/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-'use client';
-
-import Image from 'next/image';
-
-import { motion } from 'framer-motion';
-
-import { Card, CardContent } from '@documenso/ui/primitives/card';
-
-import signingCelebration from '~/assets/signing-celebration.png';
-
-export type SigningCardProps = {
- name: string;
-};
-
-export const SigningCard = ({ name }: SigningCardProps) => {
- return (
-
-
-
-
- {name}
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx
index 8e2201df9..9cff29c64 100644
--- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx
@@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
return (
{isLoading && (
-
+
)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx
index 05c1cb31c..f6f790799 100644
--- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx
@@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
return (
{isLoading && (
-
+
)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx
index 26568ddcc..74384fd89 100644
--- a/apps/web/src/app/(signing)/sign/[token]/form.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx
@@ -1,12 +1,15 @@
'use client';
+import { useMemo, useState } from 'react';
+
import { useRouter } from 'next/navigation';
-import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
+import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Document, Field, Recipient } from '@documenso/prisma/client';
+import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
+ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
+
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
- const isComplete = fields.every((f) => f.inserted);
+ const uninsertedFields = useMemo(() => {
+ return sortFieldsByPosition(fields.filter((field) => !field.inserted));
+ }, [fields]);
const onFormSubmit = async () => {
- if (!isComplete) {
+ setValidateUninsertedFields(true);
+ const isFieldsValid = validateFieldsInserted(fields);
+
+ if (!isFieldsValid) {
return;
}
@@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
)}
onSubmit={handleSubmit(onFormSubmit)}
>
-
+ {validateUninsertedFields && uninsertedFields[0] && (
+
+ Click to insert field
+
+ )}
+
+
-
+
);
};
diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
index 9688619fa..275a6ede8 100644
--- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx
@@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
return (
{isLoading && (
-
+
)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
index f410dcccc..020af41c2 100644
--- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
@@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return (
{isLoading && (
-
+
)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
index d5efcb3df..046e5b3df 100644
--- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx
@@ -3,10 +3,7 @@
import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
-import { cn } from '@documenso/ui/lib/utils';
-import { Card, CardContent } from '@documenso/ui/primitives/card';
-
-import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
+import { FieldRootContainer } from '@documenso/ui/components/field/field';
export type SignatureFieldProps = {
field: FieldWithSignature;
@@ -23,8 +20,6 @@ export const SigningFieldContainer = ({
onRemove,
children,
}: SignatureFieldProps) => {
- const coords = useFieldPageCoords(field);
-
const onSignFieldClick = async () => {
if (field.inserted) {
return;
@@ -42,40 +37,25 @@ export const SigningFieldContainer = ({
};
return (
-
-
-
+ {!field.inserted && !loading && (
+
+ )}
+
+ {field.inserted && !loading && (
+
- {!field.inserted && !loading && (
-
- )}
+ Remove
+
+ )}
- {field.inserted && !loading && (
-
- Remove
-
- )}
-
- {children}
-
-
-
+ {children}
+
);
};
diff --git a/apps/web/src/app/(unauthenticated)/layout.tsx b/apps/web/src/app/(unauthenticated)/layout.tsx
index c88b9fb2e..c040e3f4c 100644
--- a/apps/web/src/app/(unauthenticated)/layout.tsx
+++ b/apps/web/src/app/(unauthenticated)/layout.tsx
@@ -16,7 +16,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 3936783ab..a81437aee 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -2,15 +2,15 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
+import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
+import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
-import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
-import { FeatureFlagProvider } from '~/providers/feature-flag';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
index 91b045feb..f43e3507a 100644
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
@@ -17,6 +17,7 @@ import {
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client';
@@ -37,8 +38,6 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
-import { useFeatureFlags } from '~/providers/feature-flag';
-
export type ProfileDropdownProps = {
user: User;
};
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index 5a85680c8..901c6a5ae 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
@@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { useFeatureFlags } from '~/providers/feature-flag';
-
export type DesktopNavProps = HTMLAttributes;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
index 19bbefdc9..ffe2b0d80 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
@@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { useFeatureFlags } from '~/providers/feature-flag';
-
export type MobileNavProps = HTMLAttributes;
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
diff --git a/apps/web/src/components/partials/not-found.tsx b/apps/web/src/components/partials/not-found.tsx
index 0b5c2ad18..679a825ba 100644
--- a/apps/web/src/components/partials/not-found.tsx
+++ b/apps/web/src/components/partials/not-found.tsx
@@ -29,7 +29,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
diff --git a/apps/web/src/pages/api/feature-flag/all.ts b/apps/web/src/pages/api/feature-flag/all.ts
index 54efbd7fc..f4d0aa3e9 100644
--- a/apps/web/src/pages/api/feature-flag/all.ts
+++ b/apps/web/src/pages/api/feature-flag/all.ts
@@ -1,44 +1,7 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-import { getToken } from 'next-auth/jwt';
-
-import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
-
-import PostHogServerClient from '~/helpers/get-post-hog-server-client';
-
-import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
+import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
export const config = {
runtime: 'edge',
};
-/**
- * Get all the evaluated feature flags based on the current user if possible.
- */
-export default async function handler(req: Request) {
- const requestHeaders = Object.fromEntries(req.headers.entries());
-
- const nextReq = new NextRequest(req, {
- headers: requestHeaders,
- });
-
- const token = await getToken({ req: nextReq });
-
- const postHog = PostHogServerClient();
-
- // Return the local feature flags if PostHog is not enabled, true by default.
- // The front end should not call this API if PostHog is not enabled to reduce network requests.
- if (!postHog) {
- return NextResponse.json(LOCAL_FEATURE_FLAGS);
- }
-
- const distinctId = extractDistinctUserId(token, nextReq);
-
- const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
-
- const res = NextResponse.json(featureFlags);
-
- res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
-
- return res;
-}
+export default handlerFeatureFlagAll;
diff --git a/apps/web/src/pages/api/feature-flag/get.ts b/apps/web/src/pages/api/feature-flag/get.ts
index 6e45b5a18..938dfbbcd 100644
--- a/apps/web/src/pages/api/feature-flag/get.ts
+++ b/apps/web/src/pages/api/feature-flag/get.ts
@@ -1,122 +1,7 @@
-import { NextRequest, NextResponse } from 'next/server';
-
-import { JWT, getToken } from 'next-auth/jwt';
-
-import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
-import { nanoid } from '@documenso/lib/universal/id';
-
-import PostHogServerClient from '~/helpers/get-post-hog-server-client';
+import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
export const config = {
runtime: 'edge',
};
-/**
- * Evaluate a single feature flag based on the current user if possible.
- *
- * @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
- * @returns A Response with the feature flag value.
- */
-export default async function handler(req: Request) {
- const { searchParams } = new URL(req.url ?? '');
- const flag = searchParams.get('flag');
-
- const requestHeaders = Object.fromEntries(req.headers.entries());
-
- const nextReq = new NextRequest(req, {
- headers: requestHeaders,
- });
-
- const token = await getToken({ req: nextReq });
-
- if (!flag) {
- return NextResponse.json(
- {
- error: 'Missing flag query parameter.',
- },
- {
- status: 400,
- headers: {
- 'content-type': 'application/json',
- },
- },
- );
- }
-
- const postHog = PostHogServerClient();
-
- // Return the local feature flags if PostHog is not enabled, true by default.
- // The front end should not call this API if PostHog is disabled to reduce network requests.
- if (!postHog) {
- return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
- }
-
- const distinctId = extractDistinctUserId(token, nextReq);
-
- const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
-
- const res = NextResponse.json(featureFlag);
-
- res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
-
- return res;
-}
-
-/**
- * Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
- *
- * @param jwt The JWT of the current user.
- * @returns A map of properties which are consumed by PostHog.
- */
-export const mapJwtToFlagProperties = (
- jwt?: JWT | null,
-): {
- groups?: Record;
- personProperties?: Record;
- groupProperties?: Record>;
-} => {
- return {
- personProperties: {
- email: jwt?.email ?? '',
- },
- groupProperties: {
- // Add properties to group users into different groups, such as billing plan.
- },
- };
-};
-
-/**
- * Extract a distinct ID from a JWT and request.
- *
- * Will fallback to a random ID if no ID could be extracted from either the JWT or request.
- *
- * @param jwt The JWT of the current user.
- * @param request Request potentially containing a PostHog `distinct_id` cookie.
- * @returns A distinct user ID.
- */
-export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
- const config = extractPostHogConfig();
-
- const email = jwt?.email;
- const userId = jwt?.id.toString();
-
- let fallbackDistinctId = nanoid();
-
- if (config) {
- try {
- const postHogCookie = JSON.parse(
- request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
- );
-
- const postHogDistinctId = postHogCookie['distinct_id'];
-
- if (typeof postHogDistinctId === 'string') {
- fallbackDistinctId = postHogDistinctId;
- }
- } catch {
- // Do nothing.
- }
- }
-
- return email ?? userId ?? fallbackDistinctId;
-};
+export default handlerFeatureFlagGet;
diff --git a/package-lock.json b/package-lock.json
index 254855629..cbd767514 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,7 +49,9 @@
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
+ "posthog-js": "^1.77.3",
"react": "18.2.0",
+ "react-confetti": "^6.1.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.9",
"react-icons": "^4.8.0",
@@ -2445,9 +2447,9 @@
}
},
"node_modules/@hookform/resolvers": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz",
- "integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
+ "integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
@@ -6387,9 +6389,9 @@
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
},
"node_modules/@types/luxon": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
- "integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.2.tgz",
+ "integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==",
"dev": true
},
"node_modules/@types/mdast": {
@@ -12868,9 +12870,9 @@
}
},
"node_modules/luxon": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz",
- "integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.2.tgz",
+ "integrity": "sha512-uBoAVCVcajsrqy3pv7eo5jEUz1oeLmCcnMv8n4AJpT5hbpN9lUssAXibNElpbLce3Mhm9dyBzwYLs9zctM/0tA==",
"engines": {
"node": ">=12"
}
@@ -15239,9 +15241,9 @@
}
},
"node_modules/posthog-js": {
- "version": "1.75.3",
- "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.75.3.tgz",
- "integrity": "sha512-q5xP4R/Tx8E6H0goZQjY+URMLATFiYXc2raHA+31aNvpBs118fPTmExa4RK6MgRZDFhBiMUBZNT6aj7dM3SyUQ==",
+ "version": "1.77.3",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.77.3.tgz",
+ "integrity": "sha512-DKsGpBIUjQSihhGruEW8wpVCkeDxU4jz7gADdXX2jEWV6bl4WpUPxjo1ukidVDFvvc/ihCM5PQWMQrItexdpSA==",
"dependencies": {
"fflate": "^0.4.1"
}
@@ -15755,6 +15757,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-confetti": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
+ "integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==",
+ "dependencies": {
+ "tween-functions": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=10.18"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0 || ^17.0.1 || ^18.0.0"
+ }
+ },
"node_modules/react-day-picker": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz",
@@ -16285,9 +16301,9 @@
}
},
"node_modules/react-hook-form": {
- "version": "7.45.2",
- "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz",
- "integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==",
+ "version": "7.45.4",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
+ "integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==",
"engines": {
"node": ">=12.22.0"
},
@@ -18919,6 +18935,11 @@
"node": ">= 6"
}
},
+ "node_modules/tween-functions": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz",
+ "integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA=="
+ },
"node_modules/typanion": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz",
@@ -19847,6 +19868,7 @@
"license": "MIT",
"dependencies": {
"@documenso/lib": "*",
+ "@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
@@ -19867,27 +19889,32 @@
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
+ "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2",
- "@radix-ui/react-tooltip": "^1.0.5",
+ "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
+ "luxon": "^3.4.2",
"next": "13.4.19",
"pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1",
+ "react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3",
+ "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
+ "@types/luxon": "^3.3.2",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"react": "18.2.0",
diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx
index 91d8fa29d..a36f79bc4 100644
--- a/packages/email/template-components/template-document-completed.tsx
+++ b/packages/email/template-components/template-document-completed.tsx
@@ -4,14 +4,12 @@ import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentCompletedProps {
downloadLink: string;
- reviewLink: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentCompleted = ({
downloadLink,
- reviewLink,
documentName,
assetBaseUrl,
}: TemplateDocumentCompletedProps) => {
@@ -56,17 +54,17 @@ export const TemplateDocumentCompleted = ({
- Continue by downloading or reviewing the document.
+ Continue by downloading the document.
-
Review
-
+ */}
{
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TemplateDocumentSelfSigned;
diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx
index 9152d5822..adaa5d0ed 100644
--- a/packages/email/templates/document-completed.tsx
+++ b/packages/email/templates/document-completed.tsx
@@ -21,7 +21,6 @@ export type DocumentCompletedEmailTemplateProps = Partial {
@@ -56,7 +55,6 @@ export const DocumentCompletedEmailTemplate = ({
diff --git a/packages/email/templates/document-self-signed.tsx b/packages/email/templates/document-self-signed.tsx
new file mode 100644
index 000000000..3a16f707e
--- /dev/null
+++ b/packages/email/templates/document-self-signed.tsx
@@ -0,0 +1,74 @@
+import {
+ Body,
+ Container,
+ Head,
+ Html,
+ Img,
+ Preview,
+ Section,
+ Tailwind,
+} from '@react-email/components';
+
+import config from '@documenso/tailwind-config';
+
+import {
+ TemplateDocumentSelfSigned,
+ TemplateDocumentSelfSignedProps,
+} from '../template-components/template-document-self-signed';
+import TemplateFooter from '../template-components/template-footer';
+
+export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
+
+export const DocumentSelfSignedEmailTemplate = ({
+ downloadLink = 'https://documenso.com',
+ documentName = 'Open Source Pledge.pdf',
+ assetBaseUrl = 'http://localhost:3002',
+}: DocumentSelfSignedTemplateProps) => {
+ const previewText = `Completed Document`;
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+ {previewText}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DocumentSelfSignedEmailTemplate;
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
index cfa86f06d..7ed40e271 100644
--- a/packages/eslint-config/package.json
+++ b/packages/eslint-config/package.json
@@ -18,4 +18,4 @@
"eslint-plugin-react": "^7.32.2",
"typescript": "^5.1.6"
}
-}
+}
\ No newline at end of file
diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts
new file mode 100644
index 000000000..a659a6d70
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-analytics.ts
@@ -0,0 +1,61 @@
+import { posthog } from 'posthog-js';
+
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
+import {
+ FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
+ extractPostHogConfig,
+} from '@documenso/lib/constants/feature-flags';
+
+export function useAnalytics() {
+ const featureFlags = useFeatureFlags();
+ const isPostHogEnabled = extractPostHogConfig();
+
+ /**
+ * Capture an analytic event.
+ *
+ * @param event The event name.
+ * @param properties Properties to attach to the event.
+ */
+ const capture = (event: string, properties?: Record) => {
+ if (!isPostHogEnabled) {
+ return;
+ }
+
+ posthog.capture(event, properties);
+ };
+
+ /**
+ * Start the session recording.
+ *
+ * @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
+ */
+ const startSessionRecording = (eventFlag?: string) => {
+ const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
+ const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
+
+ if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
+ return;
+ }
+
+ posthog.startSessionRecording();
+ };
+
+ /**
+ * Stop the current session recording.
+ */
+ const stopSessionRecording = () => {
+ const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
+
+ if (!isPostHogEnabled || !isSessionRecordingEnabled) {
+ return;
+ }
+
+ posthog.stopSessionRecording();
+ };
+
+ return {
+ capture,
+ startSessionRecording,
+ stopSessionRecording,
+ };
+}
diff --git a/apps/web/src/hooks/use-debounced-value.ts b/packages/lib/client-only/hooks/use-debounced-value.ts
similarity index 100%
rename from apps/web/src/hooks/use-debounced-value.ts
rename to packages/lib/client-only/hooks/use-debounced-value.ts
diff --git a/packages/lib/client-only/hooks/use-element-scale-size.ts b/packages/lib/client-only/hooks/use-element-scale-size.ts
new file mode 100644
index 000000000..3e9b34b3f
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-element-scale-size.ts
@@ -0,0 +1,85 @@
+/* eslint-disable @typescript-eslint/consistent-type-assertions */
+import { RefObject, useEffect, useState } from 'react';
+
+/**
+ * Calculate the width and height of a text element.
+ *
+ * @param text The text to calculate the width and height of.
+ * @param fontSize The font size to apply to the text.
+ * @param fontFamily The font family to apply to the text.
+ * @returns Returns the width and height of the text.
+ */
+function calculateTextDimensions(
+ text: string,
+ fontSize: string,
+ fontFamily: string,
+): { width: number; height: number } {
+ // Reuse old canvas if available.
+ let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas;
+
+ if (!canvas) {
+ canvas = document.createElement('canvas');
+ (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas;
+ }
+
+ const context = canvas.getContext('2d');
+ if (!context) {
+ return { width: 0, height: 0 };
+ }
+
+ context.font = `${fontSize} ${fontFamily}`;
+ const metrics = context.measureText(text);
+
+ return {
+ width: metrics.width,
+ height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
+ };
+}
+
+/**
+ * Calculate the scaling size to apply to a text to fit it within a container.
+ *
+ * @param container The container dimensions to fit the text within.
+ * @param text The text to fit within the container.
+ * @param fontSize The font size to apply to the text.
+ * @param fontFamily The font family to apply to the text.
+ * @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text.
+ */
+export const calculateTextScaleSize = (
+ container: { width: number; height: number },
+ text: string,
+ fontSize: string,
+ fontFamily: string,
+) => {
+ const { width, height } = calculateTextDimensions(text, fontSize, fontFamily);
+ return Math.min(container.width / width, container.height / height, 1);
+};
+
+/**
+ * Given a container and child element, calculate the scaling size to apply to the child.
+ */
+export function useElementScaleSize(
+ container: { width: number; height: number },
+ child: RefObject,
+ fontSize: number,
+ fontFamily: string,
+) {
+ const [scalingFactor, setScalingFactor] = useState(1);
+
+ useEffect(() => {
+ if (!child.current) {
+ return;
+ }
+
+ const scaleSize = calculateTextScaleSize(
+ container,
+ child.current.innerText,
+ `${fontSize}px`,
+ fontFamily,
+ );
+
+ setScalingFactor(scaleSize);
+ }, [child, container, fontFamily, fontSize]);
+
+ return scalingFactor;
+}
diff --git a/apps/web/src/hooks/use-field-page-coords.ts b/packages/lib/client-only/hooks/use-field-page-coords.ts
similarity index 100%
rename from apps/web/src/hooks/use-field-page-coords.ts
rename to packages/lib/client-only/hooks/use-field-page-coords.ts
diff --git a/packages/lib/client-only/hooks/use-is-mounted.ts b/packages/lib/client-only/hooks/use-is-mounted.ts
new file mode 100644
index 000000000..7b3aafa40
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-is-mounted.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from 'react';
+
+export const useIsMounted = () => {
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ return isMounted;
+};
diff --git a/apps/marketing/src/hooks/use-window-size.ts b/packages/lib/client-only/hooks/use-window-size.ts
similarity index 100%
rename from apps/marketing/src/hooks/use-window-size.ts
rename to packages/lib/client-only/hooks/use-window-size.ts
diff --git a/apps/web/src/providers/feature-flag.tsx b/packages/lib/client-only/providers/feature-flag.tsx
similarity index 96%
rename from apps/web/src/providers/feature-flag.tsx
rename to packages/lib/client-only/providers/feature-flag.tsx
index 0a09fe0f0..e732bebbd 100644
--- a/apps/web/src/providers/feature-flag.tsx
+++ b/packages/lib/client-only/providers/feature-flag.tsx
@@ -7,8 +7,7 @@ import {
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
-
-import { getAllFlags } from '~/helpers/get-feature-flag';
+import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
diff --git a/apps/web/src/providers/feature-flag.types.ts b/packages/lib/client-only/providers/feature-flag.types.ts
similarity index 100%
rename from apps/web/src/providers/feature-flag.types.ts
rename to packages/lib/client-only/providers/feature-flag.types.ts
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
new file mode 100644
index 000000000..827fcef0a
--- /dev/null
+++ b/packages/lib/constants/app.ts
@@ -0,0 +1,8 @@
+export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
+export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
+
+export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
+
+export const APP_BASE_URL = IS_APP_WEB
+ ? process.env.NEXT_PUBLIC_WEBAPP_URL
+ : process.env.NEXT_PUBLIC_MARKETING_URL;
diff --git a/packages/lib/constants/email.ts b/packages/lib/constants/email.ts
new file mode 100644
index 000000000..f9c7ba4f5
--- /dev/null
+++ b/packages/lib/constants/email.ts
@@ -0,0 +1,4 @@
+export const FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
+export const FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
+
+export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts
index d0004e83b..e23f59eba 100644
--- a/packages/lib/constants/feature-flags.ts
+++ b/packages/lib/constants/feature-flags.ts
@@ -1,3 +1,8 @@
+/**
+ * The flag name for global session recording feature flag.
+ */
+export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
+
/**
* How frequent to poll for new feature flags in milliseconds.
*/
@@ -10,6 +15,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
*/
export const LOCAL_FEATURE_FLAGS: Record = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
+ marketing_header_single_player_mode: false,
} as const;
/**
diff --git a/packages/lib/constants/pdf.ts b/packages/lib/constants/pdf.ts
new file mode 100644
index 000000000..eba72ab56
--- /dev/null
+++ b/packages/lib/constants/pdf.ts
@@ -0,0 +1,9 @@
+import { APP_BASE_URL } from './app';
+
+export const DEFAULT_STANDARD_FONT_SIZE = 15;
+export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
+
+export const MIN_STANDARD_FONT_SIZE = 8;
+export const MIN_HANDWRITING_FONT_SIZE = 20;
+
+export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
diff --git a/packages/lib/package.json b/packages/lib/package.json
index e8c6bcee3..5376acf13 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -38,4 +38,4 @@
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^3.3.1"
}
-}
+}
\ No newline at end of file
diff --git a/packages/lib/server-only/auth/send-reset-password.ts b/packages/lib/server-only/auth/send-reset-password.ts
index 303ceb821..9479f1a45 100644
--- a/packages/lib/server-only/auth/send-reset-password.ts
+++ b/packages/lib/server-only/auth/send-reset-password.ts
@@ -18,8 +18,6 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
- console.log({ assetBaseUrl });
-
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,
userEmail: user.email,
diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts
index 62b3ddd48..89b3777ea 100644
--- a/packages/lib/server-only/document/get-document-by-token.ts
+++ b/packages/lib/server-only/document/get-document-by-token.ts
@@ -1,12 +1,21 @@
import { prisma } from '@documenso/prisma';
+import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
}
+export interface GetDocumentAndRecipientByTokenOptions {
+ token: string;
+}
+
export const getDocumentAndSenderByToken = async ({
token,
}: GetDocumentAndSenderByTokenOptions) => {
+ if (!token) {
+ throw new Error('Missing token');
+ }
+
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
@@ -29,3 +38,33 @@ export const getDocumentAndSenderByToken = async ({
User,
};
};
+
+/**
+ * Get a Document and a Recipient by the recipient token.
+ */
+export const getDocumentAndRecipientByToken = async ({
+ token,
+}: GetDocumentAndRecipientByTokenOptions): Promise => {
+ if (!token) {
+ throw new Error('Missing token');
+ }
+
+ const result = await prisma.document.findFirstOrThrow({
+ where: {
+ Recipient: {
+ some: {
+ token,
+ },
+ },
+ },
+ include: {
+ Recipient: true,
+ documentData: true,
+ },
+ });
+
+ return {
+ ...result,
+ Recipient: result.Recipient[0],
+ };
+};
diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx
index fcc0f829c..febe619f0 100644
--- a/packages/lib/server-only/document/send-document.tsx
+++ b/packages/lib/server-only/document/send-document.tsx
@@ -3,6 +3,7 @@ import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
+import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
@@ -76,8 +77,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
name,
},
from: {
- name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
- address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
+ name: FROM_NAME,
+ address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
diff --git a/packages/lib/server-only/feature-flags/all.ts b/packages/lib/server-only/feature-flags/all.ts
new file mode 100644
index 000000000..40e759221
--- /dev/null
+++ b/packages/lib/server-only/feature-flags/all.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getToken } from 'next-auth/jwt';
+
+import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
+import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
+
+import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
+
+/**
+ * Get all the evaluated feature flags based on the current user if possible.
+ */
+export default async function handlerFeatureFlagAll(req: Request) {
+ const requestHeaders = Object.fromEntries(req.headers.entries());
+
+ const nextReq = new NextRequest(req, {
+ headers: requestHeaders,
+ });
+
+ const token = await getToken({ req: nextReq });
+
+ const postHog = PostHogServerClient();
+
+ // Return the local feature flags if PostHog is not enabled, true by default.
+ // The front end should not call this API if PostHog is not enabled to reduce network requests.
+ if (!postHog) {
+ return NextResponse.json(LOCAL_FEATURE_FLAGS);
+ }
+
+ const distinctId = extractDistinctUserId(token, nextReq);
+
+ const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
+
+ const res = NextResponse.json(featureFlags);
+
+ res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
+
+ const origin = req.headers.get('origin');
+
+ if (origin) {
+ if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
+ res.headers.set('Access-Control-Allow-Origin', origin);
+ }
+
+ if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
+ res.headers.set('Access-Control-Allow-Origin', origin);
+ }
+ }
+
+ return res;
+}
diff --git a/apps/web/src/helpers/get-post-hog-server-client.ts b/packages/lib/server-only/feature-flags/get-post-hog-server-client.ts
similarity index 100%
rename from apps/web/src/helpers/get-post-hog-server-client.ts
rename to packages/lib/server-only/feature-flags/get-post-hog-server-client.ts
diff --git a/apps/web/src/helpers/get-server-component-feature-flag.ts b/packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts
similarity index 89%
rename from apps/web/src/helpers/get-server-component-feature-flag.ts
rename to packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts
index ebfaf4bd6..9cdddd7ae 100644
--- a/apps/web/src/helpers/get-server-component-feature-flag.ts
+++ b/packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts
@@ -1,6 +1,6 @@
import { headers } from 'next/headers';
-import { getAllFlags, getFlag } from './get-feature-flag';
+import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
diff --git a/packages/lib/server-only/feature-flags/get.ts b/packages/lib/server-only/feature-flags/get.ts
new file mode 100644
index 000000000..3157afb60
--- /dev/null
+++ b/packages/lib/server-only/feature-flags/get.ts
@@ -0,0 +1,129 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+import { nanoid } from 'nanoid';
+import { JWT, getToken } from 'next-auth/jwt';
+
+import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
+import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
+
+/**
+ * Evaluate a single feature flag based on the current user if possible.
+ *
+ * @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
+ * @returns A Response with the feature flag value.
+ */
+export default async function handleFeatureFlagGet(req: Request) {
+ const { searchParams } = new URL(req.url ?? '');
+ const flag = searchParams.get('flag');
+
+ const requestHeaders = Object.fromEntries(req.headers.entries());
+
+ const nextReq = new NextRequest(req, {
+ headers: requestHeaders,
+ });
+
+ const token = await getToken({ req: nextReq });
+
+ if (!flag) {
+ return NextResponse.json(
+ {
+ error: 'Missing flag query parameter.',
+ },
+ {
+ status: 400,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ },
+ );
+ }
+
+ const postHog = PostHogServerClient();
+
+ // Return the local feature flags if PostHog is not enabled, true by default.
+ // The front end should not call this API if PostHog is disabled to reduce network requests.
+ if (!postHog) {
+ return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
+ }
+
+ const distinctId = extractDistinctUserId(token, nextReq);
+
+ const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
+
+ const res = NextResponse.json(featureFlag);
+
+ res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
+
+ const origin = req.headers.get('Origin');
+
+ if (origin) {
+ if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
+ res.headers.set('Access-Control-Allow-Origin', origin);
+ }
+
+ if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
+ res.headers.set('Access-Control-Allow-Origin', origin);
+ }
+ }
+
+ return res;
+}
+
+/**
+ * Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
+ *
+ * @param jwt The JWT of the current user.
+ * @returns A map of properties which are consumed by PostHog.
+ */
+export const mapJwtToFlagProperties = (
+ jwt?: JWT | null,
+): {
+ groups?: Record;
+ personProperties?: Record;
+ groupProperties?: Record>;
+} => {
+ return {
+ personProperties: {
+ email: jwt?.email ?? '',
+ },
+ groupProperties: {
+ // Add properties to group users into different groups, such as billing plan.
+ },
+ };
+};
+
+/**
+ * Extract a distinct ID from a JWT and request.
+ *
+ * Will fallback to a random ID if no ID could be extracted from either the JWT or request.
+ *
+ * @param jwt The JWT of the current user.
+ * @param request Request potentially containing a PostHog `distinct_id` cookie.
+ * @returns A distinct user ID.
+ */
+export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
+ const config = extractPostHogConfig();
+
+ const email = jwt?.email;
+ const userId = jwt?.id.toString();
+
+ let fallbackDistinctId = nanoid();
+
+ if (config) {
+ try {
+ const postHogCookie = JSON.parse(
+ request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
+ );
+
+ const postHogDistinctId = postHogCookie['distinct_id'];
+
+ if (typeof postHogDistinctId === 'string') {
+ fallbackDistinctId = postHogDistinctId;
+ }
+ } catch {
+ // Do nothing.
+ }
+ }
+
+ return email ?? userId ?? fallbackDistinctId;
+};
diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts
index e7b1e7c5a..9da0e0bf1 100644
--- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts
+++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts
@@ -1,25 +1,31 @@
import fontkit from '@pdf-lib/fontkit';
-import { readFileSync } from 'fs';
import { PDFDocument, StandardFonts } from 'pdf-lib';
+import {
+ CAVEAT_FONT_PATH,
+ DEFAULT_HANDWRITING_FONT_SIZE,
+ DEFAULT_STANDARD_FONT_SIZE,
+ MIN_HANDWRITING_FONT_SIZE,
+ MIN_STANDARD_FONT_SIZE,
+} from '@documenso/lib/constants/pdf';
import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
-const DEFAULT_STANDARD_FONT_SIZE = 15;
-const DEFAULT_HANDWRITING_FONT_SIZE = 50;
-
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
+ // Fetch the font file from the public URL.
+ const fontResponse = await fetch(CAVEAT_FONT_PATH);
+ const fontCaveat = await fontResponse.arrayBuffer();
+
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
- const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
-
const pages = pdf.getPages();
+ const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
- let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
+ let fontSize = maxFontSize;
const page = pages.at(field.page - 1);
@@ -50,11 +56,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let imageWidth = image.width;
let imageHeight = image.height;
- // const initialDimensions = {
- // width: imageWidth,
- // height: imageHeight,
- // };
-
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
@@ -76,14 +77,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
- // const initialDimensions = {
- // width: textWidth,
- // height: textHeight,
- // };
-
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
- fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
+ fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
diff --git a/packages/lib/server-only/pdf/insert-text-in-pdf.ts b/packages/lib/server-only/pdf/insert-text-in-pdf.ts
index 229806554..248702b6e 100644
--- a/packages/lib/server-only/pdf/insert-text-in-pdf.ts
+++ b/packages/lib/server-only/pdf/insert-text-in-pdf.ts
@@ -1,7 +1,8 @@
import fontkit from '@pdf-lib/fontkit';
-import * as fs from 'fs';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
+import { CAVEAT_FONT_PATH } from '../../constants/pdf';
+
export async function insertTextInPDF(
pdfAsBase64: string,
text: string,
@@ -10,13 +11,15 @@ export async function insertTextInPDF(
page = 0,
useHandwritingFont = true,
): Promise {
- const fontBytes = fs.readFileSync('./public/fonts/caveat.ttf');
+ // Fetch the font file from the public URL.
+ const fontResponse = await fetch(CAVEAT_FONT_PATH);
+ const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit);
- const font = await pdfDoc.embedFont(useHandwritingFont ? fontBytes : StandardFonts.Helvetica);
+ const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
diff --git a/packages/lib/universal/base64.ts b/packages/lib/universal/base64.ts
new file mode 100644
index 000000000..fefb1fe89
--- /dev/null
+++ b/packages/lib/universal/base64.ts
@@ -0,0 +1 @@
+export * from '@scure/base';
diff --git a/apps/web/src/helpers/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts
similarity index 68%
rename from apps/web/src/helpers/get-feature-flag.ts
rename to packages/lib/universal/get-feature-flag.ts
index d5cd26c33..38707d41b 100644
--- a/apps/web/src/helpers/get-feature-flag.ts
+++ b/packages/lib/universal/get-feature-flag.ts
@@ -1,9 +1,12 @@
import { z } from 'zod';
+import {
+ TFeatureFlagValue,
+ ZFeatureFlagValueSchema,
+} from '@documenso/lib/client-only/providers/feature-flag.types';
+import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
-import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
-
/**
* Evaluate whether a flag is enabled for the current user.
*
@@ -21,7 +24,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
- const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
+ const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
@@ -54,7 +57,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS;
}
- const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
+ const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
@@ -69,6 +72,28 @@ export const getAllFlags = async (
.catch(() => LOCAL_FEATURE_FLAGS);
};
+/**
+ * Get all feature flags for anonymous users.
+ *
+ * @returns A record of flags and their values.
+ */
+export const getAllAnonymousFlags = async (): Promise> => {
+ if (!isFeatureFlagEnabled()) {
+ return LOCAL_FEATURE_FLAGS;
+ }
+
+ const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
+
+ return fetch(url, {
+ next: {
+ revalidate: 60,
+ },
+ })
+ .then(async (res) => res.json())
+ .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
+ .catch(() => LOCAL_FEATURE_FLAGS);
+};
+
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
diff --git a/packages/lib/universal/id.ts b/packages/lib/universal/id.ts
index 13738233e..0d40dd088 100644
--- a/packages/lib/universal/id.ts
+++ b/packages/lib/universal/id.ts
@@ -1,5 +1,5 @@
import { customAlphabet } from 'nanoid';
-export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
+export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid';
diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts
new file mode 100644
index 000000000..b88fed3e9
--- /dev/null
+++ b/packages/lib/utils/fields.ts
@@ -0,0 +1,41 @@
+import { Field } from '@documenso/prisma/client';
+
+/**
+ * Sort the fields by the Y position on the document.
+ */
+export const sortFieldsByPosition = (fields: Field[]): Field[] => {
+ const clonedFields: Field[] = JSON.parse(JSON.stringify(fields));
+
+ // Sort by page first, then position on page second.
+ return clonedFields.sort((a, b) => a.page - b.page || Number(a.positionY) - Number(b.positionY));
+};
+
+/**
+ * Validate whether all the provided fields are inserted.
+ *
+ * If there are any non-inserted fields it will be highlighted and scrolled into view.
+ *
+ * @returns `true` if all fields are inserted, `false` otherwise.
+ */
+export const validateFieldsInserted = (fields: Field[]): boolean => {
+ const fieldCardElements = document.getElementsByClassName('field-card-container');
+
+ // Attach validate attribute on all fields.
+ Array.from(fieldCardElements).forEach((element) => {
+ element.setAttribute('data-validate', 'true');
+ });
+
+ const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
+
+ const firstUninsertedField = uninsertedFields[0];
+
+ const firstUninsertedFieldElement =
+ firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
+
+ if (firstUninsertedFieldElement) {
+ firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ return false;
+ }
+
+ return uninsertedFields.length === 0;
+};
diff --git a/packages/prisma/migrations/20230830053354_add_service_user/migration.sql b/packages/prisma/migrations/20230830053354_add_service_user/migration.sql
new file mode 100644
index 000000000..b08996f11
--- /dev/null
+++ b/packages/prisma/migrations/20230830053354_add_service_user/migration.sql
@@ -0,0 +1,4 @@
+INSERT INTO "User" ("email", "name") VALUES (
+ 'serviceaccount@documenso.com',
+ 'Service Account'
+) ON CONFLICT DO NOTHING;
diff --git a/packages/prisma/types/document-with-recipient.ts b/packages/prisma/types/document-with-recipient.ts
index 4ba6a9776..1db025279 100644
--- a/packages/prisma/types/document-with-recipient.ts
+++ b/packages/prisma/types/document-with-recipient.ts
@@ -1,5 +1,10 @@
-import { Document, Recipient } from '@documenso/prisma/client';
+import { Document, DocumentData, Recipient } from '@documenso/prisma/client';
-export type DocumentWithRecipient = Document & {
+export type DocumentWithRecipients = Document & {
Recipient: Recipient[];
};
+
+export type DocumentWithRecipient = Document & {
+ Recipient: Recipient;
+ documentData: DocumentData;
+};
diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx
new file mode 100644
index 000000000..b76d54eeb
--- /dev/null
+++ b/packages/ui/components/document/document-dialog.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { useState } from 'react';
+
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+
+import { cn } from '@documenso/ui/lib/utils';
+
+import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
+import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer';
+
+export type DocumentDialogProps = {
+ document: string;
+} & Omit;
+
+/**
+ * A dialog which renders the provided document.
+ */
+export default function DocumentDialog({ document, ...props }: DocumentDialogProps) {
+ const [documentLoaded, setDocumentLoaded] = useState(false);
+
+ const onDocumentLoad = () => {
+ setDocumentLoaded(true);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx b/packages/ui/components/document/document-download-button.tsx
similarity index 97%
rename from apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx
rename to packages/ui/components/document/document-download-button.tsx
index 49b7a8f15..d9a4c58e2 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx
+++ b/packages/ui/components/document/document-download-button.tsx
@@ -15,7 +15,7 @@ export type DownloadButtonProps = HTMLAttributes & {
documentData?: DocumentData;
};
-export const DownloadButton = ({
+export const DocumentDownloadButton = ({
className,
fileName,
documentData,
diff --git a/packages/ui/components/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx
new file mode 100644
index 000000000..446b14d2d
--- /dev/null
+++ b/packages/ui/components/field/field-tooltip.tsx
@@ -0,0 +1,63 @@
+import { TooltipArrow } from '@radix-ui/react-tooltip';
+import { VariantProps, cva } from 'class-variance-authority';
+import { createPortal } from 'react-dom';
+
+import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
+import { cn } from '@documenso/ui/lib/utils';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@documenso/ui/primitives/tooltip';
+
+import { Field } from '.prisma/client';
+
+const tooltipVariants = cva('font-semibold', {
+ variants: {
+ color: {
+ default: 'border-2 fill-white',
+ warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900',
+ },
+ },
+ defaultVariants: {
+ color: 'default',
+ },
+});
+
+interface FieldToolTipProps extends VariantProps {
+ children: React.ReactNode;
+ className?: string;
+ field: Field;
+}
+
+/**
+ * Renders a tooltip for a given field.
+ */
+export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
+ const coords = useFieldPageCoords(field);
+
+ return createPortal(
+
+
+
+
+
+
+ {children}
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx
new file mode 100644
index 000000000..054cc6376
--- /dev/null
+++ b/packages/ui/components/field/field.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+
+import { createPortal } from 'react-dom';
+
+import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
+import { Field } from '@documenso/prisma/client';
+import { cn } from '@documenso/ui/lib/utils';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+export type FieldRootContainerProps = {
+ field: Field;
+ children: React.ReactNode;
+};
+
+export type FieldContainerPortalProps = {
+ field: Field;
+ className?: string;
+ children: React.ReactNode;
+};
+
+export function FieldContainerPortal({
+ field,
+ children,
+ className = '',
+}: FieldContainerPortalProps) {
+ const coords = useFieldPageCoords(field);
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+}
+
+export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
+ const [isValidating, setIsValidating] = useState(false);
+
+ const ref = React.useRef(null);
+
+ useEffect(() => {
+ if (!ref.current) {
+ return;
+ }
+
+ const observer = new MutationObserver((_mutations) => {
+ if (ref.current) {
+ setIsValidating(ref.current.getAttribute('data-validate') === 'true');
+ }
+ });
+
+ observer.observe(ref.current, {
+ attributes: true,
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/ui/components/signing-card.tsx b/packages/ui/components/signing-card.tsx
new file mode 100644
index 000000000..496e451d0
--- /dev/null
+++ b/packages/ui/components/signing-card.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import Image, { StaticImageData } from 'next/image';
+
+import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
+
+import { cn } from '@documenso/ui/lib/utils';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+export type SigningCardProps = {
+ className?: string;
+ name: string;
+ signingCelebrationImage?: StaticImageData;
+};
+
+/**
+ * 2D signing card.
+ */
+export const SigningCard = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
+ return (
+
+
+
+ {signingCelebrationImage && (
+
+ )}
+
+ );
+};
+
+/**
+ * 3D signing card that follows the mouse movement within a certain range.
+ */
+export const SigningCard3D = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
+ // Should use % based dimensions by calculating the window height/width.
+ const boundary = 400;
+
+ const [trackMouse, setTrackMouse] = useState(false);
+
+ const timeoutRef = useRef();
+
+ const cardX = useMotionValue(0);
+ const cardY = useMotionValue(0);
+ const rotateX = useTransform(cardY, [-600, 600], [8, -8]);
+ const rotateY = useTransform(cardX, [-600, 600], [-8, 8]);
+
+ const diagonalMovement = useTransform(
+ [rotateX, rotateY],
+ ([newRotateX, newRotateY]) => newRotateX + newRotateY,
+ );
+
+ const sheenPosition = useTransform(diagonalMovement, [-16, 16], [-100, 200]);
+ const sheenOpacity = useTransform(sheenPosition, [-100, 50, 200], [0, 0.1, 0]);
+ const sheenGradient = useMotionTemplate`linear-gradient(
+ 30deg,
+ transparent,
+ rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%,
+ transparent)`;
+
+ const cardRef = useRef(null);
+
+ const cardCenterPosition = useCallback(() => {
+ if (!cardRef.current) {
+ return { x: 0, y: 0 };
+ }
+
+ const { x, y, width, height } = cardRef.current.getBoundingClientRect();
+
+ return { x: x + width / 2, y: y + height / 2 };
+ }, [cardRef]);
+
+ const onMouseMove = useCallback(
+ (event: MouseEvent) => {
+ const { x, y } = cardCenterPosition();
+
+ const offsetX = event.clientX - x;
+ const offsetY = event.clientY - y;
+
+ // Calculate distance between the mouse pointer and center of the card.
+ const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
+
+ // Mouse enters enter boundary.
+ if (distance <= boundary && !trackMouse) {
+ setTrackMouse(true);
+ } else if (!trackMouse) {
+ return;
+ }
+
+ cardX.set(offsetX);
+ cardY.set(offsetY);
+
+ clearTimeout(timeoutRef.current);
+
+ // Revert the card back to the center position after the mouse stops moving.
+ timeoutRef.current = setTimeout(() => {
+ void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
+ void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
+
+ setTrackMouse(false);
+ }, 1000);
+ },
+ [cardX, cardY, cardCenterPosition, trackMouse],
+ );
+
+ useEffect(() => {
+ window.addEventListener('mousemove', onMouseMove);
+
+ return () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ };
+ }, [onMouseMove]);
+
+ return (
+
+
+
+
+
+ {signingCelebrationImage && (
+
+ )}
+
+ );
+};
+
+type SigningCardContentProps = {
+ name: string;
+ className?: string;
+};
+
+const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
+ return (
+
+
+
+ {name}
+
+
+
+ );
+};
+
+type SigningCardImageProps = {
+ signingCelebrationImage: StaticImageData;
+};
+
+const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/ui/icons/signature.tsx b/packages/ui/icons/signature.tsx
index 0d172bab4..6c118b222 100644
--- a/packages/ui/icons/signature.tsx
+++ b/packages/ui/icons/signature.tsx
@@ -30,3 +30,5 @@ export const SignatureIcon: LucideIcon = forwardRef(
);
},
);
+
+SignatureIcon.displayName = 'SignatureIcon';
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 53751f5a7..9826f2df8 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -18,6 +18,7 @@
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
+ "@types/luxon": "^3.3.2",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"react": "18.2.0",
@@ -25,6 +26,7 @@
},
"dependencies": {
"@documenso/lib": "*",
+ "@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
@@ -45,21 +47,25 @@
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
+ "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2",
- "@radix-ui/react-tooltip": "^1.0.5",
+ "@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
+ "luxon": "^3.4.2",
"next": "13.4.19",
"pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1",
+ "react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3",
+ "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
}
diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx
index c67117d6f..31df69dee 100644
--- a/packages/ui/primitives/button.tsx
+++ b/packages/ui/primitives/button.tsx
@@ -56,14 +56,14 @@ export interface ButtonProps
}
const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
+ ({ className, variant, size, asChild = false, loading, ...props }, ref) => {
if (asChild) {
return (
);
}
- const showLoader = props.loading === true;
+ const showLoader = loading === true;
const isDisabled = props.disabled || showLoader;
return (
diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx
index c83b3d87c..cea2800fc 100644
--- a/packages/ui/primitives/dialog.tsx
+++ b/packages/ui/primitives/dialog.tsx
@@ -109,6 +109,8 @@ export {
DialogContent,
DialogHeader,
DialogFooter,
+ DialogOverlay,
DialogTitle,
DialogDescription,
+ DialogPortal,
};
diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx
index 8035e48cb..834d32545 100644
--- a/packages/ui/primitives/document-dropzone.tsx
+++ b/packages/ui/primitives/document-dropzone.tsx
@@ -73,7 +73,7 @@ const DocumentDropzoneCardCenterVariants: Variants = {
};
export type DocumentDropzoneProps = {
- className: string;
+ className?: string;
onDrop?: (_file: File) => void | Promise;
[key: string]: unknown;
};
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index b821f6ca8..9abbeed1a 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -256,17 +256,28 @@ export const AddFieldsFormPartial = ({
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
- const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
+ const observer = new MutationObserver((_mutations) => {
+ const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
- if (!$page) {
- return;
- }
+ if (!$page) {
+ return;
+ }
- const { height, width } = $page.getBoundingClientRect();
+ const { height, width } = $page.getBoundingClientRect();
- fieldBounds.current = {
- height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
- width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
+ fieldBounds.current = {
+ height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
+ width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
+ };
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ return () => {
+ observer.disconnect();
};
}, []);
@@ -396,7 +407,7 @@ export const AddFieldsFormPartial = ({
)}
-
+
{
+ documentFlow.onBackStep?.();
+ remove();
+ }}
onGoNextClick={() => void onFormSubmit()}
/>
diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx
new file mode 100644
index 000000000..aed252083
--- /dev/null
+++ b/packages/ui/primitives/document-flow/add-signature.tsx
@@ -0,0 +1,344 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { DateTime } from 'luxon';
+import { useForm } from 'react-hook-form';
+import { match } from 'ts-pattern';
+
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
+import { Field, FieldType } from '@documenso/prisma/client';
+import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
+import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
+import { cn } from '@documenso/ui/lib/utils';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
+import {
+ DocumentFlowFormContainerActions,
+ DocumentFlowFormContainerContent,
+ DocumentFlowFormContainerFooter,
+ DocumentFlowFormContainerStep,
+} from '@documenso/ui/primitives/document-flow/document-flow-root';
+import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
+import { ElementVisible } from '@documenso/ui/primitives/element-visible';
+import { Input } from '@documenso/ui/primitives/input';
+import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
+
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
+import { ZAddSignatureFormSchema } from './add-signature.types';
+import {
+ SinglePlayerModeCustomTextField,
+ SinglePlayerModeSignatureField,
+} from './single-player-mode-fields';
+
+export type AddSignatureFormProps = {
+ defaultValues?: TAddSignatureFormSchema;
+ documentFlow: DocumentFlowStep;
+ fields: FieldWithSignature[];
+ numberOfSteps: number;
+ onSubmit: (_data: TAddSignatureFormSchema) => Promise | void;
+ requireName?: boolean;
+ requireSignature?: boolean;
+};
+
+export const AddSignatureFormPartial = ({
+ defaultValues,
+ documentFlow,
+ fields,
+ numberOfSteps,
+ onSubmit,
+ requireName = false,
+ requireSignature = true,
+}: AddSignatureFormProps) => {
+ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
+
+ // Refined schema which takes into account whether to allow an empty name or signature.
+ const refinedSchema = ZAddSignatureFormSchema.superRefine((val, ctx) => {
+ if (requireName && val.name.length === 0) {
+ ctx.addIssue({
+ path: ['name'],
+ code: 'custom',
+ message: 'Name is required',
+ });
+ }
+
+ if (requireSignature && val.signature.length === 0) {
+ ctx.addIssue({
+ path: ['signature'],
+ code: 'custom',
+ message: 'Signature is required',
+ });
+ }
+ });
+
+ const form = useForm({
+ resolver: zodResolver(refinedSchema),
+ defaultValues: defaultValues ?? {
+ name: '',
+ email: '',
+ signature: '',
+ },
+ });
+
+ /**
+ * A local copy of the provided fields to modify.
+ */
+ const [localFields, setLocalFields] = useState(JSON.parse(JSON.stringify(fields)));
+
+ const uninsertedFields = useMemo(() => {
+ const fields = localFields.filter((field) => !field.inserted);
+
+ return sortFieldsByPosition(fields);
+ }, [localFields]);
+
+ const onValidateFields = async (values: TAddSignatureFormSchema) => {
+ setValidateUninsertedFields(true);
+ const isFieldsValid = validateFieldsInserted(localFields);
+
+ if (!isFieldsValid) {
+ return;
+ }
+
+ await onSubmit(values);
+ };
+
+ /**
+ * Validates whether the corresponding form for a given field type is valid.
+ *
+ * @returns `true` if the form associated with the provided field is valid, `false` otherwise.
+ */
+ const validateFieldForm = async (fieldType: Field['type']): Promise => {
+ if (fieldType === FieldType.SIGNATURE) {
+ await form.trigger('signature');
+ return !form.formState.errors.signature;
+ }
+
+ if (fieldType === FieldType.NAME) {
+ await form.trigger('name');
+ return !form.formState.errors.name;
+ }
+
+ if (fieldType === FieldType.EMAIL) {
+ await form.trigger('email');
+ return !form.formState.errors.email;
+ }
+
+ return true;
+ };
+
+ /**
+ * Insert the corresponding form value into a given field.
+ */
+ const insertFormValueIntoField = (field: Field) => {
+ return match(field.type)
+ .with(FieldType.DATE, () => ({
+ ...field,
+ customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'),
+ inserted: true,
+ }))
+ .with(FieldType.EMAIL, () => ({
+ ...field,
+ customText: form.getValues('email'),
+ inserted: true,
+ }))
+ .with(FieldType.NAME, () => ({
+ ...field,
+ customText: form.getValues('name'),
+ inserted: true,
+ }))
+ .with(FieldType.SIGNATURE, () => {
+ const value = form.getValues('signature');
+
+ return {
+ ...field,
+ value,
+ Signature: {
+ id: -1,
+ recipientId: -1,
+ fieldId: -1,
+ created: new Date(),
+ signatureImageAsBase64: value,
+ typedSignature: null,
+ },
+ inserted: true,
+ };
+ })
+ .otherwise(() => {
+ throw new Error('Unsupported field');
+ });
+ };
+
+ const insertField = (field: Field) => async () => {
+ const isFieldFormValid = await validateFieldForm(field.type);
+ if (!isFieldFormValid) {
+ return;
+ }
+
+ setLocalFields((prev) =>
+ prev.map((prevField) => {
+ if (prevField.id !== field.id) {
+ return prevField;
+ }
+
+ return insertFormValueIntoField(field);
+ }),
+ );
+ };
+
+ /**
+ * When a form value changes, reset all the corresponding fields to be uninserted.
+ */
+ const onFormValueChange = (fieldType: FieldType) => {
+ setLocalFields((fields) =>
+ fields.map((field) => {
+ if (field.type !== fieldType) {
+ return field;
+ }
+
+ return {
+ ...field,
+ inserted: false,
+ };
+ }),
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/ui/primitives/document-flow/add-signature.types.tsx b/packages/ui/primitives/document-flow/add-signature.types.tsx
new file mode 100644
index 000000000..7559841ea
--- /dev/null
+++ b/packages/ui/primitives/document-flow/add-signature.types.tsx
@@ -0,0 +1,9 @@
+import { z } from 'zod';
+
+export const ZAddSignatureFormSchema = z.object({
+ email: z.string().min(1).email(),
+ name: z.string(),
+ signature: z.string(),
+});
+
+export type TAddSignatureFormSchema = z.infer;
diff --git a/packages/ui/primitives/document-flow/document-flow-root.tsx b/packages/ui/primitives/document-flow/document-flow-root.tsx
index 355d0ccad..96a0e18b1 100644
--- a/packages/ui/primitives/document-flow/document-flow-root.tsx
+++ b/packages/ui/primitives/document-flow/document-flow-root.tsx
@@ -13,7 +13,7 @@ export type DocumentFlowFormContainerProps = HTMLAttributes & {
export const DocumentFlowFormContainer = ({
children,
- id = 'edit-document-form',
+ id = 'document-flow-form-container',
className,
...props
}: DocumentFlowFormContainerProps) => {
@@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
),
});
+
+/**
+ * LazyPDFViewer variant with no loader.
+ */
+export const LazyPDFViewerNoLoader = dynamic(async () => import('./pdf-viewer'), {
+ ssr: false,
+});
diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx
index 7315f1d26..008e81f82 100644
--- a/packages/ui/primitives/pdf-viewer.tsx
+++ b/packages/ui/primitives/pdf-viewer.tsx
@@ -8,6 +8,7 @@ import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
+import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
export type LoadedPDFDocument = PDFDocumentProxy;
@@ -30,18 +31,27 @@ export type OnPDFViewerPageClick = (_event: {
export type PDFViewerProps = {
className?: string;
document: string;
+ onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick;
[key: string]: unknown;
-};
+} & Omit
, 'onPageClick'>;
-export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => {
+export const PDFViewer = ({
+ className,
+ document,
+ onDocumentLoad,
+ onPageClick,
+ ...props
+}: PDFViewerProps) => {
const $el = useRef(null);
const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0);
+ const [pdfError, setPdfError] = useState(false);
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
setNumPages(doc.numPages);
+ onDocumentLoad?.(doc);
};
const onDocumentPageClick = (
@@ -54,7 +64,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
return;
}
- const $page = $el.closest('.react-pdf__Page');
+ const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
@@ -108,12 +118,34 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
+ // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
+ // Therefore we add some additional custom error handling.
+ onSourceError={() => {
+ setPdfError(true);
+ }}
externalLinkTarget="_blank"
loading={
-
+ {pdfError ? (
+
+
Something went wrong while loading the document.
+
Please try again or contact our support.
+
+ ) : (
+ <>
+
-
Loading document...
+
Loading document...
+ >
+ )}
+
+ }
+ error={
+
+
+
Something went wrong while loading the document.
+
Please try again or contact our support.
+
}
>
@@ -129,6 +161,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
width={width}
renderAnnotationLayer={false}
renderTextLayer={false}
+ loading={() => ''}
onClick={(e) => onDocumentPageClick(e, i + 1)}
/>
diff --git a/scripts/vercel.sh b/scripts/vercel.sh
index e4ab23622..30fe99476 100755
--- a/scripts/vercel.sh
+++ b/scripts/vercel.sh
@@ -13,7 +13,8 @@ function log() {
function build_webapp() {
log "Building webapp for $VERCEL_ENV"
-
+
+ remap_webapp_env
remap_database_integration
npm run prisma:generate --workspace=@documenso/prisma
@@ -39,7 +40,8 @@ function remap_webapp_env() {
function build_marketing() {
log "Building marketing for $VERCEL_ENV"
-
+
+ remap_marketing_env
remap_database_integration
npm run prisma:generate --workspace=@documenso/prisma
@@ -72,7 +74,6 @@ function remap_database_integration() {
export NEXT_PRIVATE_DIRECT_DATABASE_URL="$POSTGRES_URL_NON_POOLING"
fi
-
if [[ "$NEXT_PRIVATE_DATABASE_URL" == *"neon.tech"* ]]; then
log "Remapping for Neon integration"
diff --git a/turbo.json b/turbo.json
index f1556f229..c1e2c30c0 100644
--- a/turbo.json
+++ b/turbo.json
@@ -26,6 +26,7 @@
"APP_VERSION",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
+ "NEXT_PUBLIC_PROJECT",
"NEXT_PUBLIC_WEBAPP_URL",
"NEXT_PUBLIC_MARKETING_URL",
"NEXT_PUBLIC_POSTHOG_KEY",
@@ -59,7 +60,6 @@
"NEXT_PRIVATE_SMTP_FROM_NAME",
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
"NEXT_PRIVATE_STRIPE_API_KEY",
-
"VERCEL",
"VERCEL_ENV",
"VERCEL_URL",
@@ -69,4 +69,4 @@
"POSTGRES_PRISMA_URL",
"POSTGRES_URL_NON_POOLING"
]
-}
+}
\ No newline at end of file