feat: add single player mode

This commit is contained in:
David Nguyen
2023-09-20 13:48:30 +10:00
parent e6e8de62c8
commit 4b8aa3298b
85 changed files with 2610 additions and 438 deletions

View File

@ -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) => {
@ -44,17 +42,17 @@ export const TemplateDocumentCompleted = ({
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document.
Continue by downloading the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
{/* <Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={reviewLink}
>
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Review
</Button>
</Button> */}
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}

View File

@ -0,0 +1,69 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentSelfSignedProps {
downloadLink: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentSelfSigned = ({
downloadLink,
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Completed
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
You have signed {documentName}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Check out our plans to access the full suite of features.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
View plans
</Button>
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Download
</Button>
</Section>
</Section>
</Tailwind>
);
};
export default TemplateDocumentSelfSigned;

View File

@ -21,7 +21,6 @@ export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentComple
export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com',
reviewLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => {
@ -56,7 +55,6 @@ export const DocumentCompletedEmailTemplate = ({
<TemplateDocumentCompleted
downloadLink={downloadLink}
reviewLink={reviewLink}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>

View File

@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<TemplateDocumentSelfSigned
downloadLink={downloadLink}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};
export default DocumentSelfSignedEmailTemplate;

View File

@ -7,7 +7,7 @@
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0",
"eslint-config-next": "13.4.12",
"eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.1.4",
@ -15,4 +15,4 @@
"eslint-plugin-react": "^7.32.2",
"typescript": "^5.1.6"
}
}
}

View File

@ -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<string, unknown>) => {
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,
};
}

View File

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -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<HTMLElement | null>,
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;
}

View File

@ -0,0 +1,78 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
export const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
};

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
export function useWindowSize() {
const [size, setSize] = useState({
width: 0,
height: 0,
});
const onResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
useEffect(() => {
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return size;
}

View File

@ -0,0 +1,95 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import {
FEATURE_FLAG_POLL_INTERVAL,
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
};
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export const useFeatureFlags = () => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
export function FeatureFlagProvider({
children,
initialFlags,
}: {
children: React.ReactNode;
initialFlags: Record<string, TFeatureFlagValue>;
}) {
const [flags, setFlags] = useState(initialFlags);
const getFlag = useCallback(
(flag: string) => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
return flags[flag] ?? false;
},
[flags],
);
/**
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const interval = setInterval(() => {
if (document.hasFocus()) {
void getAllFlags().then((newFlags) => setFlags(newFlags));
}
}, FEATURE_FLAG_POLL_INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
/**
* Refresh the flags when the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return (
<FeatureFlagContext.Provider
value={{
getFlag,
}}
>
{children}
</FeatureFlagContext.Provider>
);
}

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZFeatureFlagValueSchema = z.union([
z.boolean(),
z.string(),
z.number(),
z.undefined(),
]);
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;

View File

@ -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;

View File

@ -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';

View File

@ -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<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
marketing_header_single_player_mode: false,
} as const;
/**

View File

@ -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`;

View File

@ -25,7 +25,7 @@
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next": "13.4.19",
"next-auth": "4.22.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
@ -36,4 +36,4 @@
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^3.3.1"
}
}
}

View File

@ -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<DocumentWithRecipient> => {
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],
};
};

View File

@ -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 { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
@ -65,8 +66,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: 'Please sign this document',
html: render(template),

View File

@ -0,0 +1,39 @@
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');
return res;
}

View File

@ -0,0 +1,16 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@ -0,0 +1,26 @@
import { headers } from 'next/headers';
import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
*
* @param flag The flag to evaluate.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getServerComponentFlag = async (flag: string) => {
return await getFlag(flag, {
requestHeaders: Object.fromEntries(headers().entries()),
});
};
/**
* Get all feature flags for the current user from a server component.
*
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getServerComponentAllFlags = async () => {
return await getAllFlags({
requestHeaders: Object.fromEntries(headers().entries()),
});
};

View File

@ -0,0 +1,117 @@
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');
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<string, string>;
personProperties?: Record<string, string>;
groupProperties?: Record<string, Record<string, string>>;
} => {
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;
};

View File

@ -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;

View File

@ -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<string> {
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];

View File

@ -0,0 +1,104 @@
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';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
/**
* Get all feature flags for anonymous users.
*
* @returns A record of flags and their values.
*/
export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFlagValue>> => {
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.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@ -0,0 +1,4 @@
INSERT INTO "User" ("email", "name") VALUES (
'serviceaccount@documenso.com',
'Service Account'
) ON CONFLICT DO NOTHING;

View File

@ -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;
};

View File

@ -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<DialogPrimitive.DialogProps, 'children'>;
/**
* A dialog which renders the provided document.
*/
export default function DocumentDialog({ document, ...props }: DocumentDialogProps) {
const [documentLoaded, setDocumentLoaded] = useState(false);
const onDocumentLoad = () => {
setDocumentLoaded(true);
};
return (
<Dialog {...props}>
<DialogPortal>
<DialogOverlay className="bg-black/80" />
<DialogPrimitive.Content
className={cn(
'animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 pointer-events-none fixed z-50 h-screen w-screen overflow-y-auto px-2 py-14 opacity-0 transition-opacity lg:py-32',
{
'opacity-100': documentLoaded,
},
)}
onClick={() => props.onOpenChange?.(false)}
>
<LazyPDFViewerNoLoader
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
document={`data:application/pdf;base64,${document}`}
onClick={(e) => e.stopPropagation()}
onDocumentLoad={onDocumentLoad}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<X className="h-6 w-6 text-white" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}

View File

@ -0,0 +1,78 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
documentData?: DocumentData;
};
export const DocumentDownloadButton = ({
className,
fileName,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
return;
}
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@ -0,0 +1,205 @@
'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 = {
name: string;
signingCelebrationImage?: StaticImageData;
};
/**
* 2D signing card.
*/
export const SigningCard = ({ name, signingCelebrationImage }: SigningCardProps) => {
return (
<div className="relative w-full max-w-xs md:max-w-sm">
<SigningCardContent name={name} />
{signingCelebrationImage && (
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
)}
</div>
);
};
/**
* 3D signing card that follows the mouse movement within a certain range.
*/
export const SigningCard3D = ({ name, signingCelebrationImage }: SigningCardProps) => {
// Should use % based dimensions by calculating the window height/width.
const boundary = 400;
const [trackMouse, setTrackMouse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
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<number, number>(
[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(200 200 200 / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%,
transparent)`;
const cardRef = useRef<HTMLDivElement>(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);
}
if (!trackMouse) {
return;
}
void animate(cardX, offsetX, { duration: 0.125 });
void animate(cardY, offsetY, { duration: 0.125 });
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 (
<div className="relative w-full max-w-xs md:max-w-sm" style={{ perspective: 800 }}>
<motion.div
className="bg-background w-full"
ref={cardRef}
style={{
perspective: '800',
backgroundImage: sheenGradient,
transformStyle: 'preserve-3d',
rotateX,
rotateY,
}}
>
<SigningCardContent className="bg-transparent" name={name} />
</motion.div>
{signingCelebrationImage && (
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
)}
</div>
);
};
type SigningCardContentProps = {
name: string;
className?: string;
};
const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
return (
<Card
className={cn(
'group mx-auto flex aspect-[21/9] w-full items-center justify-center',
className,
)}
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
);
};
type SigningCardImageProps = {
signingCelebrationImage: StaticImageData;
};
const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) => {
return (
<motion.div
className="pointer-events-none absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebrationImage}
alt="background pattern"
className="w-full"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
);
};

View File

@ -1,12 +1,13 @@
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { LucideIcon, LucideProps } from 'lucide-react/dist/lucide-react';
export const SignatureIcon: LucideIcon = ({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
export const SignatureIcon = (({
size = 24,
color = 'currentColor',
strokeWidth = 1.33,
absoluteStrokeWidth,
...props
}) => {
}: LucideProps) => {
return (
<svg
width={size}
@ -25,4 +26,4 @@ export const SignatureIcon: LucideIcon = ({
/>
</svg>
);
};
}) as LucideIcon;

View File

@ -17,12 +17,14 @@
"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",
"typescript": "^5.1.6"
},
"dependencies": {
"@hookform/resolvers": "^3.3.0",
"@documenso/lib": "*",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
@ -44,6 +46,7 @@
"@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",
@ -54,12 +57,15 @@
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"next": "13.4.12",
"lucide-react": "^0.277.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"
}
}
}

View File

@ -56,14 +56,14 @@ export interface ButtonProps
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, loading, ...props }, ref) => {
if (asChild) {
return (
<Slot className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
const showLoader = props.loading === true;
const showLoader = loading === true;
const isDisabled = props.disabled || showLoader;
return (

View File

@ -109,6 +109,8 @@ export {
DialogContent,
DialogHeader,
DialogFooter,
DialogOverlay,
DialogTitle,
DialogDescription,
DialogPortal,
};

View File

@ -73,7 +73,7 @@ const DocumentDropzoneCardCenterVariants: Variants = {
};
export type DocumentDropzoneProps = {
className: string;
className?: string;
onDrop?: (_file: File) => void | Promise<void>;
[key: string]: unknown;
};

View File

@ -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 = ({
</Popover>
)}
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
@ -505,7 +516,10 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoBackClick={() => {
documentFlow.onBackStep?.();
remove();
}}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>

View File

@ -0,0 +1,282 @@
'use client';
import { 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 { FieldType } from '@documenso/prisma/client';
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 { 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> | void;
requireName?: boolean;
requireSignature?: boolean;
};
export const AddSignatureFormPartial = ({
defaultValues,
documentFlow,
fields,
numberOfSteps,
onSubmit,
requireName = false,
requireSignature = true,
}: AddSignatureFormProps) => {
// 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<TAddSignatureFormSchema>({
resolver: zodResolver(refinedSchema),
defaultValues: defaultValues ?? {
name: '',
email: '',
signature: '',
},
});
/**
* A local copy of the provided fields to modify.
*/
const [localFields, setLocalFields] = useState(
fields.map((field) => {
let customText = field.customText;
if (field.type === FieldType.DATE) {
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
}
const inserted = match(field.type)
.with(FieldType.DATE, () => true)
.with(FieldType.NAME, () => form.getValues('name').length > 0)
.with(FieldType.EMAIL, () => form.getValues('email').length > 0)
.with(FieldType.SIGNATURE, () => form.getValues('signature').length > 0)
.otherwise(() => true);
return { ...field, inserted, customText };
}),
);
const onEmailInputBlur = () => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.EMAIL) {
return field;
}
const value = form.getValues('email');
return {
...field,
customText: value,
inserted: value.length > 0,
};
}),
);
};
const onNameInputBlur = () => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.NAME) {
return field;
}
const value = form.getValues('name');
return {
...field,
customText: value,
inserted: value.length > 0,
};
}),
);
};
const onSignatureInputChange = (value: string) => {
setLocalFields((prev) =>
prev.map((field) => {
if (field.type !== FieldType.SIGNATURE) {
return field;
}
return {
...field,
value: value ?? '',
inserted: true,
Signature: {
id: -1,
recipientId: -1,
fieldId: -1,
created: new Date(),
signatureImageAsBase64: value,
typedSignature: null,
},
};
}),
);
};
return (
<Form {...form}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<DocumentFlowFormContainerContent>
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input
className="bg-background"
type="email"
autoComplete="email"
{...field}
onBlur={() => {
field.onBlur();
onEmailInputBlur();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{requireName && (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required={requireName}>Name</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onBlur={() => {
field.onBlur();
onNameInputBlur();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{requireSignature && (
<FormField
control={form.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel required={requireSignature}>Signature</FormLabel>
<FormControl>
<Card
className={cn('mt-2', {
'rounded-sm ring-2 ring-red-500 ring-offset-2 transition-all':
form.formState.errors.signature,
})}
gradient={!form.formState.errors.signature}
degrees={-120}
>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={field.value}
onChange={(value) => {
field.onChange(value ?? '');
onSignatureInputChange(value ?? '');
}}
/>
</CardContent>
</Card>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={async () => await form.handleSubmit(onSubmit)()}
/>
</DocumentFlowFormContainerFooter>
</fieldset>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
return <SinglePlayerModeCustomTextField key={field.id} field={field} />;
})
.with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField key={field.id} field={field} />
))
.otherwise(() => {
return null;
}),
)}
</ElementVisible>
</Form>
);
};

View File

@ -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<typeof ZAddSignatureFormSchema>;

View File

@ -13,7 +13,7 @@ export type DocumentFlowFormContainerProps = HTMLAttributes<HTMLFormElement> & {
export const DocumentFlowFormContainer = ({
children,
id = 'edit-document-form',
id = 'document-flow-form-container',
className,
...props
}: DocumentFlowFormContainerProps) => {
@ -152,10 +152,11 @@ export const DocumentFlowFormContainerActions = ({
</Button>
<Button
type="submit"
type="button"
className="bg-documenso flex-1"
size="lg"
disabled={disabled || loading || !canGoNext}
loading={loading}
onClick={onGoNextClick}
>
{goNextLabel}

View File

@ -0,0 +1,212 @@
'use client';
import React, { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { createPortal } from 'react-dom';
import { match } from 'ts-pattern';
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
MIN_STANDARD_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { Field, FieldType } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type FieldContainerPortalProps = {
field: FieldWithSignature;
className?: string;
children: React.ReactNode;
};
export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature;
children: React.ReactNode;
};
export function FieldContainerPortal({
field,
children,
className = '',
}: FieldContainerPortalProps) {
const coords = useFieldPageCoords(field);
return createPortal(
<div
className={cn('absolute', className)}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
{children}
</div>,
document.body,
);
}
export function SinglePlayerModeFieldCardContainer({
field,
children,
}: SinglePlayerModeFieldContainerProps) {
return (
<FieldContainerPortal field={field}>
<motion.div className="h-full w-full" animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Card
className="bg-background relative z-20 h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{children}
</CardContent>
</Card>
</motion.div>
</FieldContainerPortal>
);
}
export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSignature }) {
const fontVariable = '--font-signature';
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable,
);
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
if (!isSignatureFieldType(field.type)) {
throw new Error('Invalid field type');
}
const $paragraphEl = useRef<HTMLParagraphElement>(null);
const { height, width } = useFieldPageCoords(field);
const scalingFactor = useElementScaleSize(
{
height,
width,
},
$paragraphEl,
maxFontSize,
fontVariableValue,
);
const fontSize = maxFontSize * scalingFactor;
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
return (
<SinglePlayerModeFieldCardContainer field={field}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={
(insertedBase64Signature && 'base64Signature') ||
(insertedTypeSignature && 'typedSignature') ||
'not-inserted'
}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.3,
},
}}
exit={{ opacity: 0 }}
>
{insertedBase64Signature ? (
<img
src={insertedBase64Signature}
alt="Your signature"
className="h-full w-full object-contain"
/>
) : insertedTypeSignature ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
className="font-signature"
>
{insertedTypeSignature}
</p>
) : (
<p className="group-hover:text-primary text-muted-foreground duration-200">Signature</p>
)}
</motion.div>
</AnimatePresence>
</SinglePlayerModeFieldCardContainer>
);
}
export function SinglePlayerModeCustomTextField({ field }: { field: Field }) {
const fontVariable = '--font-sans';
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable,
);
const minFontSize = MIN_STANDARD_FONT_SIZE;
const maxFontSize = DEFAULT_STANDARD_FONT_SIZE;
if (isSignatureFieldType(field.type)) {
throw new Error('Invalid field type');
}
const $paragraphEl = useRef<HTMLParagraphElement>(null);
const { height, width } = useFieldPageCoords(field);
const scalingFactor = useElementScaleSize(
{
height,
width,
},
$paragraphEl,
maxFontSize,
fontVariableValue,
);
const fontSize = maxFontSize * scalingFactor;
return (
<SinglePlayerModeFieldCardContainer key="not-inserted" field={field}>
{field.inserted ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
>
{field.customText}
</p>
) : (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">
{match(field.type)
.with(FieldType.DATE, () => 'Date')
.with(FieldType.NAME, () => 'Name')
.with(FieldType.EMAIL, () => 'Email')
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
.otherwise(() => '')}
</p>
)}
</SinglePlayerModeFieldCardContainer>
);
}
const isSignatureFieldType = (fieldType: Field['type']) =>
fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE;

View File

@ -52,7 +52,6 @@ export interface DocumentFlowStep {
title: string;
description: string;
stepIndex: number;
onSubmit?: () => void;
onBackStep?: () => void;
onNextStep?: () => void;
}

View File

@ -27,6 +27,10 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
};
}, [target]);
useEffect(() => {
setVisible(!!document.querySelector(target));
}, [target]);
if (!visible) {
return null;
}

View File

@ -0,0 +1,192 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { AnimatePresence, motion } from 'framer-motion';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Label } from '../label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { required?: boolean }
>(({ className, ...props }, ref) => {
const { formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(
className,
// error && 'text-destructive', // Uncomment to apply styling on error.
)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<AnimatePresence>
<motion.div
initial={{
opacity: 0,
y: -10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: 10,
}}
>
<p
ref={ref}
id={formMessageId}
className={cn('text-xs text-red-500', className)}
{...props}
>
{body}
</p>
</motion.div>
</AnimatePresence>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -12,6 +12,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
},
)}
ref={ref}
{...props}

View File

@ -13,9 +13,13 @@ const labelVariants = cva(
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & { required?: boolean }
>(({ className, children, required, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props}>
{children}
{required && <span className="text-destructive ml-1 inline-block font-medium">*</span>}
</LabelPrimitive.Root>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@ -14,3 +14,10 @@ export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
</div>
),
});
/**
* LazyPDFViewer variant with no loader.
*/
export const LazyPDFViewerNoLoader = dynamic(async () => import('./pdf-viewer'), {
ssr: false,
});

View File

@ -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<React.HTMLAttributes<HTMLDivElement>, 'onPageClick'>;
export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => {
export const PDFViewer = ({
className,
document,
onDocumentLoad,
onPageClick,
...props
}: PDFViewerProps) => {
const $el = useRef<HTMLDivElement>(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={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
{pdfError ? (
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div>
) : (
<>
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
<p className="text-muted-foreground mt-4">Loading document...</p>
</>
)}
</div>
}
error={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
<div className="text-muted-foreground text-center">
<p>Something went wrong while loading the document.</p>
<p className="mt-1 text-sm">Please try again or contact our support.</p>
</div>
</div>
}
>
@ -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)}
/>
</div>