mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
Merge pull request #403 from documenso/feat/single-player-mode
feat: single player mode
This commit is contained in:
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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 }}',
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
}
|
||||||
244
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
244
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<PlausibleProvider>{children}</PlausibleProvider>
|
<PlausibleProvider>{children}</PlausibleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</FeatureFlagProvider>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
BIN
apps/marketing/src/assets/signing-celebration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 MiB |
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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,8 +16,13 @@ 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}>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
@ -27,6 +33,16 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
|||||||
/>
|
/>
|
||||||
</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
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@ -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,6 +128,26 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{match(heroMarketingCTA)
|
||||||
|
.with('spm', () => (
|
||||||
|
<motion.div
|
||||||
|
variants={HeroTitleVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
|
||||||
|
>
|
||||||
|
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||||
|
<h2 className="text-muted-foreground text-xs font-semibold">
|
||||||
|
Introducing Single Player Mode
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h1 className="text-foreground mt-1.5 font-medium leading-5">
|
||||||
|
Self sign for free!
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
.with('productHunt', () => (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={HeroTitleVariants}
|
variants={HeroTitleVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
@ -139,6 +165,8 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-12"
|
className="mt-12"
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
7
apps/marketing/src/pages/api/feature-flag/all.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagAll;
|
||||||
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
7
apps/marketing/src/pages/api/feature-flag/get.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handlerFeatureFlagGet;
|
||||||
39
apps/marketing/src/providers/posthog.tsx
Normal file
39
apps/marketing/src/providers/posthog.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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': {
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,31 +37,18 @@ export const SigningFieldContainer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FieldRootContainer field={field}>
|
||||||
className="absolute"
|
|
||||||
style={{
|
|
||||||
top: `${coords.y}px`,
|
|
||||||
left: `${coords.x}px`,
|
|
||||||
height: `${coords.height}px`,
|
|
||||||
width: `${coords.width}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className="bg-background relative h-full w-full"
|
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
className={cn(
|
|
||||||
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!field.inserted && !loading && (
|
{!field.inserted && !loading && (
|
||||||
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="absolute inset-0 z-10 h-full w-full"
|
||||||
|
onClick={onSignFieldClick}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && !loading && (
|
{field.inserted && !loading && (
|
||||||
<button
|
<button
|
||||||
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"
|
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}
|
onClick={onRemoveSignedFieldClick}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@ -74,8 +56,6 @@ export const SigningFieldContainer = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</FieldRootContainer>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
59
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
74
packages/email/templates/document-self-signed.tsx
Normal file
74
packages/email/templates/document-self-signed.tsx
Normal 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;
|
||||||
61
packages/lib/client-only/hooks/use-analytics.ts
Normal file
61
packages/lib/client-only/hooks/use-analytics.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal file
85
packages/lib/client-only/hooks/use-element-scale-size.ts
Normal 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;
|
||||||
|
}
|
||||||
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
11
packages/lib/client-only/hooks/use-is-mounted.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const useIsMounted = () => {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMounted;
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|
||||||
8
packages/lib/constants/app.ts
Normal file
8
packages/lib/constants/app.ts
Normal 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;
|
||||||
4
packages/lib/constants/email.ts
Normal file
4
packages/lib/constants/email.ts
Normal 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';
|
||||||
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
9
packages/lib/constants/pdf.ts
Normal file
9
packages/lib/constants/pdf.ts
Normal 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`;
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
51
packages/lib/server-only/feature-flags/all.ts
Normal file
51
packages/lib/server-only/feature-flags/all.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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.
|
||||||
129
packages/lib/server-only/feature-flags/get.ts
Normal file
129
packages/lib/server-only/feature-flags/get.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
1
packages/lib/universal/base64.ts
Normal file
1
packages/lib/universal/base64.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from '@scure/base';
|
||||||
@ -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.
|
||||||
@ -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';
|
||||||
|
|||||||
41
packages/lib/utils/fields.ts
Normal file
41
packages/lib/utils/fields.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
INSERT INTO "User" ("email", "name") VALUES (
|
||||||
|
'serviceaccount@documenso.com',
|
||||||
|
'Service Account'
|
||||||
|
) ON CONFLICT DO NOTHING;
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
56
packages/ui/components/document/document-dialog.tsx
Normal file
56
packages/ui/components/document/document-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
63
packages/ui/components/field/field-tooltip.tsx
Normal file
63
packages/ui/components/field/field-tooltip.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
90
packages/ui/components/field/field.tsx
Normal file
90
packages/ui/components/field/field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
packages/ui/components/signing-card.tsx
Normal file
208
packages/ui/components/signing-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -30,3 +30,5 @@ export const SignatureIcon: LucideIcon = forwardRef(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
SignatureIcon.displayName = 'SignatureIcon';
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -109,6 +109,8 @@ export {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogOverlay,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogPortal,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -256,7 +256,8 @@ 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;
|
||||||
@ -268,6 +269,16 @@ export const AddFieldsFormPartial = ({
|
|||||||
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();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -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>
|
||||||
|
|||||||
344
packages/ui/primitives/document-flow/add-signature.tsx
Normal file
344
packages/ui/primitives/document-flow/add-signature.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>;
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
192
packages/ui/primitives/form/form.tsx
Normal file
192
packages/ui/primitives/form/form.tsx
Normal 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,
|
||||||
|
};
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@ -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">
|
||||||
|
{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" />
|
<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>
|
||||||
|
|||||||
@ -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
Reference in New Issue
Block a user