Merge pull request #403 from documenso/feat/single-player-mode

feat: single player mode
This commit is contained in:
Lucas Smith
2023-09-25 11:25:58 +10:00
committed by GitHub
103 changed files with 2912 additions and 437 deletions

View File

@ -6,7 +6,7 @@ NEXTAUTH_SECRET="secret"
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# [[APP]] # [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"

View File

@ -2,7 +2,7 @@
const path = require('path'); const path = require('path');
const { withContentlayer } = require('next-contentlayer'); const { withContentlayer } = require('next-contentlayer');
const { parsed: env } = require('dotenv').config({ require('dotenv').config({
path: path.join(__dirname, '../../.env.local'), path: path.join(__dirname, '../../.env.local'),
}); });
@ -10,9 +10,13 @@ const { parsed: env } = require('dotenv').config({
const config = { const config = {
experimental: { experimental: {
serverActions: true, serverActions: true,
serverActionsBodySizeLimit: '10mb',
}, },
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
},
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View File

@ -26,7 +26,9 @@
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
"posthog-js": "^1.77.3",
"react": "18.2.0", "react": "18.2.0",
"react-confetti": "^6.1.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",

View File

@ -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 { Footer } from '~/components/(marketing)/footer';
import { Header } from '~/components/(marketing)/header'; import { Header } from '~/components/(marketing)/header';
@ -8,15 +12,31 @@ export type MarketingLayoutProps = {
}; };
export default function MarketingLayout({ children }: 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 ( return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28"> <div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<div className="bg-background/50 fixed left-0 top-0 z-50 w-full backdrop-blur-md"> <div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,
})}
>
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" /> <Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
</div> </div>
<div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div> <div className="relative mx-auto max-w-screen-xl px-4 lg:px-8">{children}</div>
<Footer className="mt-24 bg-transparent backdrop-blur-[2px]" /> <Footer className="bg-background border-muted mt-24 border-t" />
</div> </div>
); );
} }

View File

@ -66,7 +66,7 @@ export default async function OpenPage() {
.then((res) => ZStargazersLiveResponse.parse(res)); .then((res) => ZStargazersLiveResponse.parse(res));
return ( return (
<div className="mx-auto mt-12 max-w-screen-lg"> <div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1> <h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>

View File

@ -37,7 +37,7 @@ export default async function OSSFriendsPage() {
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]" className="-mr-[15vw] -mt-[15vh] h-full max-h-[150vh] scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:-mr-[50vw] md:scale-150 lg:scale-[175%]"
/> />
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@ export type PricingPageProps = {
export default function PricingPage() { export default function PricingPage() {
return ( return (
<div className="mt-12"> <div className="mt-6 sm:mt-12">
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1> <h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>

View File

@ -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 <SinglePlayerModeSuccess document={document} />;
}

View File

@ -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<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
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 (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
View our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer document={uploadedFile.fileBase64} />
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
}

View File

@ -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 { Toaster } from '@documenso/ui/primitives/toaster';
import { ThemeProvider } from '~/providers/next-theme'; import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible'; import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css'; import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative', 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 ( return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning> <html
lang="en"
className={cn(fontInter.variable, fontCaveat.variable)}
suppressHydrationWarning
>
<head> <head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
@ -43,10 +56,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
</head> </head>
<Suspense>
<PostHogPageview />
</Suspense>
<body> <body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>{children}</PlausibleProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
</ThemeProvider> <PlausibleProvider>{children}</PlausibleProvider>
</ThemeProvider>
</FeatureFlagProvider>
<Toaster /> <Toaster />
</body> </body>

View File

@ -26,7 +26,7 @@ export default function NotFound() {
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:invert dark:sepia md:scale-100 lg:scale-[100%]" className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
priority priority
/> />
</motion.div> </motion.div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = {
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams(); const params = useSearchParams();
const { toast } = useToast(); const analytics = useAnalytics();
const event = usePlausible(); const event = usePlausible();
const { toast } = useToast();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const { const {
@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
]); ]);
event('claim-plan-pricing'); event('claim-plan-pricing');
analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl; window.location.href = redirectUrl;
} catch (error) { } catch (error) {
event('claim-plan-failed'); event('claim-plan-failed');
analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',

View File

@ -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<typeof Confetti> & { 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(
<Confetti
{...props}
className="w-full"
numberOfPieces={numberOfPieces}
width={width}
height={height}
/>,
document.body,
);
}

View File

@ -22,7 +22,7 @@ export const FasterSmarterBeautifulBento = ({
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="h-full scale-125 object-cover dark:invert dark:sepia md:scale-150 lg:scale-[175%]" className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
/> />
</div> </div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24"> <h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -20,6 +20,7 @@ const SOCIAL_LINKS = [
const FOOTER_LINKS = [ const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' }, { href: '/pricing', text: 'Pricing' },
{ href: '/single-player-mode', text: 'Single Player Mode' },
{ href: '/blog', text: 'Blog' }, { href: '/blog', text: 'Blog' },
{ href: '/open', text: 'Open' }, { href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },

View File

@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { HamburgerMenu } from './mobile-hamburger'; import { HamburgerMenu } from './mobile-hamburger';
@ -15,17 +16,32 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
export const Header = ({ className, ...props }: HeaderProps) => { export const Header = ({ className, ...props }: HeaderProps) => {
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const { getFlag } = useFeatureFlags();
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
return ( return (
<header className={cn('flex items-center justify-between', className)} {...props}> <header className={cn('flex items-center justify-between', className)} {...props}>
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}> <div className="flex items-center space-x-4">
<Image <Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
src="/logo.png" <Image
alt="Documenso Logo" src="/logo.png"
className="dark:invert" alt="Documenso Logo"
width={170} className="dark:invert"
height={25} width={170}
/> height={25}
</Link> />
</Link>
{isSinglePlayerModeMarketingEnabled && (
<Link
href="/single-player-mode"
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Try now!
</Link>
)}
</div>
<div className="hidden items-center gap-x-6 md:flex"> <div className="hidden items-center gap-x-6 md:flex">
<Link <Link

View File

@ -6,7 +6,9 @@ import Link from 'next/link';
import { Variants, motion } from 'framer-motion'; import { Variants, motion } from 'framer-motion';
import { Github } from 'lucide-react'; import { Github } from 'lucide-react';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
import { match } from 'ts-pattern';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -51,6 +53,10 @@ const HeroTitleVariants: Variants = {
export const Hero = ({ className, ...props }: HeroProps) => { export const Hero = ({ className, ...props }: HeroProps) => {
const event = usePlausible(); const event = usePlausible();
const { getFlag } = useFeatureFlags();
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
const onSignUpClick = () => { const onSignUpClick = () => {
const el = document.getElementById('email'); const el = document.getElementById('email');
@ -80,7 +86,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:invert dark:sepia md:scale-150 lg:scale-[175%]" className="-mr-[50vw] -mt-[15vh] h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
/> />
</motion.div> </motion.div>
</div> </div>
@ -109,7 +115,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
onClick={onSignUpClick} onClick={onSignUpClick}
> >
Get the Community Plan Get the Community Plan
<span className="bg-primary dark:text-background -mr-2 ml-2.5 rounded-full px-2 py-1.5 text-xs"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
$30/mo. forever! $30/mo. forever!
</span> </span>
</Button> </Button>
@ -122,23 +128,45 @@ export const Hero = ({ className, ...props }: HeroProps) => {
</Link> </Link>
</motion.div> </motion.div>
<motion.div {match(heroMarketingCTA)
variants={HeroTitleVariants} .with('spm', () => (
initial="initial" <motion.div
animate="animate" variants={HeroTitleVariants}
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4" initial="initial"
> animate="animate"
<Link className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso" >
target="_blank" <Link href="/single-player-mode" className="block px-4 py-2 text-center">
> <h2 className="text-muted-foreground text-xs font-semibold">
<img Introducing Single Player Mode
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily" </h2>
alt="Documenso - The open source DocuSign alternative | Product Hunt"
style={{ width: '250px', height: '54px' }} <h1 className="text-foreground mt-1.5 font-medium leading-5">
/> Self sign for free!
</Link> </h1>
</motion.div> </Link>
</motion.div>
))
.with('productHunt', () => (
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
>
<Link
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
alt="Documenso - The open source DocuSign alternative | Product Hunt"
style={{ width: '250px', height: '54px' }}
/>
</Link>
</motion.div>
))
.otherwise(() => null)}
<motion.div <motion.div
className="mt-12" className="mt-12"

View File

@ -14,6 +14,10 @@ export type MobileNavigationProps = {
}; };
export const MENU_NAVIGATION_LINKS = [ export const MENU_NAVIGATION_LINKS = [
{
href: '/single-player-mode',
text: 'Single Player Mode',
},
{ {
href: '/blog', href: '/blog',
text: 'Blog', text: 'Blog',

View File

@ -19,7 +19,7 @@ export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplat
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="h-full scale-125 object-cover dark:invert dark:sepia md:scale-150 lg:scale-[175%]" className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
/> />
</div> </div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24"> <h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -23,7 +23,7 @@ export const ShareConnectPaidWidgetBento = ({
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="h-full scale-125 object-cover dark:invert dark:sepia md:scale-150 lg:scale-[175%]" className="h-full scale-125 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-150 lg:scale-[175%]"
/> />
</div> </div>
<h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24"> <h2 className="px-0 text-[22px] font-semibold md:px-12 md:text-4xl lg:px-24">

View File

@ -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<typeof ZCreateSinglePlayerDocumentSchema>;
/**
* Create and self signs a document.
*
* Returns the document token.
*/
export const createSinglePlayerDocument = async (
value: TCreateSinglePlayerDocumentSchema,
): Promise<string> => {
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,
};
};

View File

@ -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<string | null>(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 (
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
{isConfettiEnabled && (
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
)}
<h2 className="text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
You have signed
<span className="mt-2 block">{document.title}</span>
</h2>
<SigningCard3D
className="mt-8"
name={document.Recipient.name || document.Recipient.email}
signingCelebrationImage={signingCelebration}
/>
<div className="mt-8 w-full">
<div className={cn('flex flex-col items-center', className)}>
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1" disabled>
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
<Button
onClick={async () => onShowDocumentClick()}
loading={isFetchingDocumentFile}
className="col-span-2"
>
Show document
</Button>
</div>
</div>
</div>
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>
free account
</Link>{' '}
to access your signed documents at any time
</p>
<DocumentDialog
document={documentFile ?? ''}
open={showDocumentDialog}
onOpenChange={setShowDocumentDialog}
/>
</div>
);
};

View File

@ -0,0 +1,7 @@
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
export const config = {
runtime: 'edge',
};
export default handlerFeatureFlagAll;

View File

@ -0,0 +1,7 @@
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
export const config = {
runtime: 'edge',
};
export default handlerFeatureFlagGet;

View File

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

View File

@ -2,7 +2,7 @@
const path = require('path'); const path = require('path');
const { version } = require('./package.json'); const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({ require('dotenv').config({
path: path.join(__dirname, '../../.env.local'), path: path.join(__dirname, '../../.env.local'),
}); });
@ -22,6 +22,7 @@ const config = {
], ],
env: { env: {
APP_VERSION: version, APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

View File

@ -55,21 +55,18 @@ export const EditDocumentForm = ({
title: 'Add Signers', title: 'Add Signers',
description: 'Add the people who will sign the document.', description: 'Add the people who will sign the document.',
stepIndex: 1, stepIndex: 1,
onSubmit: () => onAddSignersFormSubmit,
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 2, stepIndex: 2,
onBackStep: () => setStep('signers'), onBackStep: () => setStep('signers'),
onSubmit: () => onAddFieldsFormSubmit,
}, },
subject: { subject: {
title: 'Add Subject', title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.', description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3, stepIndex: 3,
onBackStep: () => setStep('fields'), onBackStep: () => setStep('fields'),
onSubmit: () => onAddSubjectFormSubmit,
}, },
}; };

View File

@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-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 { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const user = await getRequiredServerComponentSession(); const user = await getRequiredServerComponentSession();

View File

@ -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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client'; 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 { ShareButton } from './share-button';
import { SigningCard } from './signing-card';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
params: { params: {
@ -53,7 +55,7 @@ export default async function CompletedSigningPage({
return ( return (
<div className="flex flex-col items-center pt-24"> <div className="flex flex-col items-center pt-24">
{/* Card with recipient */} {/* Card with recipient */}
<SigningCard name={recipientName} /> <SigningCard name={recipientName} signingCelebrationImage={signingCelebration} />
<div className="mt-6"> <div className="mt-6">
{match(document.status) {match(document.status)
@ -90,7 +92,7 @@ export default async function CompletedSigningPage({
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<ShareButton documentId={document.id} token={recipient.token} /> <ShareButton documentId={document.id} token={recipient.token} />
<DownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1"
fileName={document.title} fileName={document.title}
documentData={documentData} documentData={documentData}
@ -99,7 +101,7 @@ export default async function CompletedSigningPage({
</div> </div>
<p className="text-muted-foreground/60 mt-36 text-sm"> <p className="text-muted-foreground/60 mt-36 text-sm">
Want so send slick signing links like this one?{' '} Want to send slick signing links like this one?{' '}
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600"> <Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
Check out Documenso. Check out Documenso.
</Link> </Link>

View File

@ -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 (
<div className="relative w-full max-w-xs md:max-w-sm">
<Card
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
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>
<motion.div
className="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={signingCelebration}
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>
</div>
);
};

View File

@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div> </div>
)} )}

View File

@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div> </div>
)} )}

View File

@ -1,12 +1,15 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; 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 { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; 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 { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { const {
handleSubmit, handleSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm(); } = useForm();
const isComplete = fields.every((f) => f.inserted); const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]);
const onFormSubmit = async () => { const onFormSubmit = async () => {
if (!isComplete) { setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
return; return;
} }
@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
)} )}
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}> {validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<fieldset
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div className={cn('flex flex-1 flex-col')}> <div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> <h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
@ -106,19 +125,13 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Cancel Cancel
</Button> </Button>
<Button <Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
className="w-full"
type="submit"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Complete Complete
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </fieldset>
</form> </form>
); );
}; };

View File

@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div> </div>
)} )}

View File

@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div> </div>
)} )}

View File

@ -3,10 +3,7 @@
import React from 'react'; import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
export type SignatureFieldProps = { export type SignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
@ -23,8 +20,6 @@ export const SigningFieldContainer = ({
onRemove, onRemove,
children, children,
}: SignatureFieldProps) => { }: SignatureFieldProps) => {
const coords = useFieldPageCoords(field);
const onSignFieldClick = async () => { const onSignFieldClick = async () => {
if (field.inserted) { if (field.inserted) {
return; return;
@ -42,40 +37,25 @@ export const SigningFieldContainer = ({
}; };
return ( return (
<div <FieldRootContainer field={field}>
className="absolute" {!field.inserted && !loading && (
style={{ <button
top: `${coords.y}px`, type="submit"
left: `${coords.x}px`, className="absolute inset-0 z-10 h-full w-full"
height: `${coords.height}px`, onClick={onSignFieldClick}
width: `${coords.width}px`, />
}} )}
>
<Card {field.inserted && !loading && (
className="bg-background relative h-full w-full" <button
data-inserted={field.inserted ? 'true' : 'false'} className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
> onClick={onRemoveSignedFieldClick}
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
> >
{!field.inserted && !loading && ( Remove
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} /> </button>
)} )}
{field.inserted && !loading && ( {children}
<button </FieldRootContainer>
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
); );
}; };

View File

@ -16,7 +16,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="dark:brightness-95 dark:invert dark:sepia" className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/> />
</div> </div>

View File

@ -2,15 +2,15 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; 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 { 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 { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster'; import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; 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 { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible'; import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog'; import { PostHogPageview } from '~/providers/posthog';

View File

@ -17,6 +17,7 @@ import {
import { signOut } from 'next-auth/react'; import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes'; 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 { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client'; import { User } from '@documenso/prisma/client';
@ -37,8 +38,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { useFeatureFlags } from '~/providers/feature-flag';
export type ProfileDropdownProps = { export type ProfileDropdownProps = {
user: User; user: User;
}; };

View File

@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react'; 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>; export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {

View File

@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react'; 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type MobileNavProps = HTMLAttributes<HTMLDivElement>; export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
export const MobileNav = ({ className, ...props }: MobileNavProps) => { export const MobileNav = ({ className, ...props }: MobileNavProps) => {

View File

@ -29,7 +29,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) {
<Image <Image
src={backgroundPattern} src={backgroundPattern}
alt="background pattern" alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]" className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
priority priority
/> />
</motion.div> </motion.div>

View File

@ -1,44 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
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';
export const config = { export const config = {
runtime: 'edge', runtime: 'edge',
}; };
/** export default handlerFeatureFlagAll;
* 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;
}

View File

@ -1,122 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
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';
export const config = { export const config = {
runtime: 'edge', runtime: 'edge',
}; };
/** export default handlerFeatureFlagGet;
* 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<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;
};

59
package-lock.json generated
View File

@ -49,7 +49,9 @@
"next-contentlayer": "^0.3.4", "next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0", "perfect-freehand": "^1.2.0",
"posthog-js": "^1.77.3",
"react": "18.2.0", "react": "18.2.0",
"react-confetti": "^6.1.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
@ -2445,9 +2447,9 @@
} }
}, },
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.1.1", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
"integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==", "integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==",
"peerDependencies": { "peerDependencies": {
"react-hook-form": "^7.0.0" "react-hook-form": "^7.0.0"
} }
@ -6387,9 +6389,9 @@
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
}, },
"node_modules/@types/luxon": { "node_modules/@types/luxon": {
"version": "3.3.1", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.2.tgz",
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==", "integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==",
"dev": true "dev": true
}, },
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
@ -12868,9 +12870,9 @@
} }
}, },
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.4.0", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==", "integrity": "sha512-uBoAVCVcajsrqy3pv7eo5jEUz1oeLmCcnMv8n4AJpT5hbpN9lUssAXibNElpbLce3Mhm9dyBzwYLs9zctM/0tA==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -15239,9 +15241,9 @@
} }
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.75.3", "version": "1.77.3",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.75.3.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.77.3.tgz",
"integrity": "sha512-q5xP4R/Tx8E6H0goZQjY+URMLATFiYXc2raHA+31aNvpBs118fPTmExa4RK6MgRZDFhBiMUBZNT6aj7dM3SyUQ==", "integrity": "sha512-DKsGpBIUjQSihhGruEW8wpVCkeDxU4jz7gADdXX2jEWV6bl4WpUPxjo1ukidVDFvvc/ihCM5PQWMQrItexdpSA==",
"dependencies": { "dependencies": {
"fflate": "^0.4.1" "fflate": "^0.4.1"
} }
@ -15755,6 +15757,20 @@
"node": ">=0.10.0" "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": { "node_modules/react-day-picker": {
"version": "8.8.0", "version": "8.8.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz",
@ -16285,9 +16301,9 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.45.2", "version": "7.45.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
"integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==", "integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==",
"engines": { "engines": {
"node": ">=12.22.0" "node": ">=12.22.0"
}, },
@ -18919,6 +18935,11 @@
"node": ">= 6" "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": { "node_modules/typanion": {
"version": "3.13.0", "version": "3.13.0",
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz", "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz",
@ -19847,6 +19868,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2", "@radix-ui/react-aspect-ratio": "^1.0.2",
@ -19867,27 +19889,32 @@
"@radix-ui/react-select": "^1.2.1", "@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2", "@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", "@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "13.4.19", "next": "13.4.19",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3", "react-pdf": "^7.3.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5" "tailwindcss-animate": "^1.0.5"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@types/luxon": "^3.3.2",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"react": "18.2.0", "react": "18.2.0",

View File

@ -4,14 +4,12 @@ import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentCompletedProps { export interface TemplateDocumentCompletedProps {
downloadLink: string; downloadLink: string;
reviewLink: string;
documentName: string; documentName: string;
assetBaseUrl: string; assetBaseUrl: string;
} }
export const TemplateDocumentCompleted = ({ export const TemplateDocumentCompleted = ({
downloadLink, downloadLink,
reviewLink,
documentName, documentName,
assetBaseUrl, assetBaseUrl,
}: TemplateDocumentCompletedProps) => { }: TemplateDocumentCompletedProps) => {
@ -56,17 +54,17 @@ export const TemplateDocumentCompleted = ({
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document. Continue by downloading the document.
</Text> </Text>
<Section className="mb-6 mt-8 text-center"> <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" 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} href={reviewLink}
> >
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" /> <Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Review Review
</Button> </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" 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} 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 = ({ export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com', downloadLink = 'https://documenso.com',
reviewLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf', documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
}: DocumentCompletedEmailTemplateProps) => { }: DocumentCompletedEmailTemplateProps) => {
@ -56,7 +55,6 @@ export const DocumentCompletedEmailTemplate = ({
<TemplateDocumentCompleted <TemplateDocumentCompleted
downloadLink={downloadLink} downloadLink={downloadLink}
reviewLink={reviewLink}
documentName={documentName} documentName={documentName}
assetBaseUrl={assetBaseUrl} 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

@ -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,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,11 @@
import { useEffect, useState } from 'react';
export const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
};

View File

@ -7,8 +7,7 @@ import {
LOCAL_FEATURE_FLAGS, LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled, isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags'; } from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { getAllFlags } from '~/helpers/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types'; import { TFeatureFlagValue } from './feature-flag.types';

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

@ -18,8 +18,6 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
console.log({ assetBaseUrl });
const template = createElement(ResetPasswordTemplate, { const template = createElement(ResetPasswordTemplate, {
assetBaseUrl, assetBaseUrl,
userEmail: user.email, userEmail: user.email,

View File

@ -1,12 +1,21 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
export interface GetDocumentAndSenderByTokenOptions { export interface GetDocumentAndSenderByTokenOptions {
token: string; token: string;
} }
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
}
export const getDocumentAndSenderByToken = async ({ export const getDocumentAndSenderByToken = async ({
token, token,
}: GetDocumentAndSenderByTokenOptions) => { }: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({ const result = await prisma.document.findFirstOrThrow({
where: { where: {
Recipient: { Recipient: {
@ -29,3 +38,33 @@ export const getDocumentAndSenderByToken = async ({
User, 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 { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; 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 { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
@ -76,8 +77,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
name, name,
}, },
from: { from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', name: FROM_NAME,
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', address: FROM_ADDRESS,
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)

View File

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

View File

@ -1,6 +1,6 @@
import { headers } from 'next/headers'; 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. * Evaluate whether a flag is enabled for the current user in a server component.

View File

@ -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<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 fontkit from '@pdf-lib/fontkit';
import { readFileSync } from 'fs';
import { PDFDocument, StandardFonts } from 'pdf-lib'; 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 { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; 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) => { 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); const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit); pdf.registerFontkit(fontkit);
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
const pages = pdf.getPages(); 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; 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); const page = pages.at(field.page - 1);
@ -50,11 +56,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let imageWidth = image.width; let imageWidth = image.width;
let imageHeight = image.height; let imageHeight = image.height;
// const initialDimensions = {
// width: imageWidth,
// height: imageHeight,
// };
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1); const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor; imageWidth = imageWidth * scalingFactor;
@ -76,14 +77,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
let textWidth = font.widthOfTextAtSize(field.customText, fontSize); let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize); const textHeight = font.heightAtSize(fontSize);
// const initialDimensions = {
// width: textWidth,
// height: textHeight,
// };
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1); 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); textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2; const textX = fieldX + (fieldWidth - textWidth) / 2;

View File

@ -1,7 +1,8 @@
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { CAVEAT_FONT_PATH } from '../../constants/pdf';
export async function insertTextInPDF( export async function insertTextInPDF(
pdfAsBase64: string, pdfAsBase64: string,
text: string, text: string,
@ -10,13 +11,15 @@ export async function insertTextInPDF(
page = 0, page = 0,
useHandwritingFont = true, useHandwritingFont = true,
): Promise<string> { ): 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); const pdfDoc = await PDFDocument.load(pdfAsBase64);
pdfDoc.registerFontkit(fontkit); 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 pages = pdfDoc.getPages();
const pdfPage = pages[page]; const pdfPage = pages[page];

View File

@ -0,0 +1 @@
export * from '@scure/base';

View File

@ -1,9 +1,12 @@
import { z } from 'zod'; 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 { 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. * Evaluate whether a flag is enabled for the current user.
* *
@ -21,7 +24,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true; 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); url.searchParams.set('flag', flag);
const response = await fetch(url, { const response = await fetch(url, {
@ -54,7 +57,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS; 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, { return fetch(url, {
headers: { headers: {
@ -69,6 +72,28 @@ export const getAllFlags = async (
.catch(() => LOCAL_FEATURE_FLAGS); .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 { interface GetFlagOptions {
/** /**
* The headers to attach to the request to evaluate flags. * The headers to attach to the request to evaluate flags.

View File

@ -1,5 +1,5 @@
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10); export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
export { nanoid } from 'nanoid'; export { nanoid } from 'nanoid';

View File

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

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[]; 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

@ -15,7 +15,7 @@ export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
documentData?: DocumentData; documentData?: DocumentData;
}; };
export const DownloadButton = ({ export const DocumentDownloadButton = ({
className, className,
fileName, fileName,
documentData, documentData,

View File

@ -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<typeof tooltipVariants> {
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(
<div
className={cn('absolute')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<TooltipProvider>
<Tooltip delayDuration={0} open={!field.inserted}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>,
document.body,
);
}

View File

@ -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(
<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 FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(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 (
<FieldContainerPortal field={field}>
<Card
id={`field-${field.id}`}
className={cn(
'field-card-container bg-background relative z-20 h-full w-full transition-all',
{
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
},
)}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
</FieldContainerPortal>
);
}

View File

@ -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 (
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
<SigningCardContent name={name} />
{signingCelebrationImage && (
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
)}
</div>
);
};
/**
* 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<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(var(--sheen-color) / ${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);
} 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 (
<div
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
style={{ perspective: 800 }}
>
<motion.div
className="bg-background w-full [--sheen-color:180_180_180] dark:[--sheen-color:200_200_200]"
ref={cardRef}
style={{
perspective: '800',
backgroundImage: sheenGradient,
transformStyle: 'preserve-3d',
rotateX,
rotateY,
// willChange: 'transform background-image',
}}
>
<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 dark:invert dark:sepia"
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

@ -30,3 +30,5 @@ export const SignatureIcon: LucideIcon = forwardRef(
); );
}, },
); );
SignatureIcon.displayName = 'SignatureIcon';

View File

@ -18,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"@types/luxon": "^3.3.2",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",
"react": "18.2.0", "react": "18.2.0",
@ -25,6 +26,7 @@
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@hookform/resolvers": "^3.3.0",
"@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2", "@radix-ui/react-aspect-ratio": "^1.0.2",
@ -45,21 +47,25 @@
"@radix-ui/react-select": "^1.2.1", "@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2", "@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", "@tanstack/react-table": "^8.9.1",
"class-variance-authority": "^0.6.0", "class-variance-authority": "^0.6.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "13.4.19", "next": "13.4.19",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4",
"react-pdf": "^7.3.3", "react-pdf": "^7.3.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5" "tailwindcss-animate": "^1.0.5"
} }

View File

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

View File

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

View File

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

View File

@ -256,17 +256,28 @@ export const AddFieldsFormPartial = ({
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => { 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) { if (!$page) {
return; return;
} }
const { height, width } = $page.getBoundingClientRect(); const { height, width } = $page.getBoundingClientRect();
fieldBounds.current = { fieldBounds.current = {
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_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> </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"> <div className="grid grid-cols-2 gap-x-4 gap-y-8">
<button <button
type="button" type="button"
@ -505,7 +516,10 @@ export const AddFieldsFormPartial = ({
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep} onGoBackClick={() => {
documentFlow.onBackStep?.();
remove();
}}
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -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> | 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<TAddSignatureFormSchema>({
resolver: zodResolver(refinedSchema),
defaultValues: defaultValues ?? {
name: '',
email: '',
signature: '',
},
});
/**
* A local copy of the provided fields to modify.
*/
const [localFields, setLocalFields] = useState<Field[]>(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<boolean> => {
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 (
<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}
onChange={(value) => {
onFormValueChange(FieldType.EMAIL);
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{requireName && (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required={requireName}>Name</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
onChange={(value) => {
onFormValueChange(FieldType.NAME);
field.onChange(value);
}}
/>
</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}
onBlur={field.onBlur}
onChange={(value) => {
onFormValueChange(FieldType.SIGNATURE);
field.onChange(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={form.handleSubmit(onValidateFields)}
/>
</DocumentFlowFormContainerFooter>
</fieldset>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field) =>
match(field.type)
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => {
return (
<SinglePlayerModeCustomTextField
onClick={insertField(field)}
key={field.id}
field={field}
/>
);
})
.with(FieldType.SIGNATURE, () => (
<SinglePlayerModeSignatureField
onClick={insertField(field)}
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 = ({ export const DocumentFlowFormContainer = ({
children, children,
id = 'edit-document-form', id = 'document-flow-form-container',
className, className,
...props ...props
}: DocumentFlowFormContainerProps) => { }: DocumentFlowFormContainerProps) => {
@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
<form <form
id={id} id={id}
className={cn( className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6', 'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[64rem] flex-col rounded-xl border px-4 py-6',
className, className,
)} )}
{...props} {...props}
@ -152,10 +152,11 @@ export const DocumentFlowFormContainerActions = ({
</Button> </Button>
<Button <Button
type="submit" type="button"
className="bg-documenso flex-1" className="bg-documenso flex-1"
size="lg" size="lg"
disabled={disabled || loading || !canGoNext} disabled={disabled || loading || !canGoNext}
loading={loading}
onClick={onGoNextClick} onClick={onGoNextClick}
> >
{goNextLabel} {goNextLabel}

View File

@ -0,0 +1,185 @@
'use client';
import React, { useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
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 { FieldRootContainer } from '@documenso/ui/components/field/field';
export type SinglePlayerModeFieldContainerProps = {
field: FieldWithSignature;
children: React.ReactNode;
};
export type SinglePlayerModeFieldProps<T> = {
field: T;
onClick?: () => void;
};
export function SinglePlayerModeFieldCardContainer({
field,
children,
}: SinglePlayerModeFieldContainerProps) {
return (
<FieldRootContainer field={field}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={field.inserted ? 'inserted' : 'not-inserted'}
className="flex items-center justify-center p-2"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
}}
exit={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: 'easeIn',
}}
>
{children}
</motion.div>
</AnimatePresence>
</FieldRootContainer>
);
}
export function SinglePlayerModeSignatureField({
field,
onClick,
}: SinglePlayerModeFieldProps<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}>
{insertedBase64Signature ? (
<img
src={insertedBase64Signature}
alt="Your signature"
className="h-full max-w-full object-contain dark:invert"
/>
) : insertedTypeSignature ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
className="font-signature"
>
{insertedTypeSignature}
</p>
) : (
<button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full duration-200"
>
Signature
</button>
)}
</SinglePlayerModeFieldCardContainer>
);
}
export function SinglePlayerModeCustomTextField({
field,
onClick,
}: SinglePlayerModeFieldProps<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 field={field}>
{field.inserted ? (
<p
ref={$paragraphEl}
style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`,
}}
>
{field.customText}
</p>
) : (
<button
onClick={() => onClick?.()}
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full 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(() => '')}
</button>
)}
</SinglePlayerModeFieldCardContainer>
);
}
const isSignatureFieldType = (fieldType: Field['type']) =>
fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE;

View File

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

View File

@ -27,6 +27,10 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
}; };
}, [target]); }, [target]);
useEffect(() => {
setVisible(!!document.querySelector(target));
}, [target]);
if (!visible) { if (!visible) {
return null; 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( 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', '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, className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
},
)} )}
ref={ref} ref={ref}
{...props} {...props}

View File

@ -13,9 +13,13 @@ const labelVariants = cva(
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
>(({ className, ...props }, ref) => ( VariantProps<typeof labelVariants> & { required?: boolean }
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> >(({ 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; Label.displayName = LabelPrimitive.Root.displayName;

View File

@ -14,3 +14,10 @@ export const LazyPDFViewer = dynamic(async () => import('./pdf-viewer'), {
</div> </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/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.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'; import { cn } from '@documenso/ui/lib/utils';
export type LoadedPDFDocument = PDFDocumentProxy; export type LoadedPDFDocument = PDFDocumentProxy;
@ -30,18 +31,27 @@ export type OnPDFViewerPageClick = (_event: {
export type PDFViewerProps = { export type PDFViewerProps = {
className?: string; className?: string;
document: string; document: string;
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick; onPageClick?: OnPDFViewerPageClick;
[key: string]: unknown; [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 $el = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
const [pdfError, setPdfError] = useState(false);
const onDocumentLoaded = (doc: LoadedPDFDocument) => { const onDocumentLoaded = (doc: LoadedPDFDocument) => {
setNumPages(doc.numPages); setNumPages(doc.numPages);
onDocumentLoad?.(doc);
}; };
const onDocumentPageClick = ( const onDocumentPageClick = (
@ -54,7 +64,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
return; return;
} }
const $page = $el.closest('.react-pdf__Page'); const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) { if (!$page) {
return; return;
@ -108,12 +118,34 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
'h-[80vh] max-h-[60rem]': numPages === 0, 'h-[80vh] max-h-[60rem]': numPages === 0,
})} })}
onLoadSuccess={(d) => onDocumentLoaded(d)} 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" externalLinkTarget="_blank"
loading={ loading={
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50"> <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> </div>
} }
> >
@ -129,6 +161,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie
width={width} width={width}
renderAnnotationLayer={false} renderAnnotationLayer={false}
renderTextLayer={false} renderTextLayer={false}
loading={() => ''}
onClick={(e) => onDocumentPageClick(e, i + 1)} onClick={(e) => onDocumentPageClick(e, i + 1)}
/> />
</div> </div>

View File

@ -14,6 +14,7 @@ function log() {
function build_webapp() { function build_webapp() {
log "Building webapp for $VERCEL_ENV" log "Building webapp for $VERCEL_ENV"
remap_webapp_env
remap_database_integration remap_database_integration
npm run prisma:generate --workspace=@documenso/prisma npm run prisma:generate --workspace=@documenso/prisma
@ -40,6 +41,7 @@ function remap_webapp_env() {
function build_marketing() { function build_marketing() {
log "Building marketing for $VERCEL_ENV" log "Building marketing for $VERCEL_ENV"
remap_marketing_env
remap_database_integration remap_database_integration
npm run prisma:generate --workspace=@documenso/prisma 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" export NEXT_PRIVATE_DIRECT_DATABASE_URL="$POSTGRES_URL_NON_POOLING"
fi fi
if [[ "$NEXT_PRIVATE_DATABASE_URL" == *"neon.tech"* ]]; then if [[ "$NEXT_PRIVATE_DATABASE_URL" == *"neon.tech"* ]]; then
log "Remapping for Neon integration" log "Remapping for Neon integration"

Some files were not shown because too many files have changed in this diff Show More