diff --git a/.env.example b/.env.example index fb22bbedf..3dc0985cb 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ NEXTAUTH_SECRET="secret" NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" -# [[APP]] +# [[URLS]] NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" diff --git a/apps/marketing/next.config.js b/apps/marketing/next.config.js index 2783e4063..e74f7d545 100644 --- a/apps/marketing/next.config.js +++ b/apps/marketing/next.config.js @@ -2,7 +2,7 @@ const path = require('path'); const { withContentlayer } = require('next-contentlayer'); -const { parsed: env } = require('dotenv').config({ +require('dotenv').config({ path: path.join(__dirname, '../../.env.local'), }); @@ -10,9 +10,13 @@ const { parsed: env } = require('dotenv').config({ const config = { experimental: { serverActions: true, + serverActionsBodySizeLimit: '10mb', }, reactStrictMode: true, transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], + env: { + NEXT_PUBLIC_PROJECT: 'marketing', + }, modularizeImports: { 'lucide-react': { transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 8e61ad51f..8ee8d3808 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -26,7 +26,9 @@ "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", + "posthog-js": "^1.77.3", "react": "18.2.0", + "react-confetti": "^6.1.0", "react-dom": "18.2.0", "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 688c484d9..36241e8e2 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -1,4 +1,8 @@ -import React from 'react'; +'use client'; + +import React, { useEffect, useState } from 'react'; + +import { cn } from '@documenso/ui/lib/utils'; import { Footer } from '~/components/(marketing)/footer'; import { Header } from '~/components/(marketing)/header'; @@ -8,15 +12,31 @@ export type MarketingLayoutProps = { }; export default function MarketingLayout({ children }: MarketingLayoutProps) { + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + const onScroll = () => { + setScrollY(window.scrollY); + }; + + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + return (
-
+
5, + })} + >
{children}
-
+
); } diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 2d5bc2aa4..37d390223 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -66,7 +66,7 @@ export default async function OpenPage() { .then((res) => ZStargazersLiveResponse.parse(res)); return ( -
+

Open Startup

diff --git a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx index 4de3de38e..38eec0938 100644 --- a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx +++ b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx @@ -37,7 +37,7 @@ export default async function OSSFriendsPage() { background pattern
diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index 925f2af66..5d9e623da 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -20,7 +20,7 @@ export type PricingPageProps = { export default function PricingPage() { return ( -
+

Pricing

diff --git a/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx b/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx new file mode 100644 index 000000000..6e02a470f --- /dev/null +++ b/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation'; + +import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { DocumentStatus } from '@documenso/prisma/client'; + +import { SinglePlayerModeSuccess } from '~/components/(marketing)/single-player-mode/single-player-mode-success'; + +export type SinglePlayerModeSuccessPageProps = { + params: { + token?: string; + }; +}; + +export default async function SinglePlayerModeSuccessPage({ + params: { token }, +}: SinglePlayerModeSuccessPageProps) { + if (!token) { + return notFound(); + } + + const document = await getDocumentAndRecipientByToken({ + token, + }).catch(() => null); + + if (!document || document.status !== DocumentStatus.COMPLETED) { + return notFound(); + } + + return ; +} diff --git a/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx new file mode 100644 index 000000000..3c76ebac0 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { base64 } from '@documenso/lib/universal/base64'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { Field, Prisma, Recipient } from '@documenso/prisma/client'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; +import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; +import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature'; +import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action'; + +type SinglePlayerModeStep = 'fields' | 'sign'; + +export default function SinglePlayerModePage() { + const analytics = useAnalytics(); + const router = useRouter(); + + const { toast } = useToast(); + + const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + + const [step, setStep] = useState('fields'); + const [fields, setFields] = useState([]); + + const documentFlow: Record = { + fields: { + title: 'Add document', + description: 'Upload a document and add fields.', + stepIndex: 1, + onBackStep: uploadedFile + ? () => { + setUploadedFile(null); + setFields([]); + } + : undefined, + onNextStep: () => setStep('sign'), + }, + sign: { + title: 'Sign', + description: 'Enter your details.', + stepIndex: 2, + onBackStep: () => setStep('fields'), + }, + }; + + const currentDocumentFlow = documentFlow[step]; + + useEffect(() => { + analytics.startSessionRecording('marketing_session_recording_spm'); + + return () => { + analytics.stopSessionRecording(); + }; + }, [analytics]); + + /** + * Insert the selected fields into the local state. + */ + const onFieldsSubmit = (data: TAddFieldsFormSchema) => { + if (!uploadedFile) { + return; + } + + setFields( + data.fields.map((field, i) => ({ + id: i, + documentId: -1, + recipientId: -1, + type: field.type, + page: field.pageNumber, + positionX: new Prisma.Decimal(field.pageX), + positionY: new Prisma.Decimal(field.pageY), + width: new Prisma.Decimal(field.pageWidth), + height: new Prisma.Decimal(field.pageHeight), + customText: '', + inserted: false, + })), + ); + + analytics.capture('Marketing: SPM - Fields added'); + + documentFlow.fields.onNextStep?.(); + }; + + /** + * Upload, create, sign and send the document. + */ + const onSignSubmit = async (data: TAddSignatureFormSchema) => { + if (!uploadedFile) { + return; + } + + try { + const putFileData = await putFile(uploadedFile.file); + + const documentToken = await createSinglePlayerDocument({ + documentData: { + type: putFileData.type, + data: putFileData.data, + }, + documentName: uploadedFile.file.name, + signer: data, + fields: fields.map((field) => ({ + page: field.page, + type: field.type, + positionX: field.positionX.toNumber(), + positionY: field.positionY.toNumber(), + width: field.width.toNumber(), + height: field.height.toNumber(), + })), + }); + + analytics.capture('Marketing: SPM - Document signed', { + signer: data.email, + }); + + router.push(`/single-player-mode/${documentToken}/success`); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const placeholderRecipient: Recipient = { + id: -1, + documentId: -1, + email: '', + name: '', + token: '', + expired: null, + signedAt: null, + readStatus: 'OPENED', + signingStatus: 'NOT_SIGNED', + sendStatus: 'NOT_SENT', + }; + + const onFileDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); + + setUploadedFile({ + file, + fileBase64: `data:application/pdf;base64,${base64String}`, + }); + + analytics.capture('Marketing: SPM - Document uploaded'); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + return ( +
+
+

Single Player Mode

+ +

+ View our{' '} + + community plan + {' '} + for exclusive features, including the ability to collaborate with multiple signers. +

+
+ +
+
+ {uploadedFile ? ( + + + + + + ) : ( + + )} +
+ +
+ e.preventDefault()}> + + + {/* Add fields to PDF page. */} + {step === 'fields' && ( +
+ +
+ )} + + {/* Enter user details and signature. */} + {step === 'sign' && ( + field.type === 'NAME'))} + requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))} + /> + )} +
+
+
+
+ ); +} diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 0f0c18187..f99050512 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -1,13 +1,20 @@ -import { Inter } from 'next/font/google'; +import { Suspense } from 'react'; +import { Caveat, Inter } from 'next/font/google'; + +import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; +import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag'; +import { cn } from '@documenso/ui/lib/utils'; import { Toaster } from '@documenso/ui/primitives/toaster'; import { ThemeProvider } from '~/providers/next-theme'; import { PlausibleProvider } from '~/providers/plausible'; +import { PostHogPageview } from '~/providers/posthog'; import './globals.css'; const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); +const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); export const metadata = { title: 'Documenso - The Open Source DocuSign Alternative', @@ -33,9 +40,15 @@ export const metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const flags = await getAllAnonymousFlags(); + return ( - + @@ -43,10 +56,16 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + + + + - - {children} - + + + {children} + + diff --git a/apps/marketing/src/app/not-found.tsx b/apps/marketing/src/app/not-found.tsx index 9cfba7af9..0adc2e0ae 100644 --- a/apps/marketing/src/app/not-found.tsx +++ b/apps/marketing/src/app/not-found.tsx @@ -26,7 +26,7 @@ export default function NotFound() { background pattern diff --git a/apps/marketing/src/assets/signing-celebration.png b/apps/marketing/src/assets/signing-celebration.png new file mode 100644 index 000000000..a3fb5bc65 Binary files /dev/null and b/apps/marketing/src/assets/signing-celebration.png differ diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx index e1e8d7da8..92e871d67 100644 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx @@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -43,9 +44,11 @@ export type ClaimPlanDialogProps = { export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { const params = useSearchParams(); - const { toast } = useToast(); + const analytics = useAnalytics(); const event = usePlausible(); + const { toast } = useToast(); + const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); const { @@ -73,10 +76,12 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog ]); event('claim-plan-pricing'); + analytics.capture('Marketing: Claim plan', { planId, email }); window.location.href = redirectUrl; } catch (error) { event('claim-plan-failed'); + analytics.capture('Marketing: Claim plan failure', { planId, email }); toast({ title: 'Something went wrong', diff --git a/apps/marketing/src/components/(marketing)/confetti-screen.tsx b/apps/marketing/src/components/(marketing)/confetti-screen.tsx new file mode 100644 index 000000000..9843a0df0 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/confetti-screen.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import Confetti from 'react-confetti'; +import { createPortal } from 'react-dom'; + +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; + +export default function ConfettiScreen({ + numberOfPieces: numberOfPiecesProp = 200, + ...props +}: React.ComponentPropsWithoutRef & { duration?: number }) { + const isMounted = useIsMounted(); + const { width, height } = useWindowSize(); + + const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp); + + useEffect(() => { + if (!props.duration) { + return; + } + + const timer = setTimeout(() => { + setNumberOfPieces(0); + }, props.duration); + + return () => clearTimeout(timer); + }, [props.duration]); + + if (!isMounted) { + return null; + } + + return createPortal( + , + document.body, + ); +} diff --git a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx index 4fd885f05..d4d3df89d 100644 --- a/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx +++ b/apps/marketing/src/components/(marketing)/faster-smarter-beautiful-bento.tsx @@ -22,7 +22,7 @@ export const FasterSmarterBeautifulBento = ({ background pattern

diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 5b929c485..853aab536 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -20,6 +20,7 @@ const SOCIAL_LINKS = [ const FOOTER_LINKS = [ { href: '/pricing', text: 'Pricing' }, + { href: '/single-player-mode', text: 'Single Player Mode' }, { href: '/blog', text: 'Blog' }, { href: '/open', text: 'Open' }, { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, diff --git a/apps/marketing/src/components/(marketing)/header.tsx b/apps/marketing/src/components/(marketing)/header.tsx index 6dc017f37..117f47319 100644 --- a/apps/marketing/src/components/(marketing)/header.tsx +++ b/apps/marketing/src/components/(marketing)/header.tsx @@ -5,6 +5,7 @@ import { HTMLAttributes, useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; import { HamburgerMenu } from './mobile-hamburger'; @@ -15,17 +16,32 @@ export type HeaderProps = HTMLAttributes; export const Header = ({ className, ...props }: HeaderProps) => { const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); + const { getFlag } = useFeatureFlags(); + + const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode'); + return (
- setIsHamburgerMenuOpen(false)}> - Documenso Logo - +
+ setIsHamburgerMenuOpen(false)}> + Documenso Logo + + + {isSinglePlayerModeMarketingEnabled && ( + + Try now! + + )} +
{ const event = usePlausible(); + const { getFlag } = useFeatureFlags(); + + const heroMarketingCTA = getFlag('marketing_landing_hero_cta'); + const onSignUpClick = () => { const el = document.getElementById('email'); @@ -80,7 +86,7 @@ export const Hero = ({ className, ...props }: HeroProps) => { background pattern
@@ -109,7 +115,7 @@ export const Hero = ({ className, ...props }: HeroProps) => { onClick={onSignUpClick} > Get the Community Plan - + $30/mo. forever! @@ -122,23 +128,45 @@ export const Hero = ({ className, ...props }: HeroProps) => { - - - Documenso - The open source DocuSign alternative | Product Hunt - - + {match(heroMarketingCTA) + .with('spm', () => ( + + +

+ Introducing Single Player Mode +

+ +

+ Self sign for free! +

+ +
+ )) + .with('productHunt', () => ( + + + Documenso - The open source DocuSign alternative | Product Hunt + + + )) + .otherwise(() => null)}

diff --git a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx index 15335d9a5..ea05ae7a6 100644 --- a/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx +++ b/apps/marketing/src/components/(marketing)/share-connect-paid-widget-bento.tsx @@ -23,7 +23,7 @@ export const ShareConnectPaidWidgetBento = ({ background pattern

diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts new file mode 100644 index 000000000..f2bc074ea --- /dev/null +++ b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts @@ -0,0 +1,234 @@ +'use server'; + +import { createElement } from 'react'; + +import { DateTime } from 'luxon'; +import { PDFDocument } from 'pdf-lib'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed'; +import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email'; +import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; +import { alphaid } from '@documenso/lib/universal/id'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { prisma } from '@documenso/prisma'; +import { + DocumentDataType, + DocumentStatus, + FieldType, + Prisma, + ReadStatus, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; + +const ZCreateSinglePlayerDocumentSchema = z.object({ + documentData: z.object({ + data: z.string(), + type: z.nativeEnum(DocumentDataType), + }), + documentName: z.string(), + signer: z.object({ + email: z.string().email().min(1), + name: z.string(), + signature: z.string(), + }), + fields: z.array( + z.object({ + page: z.number(), + type: z.nativeEnum(FieldType), + positionX: z.number(), + positionY: z.number(), + width: z.number(), + height: z.number(), + }), + ), +}); + +export type TCreateSinglePlayerDocumentSchema = z.infer; + +/** + * Create and self signs a document. + * + * Returns the document token. + */ +export const createSinglePlayerDocument = async ( + value: TCreateSinglePlayerDocumentSchema, +): Promise => { + const { signer, fields, documentData, documentName } = + ZCreateSinglePlayerDocumentSchema.parse(value); + + const document = await getFile({ + data: documentData.data, + type: documentData.type, + }); + + const doc = await PDFDocument.load(document); + const createdAt = new Date(); + + const isBase64 = signer.signature.startsWith('data:image/png;base64,'); + const signatureImageAsBase64 = isBase64 ? signer.signature : null; + const typedSignature = !isBase64 ? signer.signature : null; + + // Update the document with the fields inserted. + for (const field of fields) { + const isSignatureField = field.type === FieldType.SIGNATURE; + + await insertFieldInPDF(doc, { + ...mapField(field, signer), + Signature: isSignatureField + ? { + created: createdAt, + signatureImageAsBase64, + typedSignature, + // Dummy data. + id: -1, + recipientId: -1, + fieldId: -1, + } + : null, + // Dummy data. + id: -1, + documentId: -1, + recipientId: -1, + }); + } + + const pdfBytes = await doc.save(); + + const documentToken = await prisma.$transaction( + async (tx) => { + const documentToken = alphaid(); + + // Fetch service user who will be the owner of the document. + const serviceUser = await tx.user.findFirstOrThrow({ + where: { + email: SERVICE_USER_EMAIL, + }, + }); + + const documentDataBytes = Buffer.from(pdfBytes).toString('base64'); + + const { id: documentDataId } = await tx.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: documentDataBytes, + initialData: documentDataBytes, + }, + }); + + // Create document. + const document = await tx.document.create({ + data: { + title: documentName, + status: DocumentStatus.COMPLETED, + documentDataId, + userId: serviceUser.id, + createdAt, + }, + }); + + // Create recipient. + const recipient = await tx.recipient.create({ + data: { + documentId: document.id, + name: signer.name, + email: signer.email, + token: documentToken, + signedAt: createdAt, + readStatus: ReadStatus.OPENED, + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + }, + }); + + // Create fields and signatures. + await Promise.all( + fields.map(async (field) => { + const insertedField = await tx.field.create({ + data: { + documentId: document.id, + recipientId: recipient.id, + ...mapField(field, signer), + }, + }); + + if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { + await tx.signature.create({ + data: { + fieldId: insertedField.id, + signatureImageAsBase64, + typedSignature, + recipientId: recipient.id, + }, + }); + } + }), + ); + + return documentToken; + }, + { + maxWait: 5000, + timeout: 30000, + }, + ); + + // Todo: Handle `downloadLink` + const template = createElement(DocumentSelfSignedEmailTemplate, { + downloadLink: `${process.env.NEXT_PUBLIC_MARKETING_URL}/single-player-mode/${documentToken}`, + documentName: documentName, + assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000', + }); + + // Send email to signer. + await mailer.sendMail({ + to: { + address: signer.email, + name: signer.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document signed', + html: render(template), + text: render(template, { plainText: true }), + }); + + return documentToken; +}; + +/** + * Map the fields provided by the user to fields compatible with Prisma. + * + * Signature fields are handled separately. + * + * @param field The field passed in by the user. + * @param signer The details of the person who is signing this document. + * @returns A field compatible with Prisma. + */ +const mapField = ( + field: TCreateSinglePlayerDocumentSchema['fields'][number], + signer: TCreateSinglePlayerDocumentSchema['signer'], +) => { + const customText = match(field.type) + .with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a')) + .with(FieldType.EMAIL, () => signer.email) + .with(FieldType.NAME, () => signer.name) + .otherwise(() => ''); + + return { + type: field.type, + page: field.page, + positionX: new Prisma.Decimal(field.positionX), + positionY: new Prisma.Decimal(field.positionY), + width: new Prisma.Decimal(field.width), + height: new Prisma.Decimal(field.height), + customText, + inserted: true, + }; +}; diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx new file mode 100644 index 000000000..70bf58926 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; + +import { Share } from 'lucide-react'; + +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { base64 } from '@documenso/lib/universal/base64'; +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import DocumentDialog from '@documenso/ui/components/document/document-dialog'; +import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; +import { SigningCard3D } from '@documenso/ui/components/signing-card'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import signingCelebration from '~/assets/signing-celebration.png'; +import ConfettiScreen from '~/components/(marketing)/confetti-screen'; + +import { DocumentStatus } from '.prisma/client'; + +interface SinglePlayerModeSuccessProps { + className?: string; + document: DocumentWithRecipient; +} + +export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerModeSuccessProps) => { + const { getFlag } = useFeatureFlags(); + + const isConfettiEnabled = getFlag('marketing_spm_confetti'); + + const [showDocumentDialog, setShowDocumentDialog] = useState(false); + const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false); + const [documentFile, setDocumentFile] = useState(null); + + const { toast } = useToast(); + + const onShowDocumentClick = async () => { + if (isFetchingDocumentFile) { + return; + } + + setIsFetchingDocumentFile(true); + + try { + const data = await getFile(document.documentData); + + setDocumentFile(base64.encode(data)); + + setShowDocumentDialog(true); + } catch { + toast({ + title: 'Something went wrong.', + description: 'We were unable to retrieve the document at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + + setIsFetchingDocumentFile(false); + }; + + useEffect(() => { + window.scrollTo({ top: 0 }); + }, []); + + return ( +
+ {isConfettiEnabled && ( + + )} + +

+ You have signed + {document.title} +

+ + + +
+
+
+ {/* TODO: Hook this up */} + + + + + +
+
+
+ +

+ Create a{' '} + + free account + {' '} + to access your signed documents at any time +

+ + +
+ ); +}; diff --git a/apps/marketing/src/pages/api/feature-flag/all.ts b/apps/marketing/src/pages/api/feature-flag/all.ts new file mode 100644 index 000000000..f4d0aa3e9 --- /dev/null +++ b/apps/marketing/src/pages/api/feature-flag/all.ts @@ -0,0 +1,7 @@ +import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all'; + +export const config = { + runtime: 'edge', +}; + +export default handlerFeatureFlagAll; diff --git a/apps/marketing/src/pages/api/feature-flag/get.ts b/apps/marketing/src/pages/api/feature-flag/get.ts new file mode 100644 index 000000000..938dfbbcd --- /dev/null +++ b/apps/marketing/src/pages/api/feature-flag/get.ts @@ -0,0 +1,7 @@ +import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get'; + +export const config = { + runtime: 'edge', +}; + +export default handlerFeatureFlagGet; diff --git a/apps/marketing/src/providers/posthog.tsx b/apps/marketing/src/providers/posthog.tsx new file mode 100644 index 000000000..a4019bfb5 --- /dev/null +++ b/apps/marketing/src/providers/posthog.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useEffect } from 'react'; + +import { usePathname, useSearchParams } from 'next/navigation'; + +import posthog from 'posthog-js'; + +import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; + +export function PostHogPageview() { + const postHogConfig = extractPostHogConfig(); + + const pathname = usePathname(); + const searchParams = useSearchParams(); + + if (typeof window !== 'undefined' && postHogConfig) { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + disable_session_recording: true, + }); + } + + useEffect(() => { + if (!postHogConfig || !pathname) { + return; + } + + let url = window.origin + pathname; + if (searchParams && searchParams.toString()) { + url = url + `?${searchParams.toString()}`; + } + posthog.capture('$pageview', { + $current_url: url, + }); + }, [pathname, searchParams, postHogConfig]); + + return null; +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index be51b51fc..c4359060b 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,7 +2,7 @@ const path = require('path'); const { version } = require('./package.json'); -const { parsed: env } = require('dotenv').config({ +require('dotenv').config({ path: path.join(__dirname, '../../.env.local'), }); @@ -22,6 +22,7 @@ const config = { ], env: { APP_VERSION: version, + NEXT_PUBLIC_PROJECT: 'web', }, modularizeImports: { 'lucide-react': { diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index b07e5f848..7c30dc411 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -55,21 +55,18 @@ export const EditDocumentForm = ({ title: 'Add Signers', description: 'Add the people who will sign the document.', stepIndex: 1, - onSubmit: () => onAddSignersFormSubmit, }, fields: { title: 'Add Fields', description: 'Add all relevant fields for each recipient.', stepIndex: 2, onBackStep: () => setStep('signers'), - onSubmit: () => onAddFieldsFormSubmit, }, subject: { title: 'Add Subject', description: 'Add the subject and message you wish to send to signers.', stepIndex: 3, onBackStep: () => setStep('fields'), - onSubmit: () => onAddSubjectFormSubmit, }, }; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 7e6694491..28c8b8122 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -4,12 +4,12 @@ import { redirect } from 'next/navigation'; import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { SubscriptionStatus } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { LocaleDate } from '~/components/formatter/locale-date'; -import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag'; export default async function BillingSettingsPage() { const user = await getRequiredServerComponentSession(); diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index af9b2ab06..a89f1bb3f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -8,10 +8,12 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; +import { SigningCard } from '@documenso/ui/components/signing-card'; + +import signingCelebration from '~/assets/signing-celebration.png'; -import { DownloadButton } from './download-button'; import { ShareButton } from './share-button'; -import { SigningCard } from './signing-card'; export type CompletedSigningPageProps = { params: { @@ -53,7 +55,7 @@ export default async function CompletedSigningPage({ return (
{/* Card with recipient */} - +
{match(document.status) @@ -90,7 +92,7 @@ export default async function CompletedSigningPage({
-

- Want so send slick signing links like this one?{' '} + Want to send slick signing links like this one?{' '} Check out Documenso. diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx deleted file mode 100644 index 791c61231..000000000 --- a/apps/web/src/app/(signing)/sign/[token]/complete/signing-card.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import Image from 'next/image'; - -import { motion } from 'framer-motion'; - -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import signingCelebration from '~/assets/signing-celebration.png'; - -export type SigningCardProps = { - name: string; -}; - -export const SigningCard = ({ name }: SigningCardProps) => { - return ( -

- - - - {name} - - - - - - background pattern - -
- ); -}; diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 8e2201df9..9cff29c64 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { return ( {isLoading && ( -
+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index 05c1cb31c..f6f790799 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { return ( {isLoading && ( -
+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 26568ddcc..74384fd89 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -1,12 +1,15 @@ 'use client'; +import { useMemo, useState } from 'react'; + import { useRouter } from 'next/navigation'; -import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { Document, Field, Recipient } from '@documenso/prisma/client'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const { handleSubmit, formState: { isSubmitting }, } = useForm(); - const isComplete = fields.every((f) => f.inserted); + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(fields.filter((field) => !field.inserted)); + }, [fields]); const onFormSubmit = async () => { - if (!isComplete) { + setValidateUninsertedFields(true); + const isFieldsValid = validateFieldsInserted(fields); + + if (!isFieldsValid) { return; } @@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = )} onSubmit={handleSubmit(onFormSubmit)} > -
+ {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + +

Sign Document

@@ -106,19 +125,13 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = Cancel -
-
+ ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 9688619fa..275a6ede8 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { return ( {isLoading && ( -
+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index f410dcccc..020af41c2 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { return ( {isLoading && ( -
+
)} diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index d5efcb3df..046e5b3df 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -3,10 +3,7 @@ import React from 'react'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import { useFieldPageCoords } from '~/hooks/use-field-page-coords'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; export type SignatureFieldProps = { field: FieldWithSignature; @@ -23,8 +20,6 @@ export const SigningFieldContainer = ({ onRemove, children, }: SignatureFieldProps) => { - const coords = useFieldPageCoords(field); - const onSignFieldClick = async () => { if (field.inserted) { return; @@ -42,40 +37,25 @@ export const SigningFieldContainer = ({ }; return ( -
- - + {!field.inserted && !loading && ( + + )} - {field.inserted && !loading && ( - - )} - - {children} - - -
+ {children} + ); }; diff --git a/apps/web/src/app/(unauthenticated)/layout.tsx b/apps/web/src/app/(unauthenticated)/layout.tsx index c88b9fb2e..c040e3f4c 100644 --- a/apps/web/src/app/(unauthenticated)/layout.tsx +++ b/apps/web/src/app/(unauthenticated)/layout.tsx @@ -16,7 +16,7 @@ export default function UnauthenticatedLayout({ children }: UnauthenticatedLayou background pattern
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3936783ab..a81437aee 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,15 +2,15 @@ import { Suspense } from 'react'; import { Caveat, Inter } from 'next/font/google'; +import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { LocaleProvider } from '@documenso/lib/client-only/providers/locale'; +import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { TrpcProvider } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Toaster } from '@documenso/ui/primitives/toaster'; import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; -import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag'; -import { FeatureFlagProvider } from '~/providers/feature-flag'; import { ThemeProvider } from '~/providers/next-theme'; import { PlausibleProvider } from '~/providers/plausible'; import { PostHogPageview } from '~/providers/posthog'; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 91b045feb..f43e3507a 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -17,6 +17,7 @@ import { import { signOut } from 'next-auth/react'; import { useTheme } from 'next-themes'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { User } from '@documenso/prisma/client'; @@ -37,8 +38,6 @@ import { DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; -import { useFeatureFlags } from '~/providers/feature-flag'; - export type ProfileDropdownProps = { user: User; }; diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 5a85680c8..901c6a5ae 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation'; import { CreditCard, Key, User } from 'lucide-react'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { useFeatureFlags } from '~/providers/feature-flag'; - export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx index 19bbefdc9..ffe2b0d80 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation'; import { CreditCard, Key, User } from 'lucide-react'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { useFeatureFlags } from '~/providers/feature-flag'; - export type MobileNavProps = HTMLAttributes; export const MobileNav = ({ className, ...props }: MobileNavProps) => { diff --git a/apps/web/src/components/partials/not-found.tsx b/apps/web/src/components/partials/not-found.tsx index 0b5c2ad18..679a825ba 100644 --- a/apps/web/src/components/partials/not-found.tsx +++ b/apps/web/src/components/partials/not-found.tsx @@ -29,7 +29,7 @@ export default function NotFoundPartial({ children }: NotFoundPartialProps) { background pattern diff --git a/apps/web/src/pages/api/feature-flag/all.ts b/apps/web/src/pages/api/feature-flag/all.ts index 54efbd7fc..f4d0aa3e9 100644 --- a/apps/web/src/pages/api/feature-flag/all.ts +++ b/apps/web/src/pages/api/feature-flag/all.ts @@ -1,44 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; - -import { getToken } from 'next-auth/jwt'; - -import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags'; - -import PostHogServerClient from '~/helpers/get-post-hog-server-client'; - -import { extractDistinctUserId, mapJwtToFlagProperties } from './get'; +import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all'; export const config = { runtime: 'edge', }; -/** - * Get all the evaluated feature flags based on the current user if possible. - */ -export default async function handler(req: Request) { - const requestHeaders = Object.fromEntries(req.headers.entries()); - - const nextReq = new NextRequest(req, { - headers: requestHeaders, - }); - - const token = await getToken({ req: nextReq }); - - const postHog = PostHogServerClient(); - - // Return the local feature flags if PostHog is not enabled, true by default. - // The front end should not call this API if PostHog is not enabled to reduce network requests. - if (!postHog) { - return NextResponse.json(LOCAL_FEATURE_FLAGS); - } - - const distinctId = extractDistinctUserId(token, nextReq); - - const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token)); - - const res = NextResponse.json(featureFlags); - - res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); - - return res; -} +export default handlerFeatureFlagAll; diff --git a/apps/web/src/pages/api/feature-flag/get.ts b/apps/web/src/pages/api/feature-flag/get.ts index 6e45b5a18..938dfbbcd 100644 --- a/apps/web/src/pages/api/feature-flag/get.ts +++ b/apps/web/src/pages/api/feature-flag/get.ts @@ -1,122 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; - -import { JWT, getToken } from 'next-auth/jwt'; - -import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; -import { nanoid } from '@documenso/lib/universal/id'; - -import PostHogServerClient from '~/helpers/get-post-hog-server-client'; +import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get'; export const config = { runtime: 'edge', }; -/** - * Evaluate a single feature flag based on the current user if possible. - * - * @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name - * @returns A Response with the feature flag value. - */ -export default async function handler(req: Request) { - const { searchParams } = new URL(req.url ?? ''); - const flag = searchParams.get('flag'); - - const requestHeaders = Object.fromEntries(req.headers.entries()); - - const nextReq = new NextRequest(req, { - headers: requestHeaders, - }); - - const token = await getToken({ req: nextReq }); - - if (!flag) { - return NextResponse.json( - { - error: 'Missing flag query parameter.', - }, - { - status: 400, - headers: { - 'content-type': 'application/json', - }, - }, - ); - } - - const postHog = PostHogServerClient(); - - // Return the local feature flags if PostHog is not enabled, true by default. - // The front end should not call this API if PostHog is disabled to reduce network requests. - if (!postHog) { - return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true); - } - - const distinctId = extractDistinctUserId(token, nextReq); - - const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token)); - - const res = NextResponse.json(featureFlag); - - res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); - - return res; -} - -/** - * Map a JWT to properties which are consumed by PostHog to evaluate feature flags. - * - * @param jwt The JWT of the current user. - * @returns A map of properties which are consumed by PostHog. - */ -export const mapJwtToFlagProperties = ( - jwt?: JWT | null, -): { - groups?: Record; - personProperties?: Record; - groupProperties?: Record>; -} => { - return { - personProperties: { - email: jwt?.email ?? '', - }, - groupProperties: { - // Add properties to group users into different groups, such as billing plan. - }, - }; -}; - -/** - * Extract a distinct ID from a JWT and request. - * - * Will fallback to a random ID if no ID could be extracted from either the JWT or request. - * - * @param jwt The JWT of the current user. - * @param request Request potentially containing a PostHog `distinct_id` cookie. - * @returns A distinct user ID. - */ -export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => { - const config = extractPostHogConfig(); - - const email = jwt?.email; - const userId = jwt?.id.toString(); - - let fallbackDistinctId = nanoid(); - - if (config) { - try { - const postHogCookie = JSON.parse( - request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '', - ); - - const postHogDistinctId = postHogCookie['distinct_id']; - - if (typeof postHogDistinctId === 'string') { - fallbackDistinctId = postHogDistinctId; - } - } catch { - // Do nothing. - } - } - - return email ?? userId ?? fallbackDistinctId; -}; +export default handlerFeatureFlagGet; diff --git a/package-lock.json b/package-lock.json index 254855629..cbd767514 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,9 @@ "next-contentlayer": "^0.3.4", "next-plausible": "^3.10.1", "perfect-freehand": "^1.2.0", + "posthog-js": "^1.77.3", "react": "18.2.0", + "react-confetti": "^6.1.0", "react-dom": "18.2.0", "react-hook-form": "^7.43.9", "react-icons": "^4.8.0", @@ -2445,9 +2447,9 @@ } }, "node_modules/@hookform/resolvers": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz", - "integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz", + "integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==", "peerDependencies": { "react-hook-form": "^7.0.0" } @@ -6387,9 +6389,9 @@ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" }, "node_modules/@types/luxon": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz", - "integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.2.tgz", + "integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==", "dev": true }, "node_modules/@types/mdast": { @@ -12868,9 +12870,9 @@ } }, "node_modules/luxon": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz", - "integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-uBoAVCVcajsrqy3pv7eo5jEUz1oeLmCcnMv8n4AJpT5hbpN9lUssAXibNElpbLce3Mhm9dyBzwYLs9zctM/0tA==", "engines": { "node": ">=12" } @@ -15239,9 +15241,9 @@ } }, "node_modules/posthog-js": { - "version": "1.75.3", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.75.3.tgz", - "integrity": "sha512-q5xP4R/Tx8E6H0goZQjY+URMLATFiYXc2raHA+31aNvpBs118fPTmExa4RK6MgRZDFhBiMUBZNT6aj7dM3SyUQ==", + "version": "1.77.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.77.3.tgz", + "integrity": "sha512-DKsGpBIUjQSihhGruEW8wpVCkeDxU4jz7gADdXX2jEWV6bl4WpUPxjo1ukidVDFvvc/ihCM5PQWMQrItexdpSA==", "dependencies": { "fflate": "^0.4.1" } @@ -15755,6 +15757,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-confetti": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz", + "integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==", + "dependencies": { + "tween-functions": "^1.2.0" + }, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.1 || ^18.0.0" + } + }, "node_modules/react-day-picker": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz", @@ -16285,9 +16301,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.45.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz", - "integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==", + "version": "7.45.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz", + "integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==", "engines": { "node": ">=12.22.0" }, @@ -18919,6 +18935,11 @@ "node": ">= 6" } }, + "node_modules/tween-functions": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz", + "integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==" + }, "node_modules/typanion": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz", @@ -19847,6 +19868,7 @@ "license": "MIT", "dependencies": { "@documenso/lib": "*", + "@hookform/resolvers": "^3.3.0", "@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-aspect-ratio": "^1.0.2", @@ -19867,27 +19889,32 @@ "@radix-ui/react-select": "^1.2.1", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", + "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toggle": "^1.0.2", - "@radix-ui/react-tooltip": "^1.0.5", + "@radix-ui/react-tooltip": "^1.0.6", "@tanstack/react-table": "^8.9.1", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", "cmdk": "^0.2.0", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", + "luxon": "^3.4.2", "next": "13.4.19", "pdfjs-dist": "3.6.172", "react-day-picker": "^8.7.1", + "react-hook-form": "^7.45.4", "react-pdf": "^7.3.3", + "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5" }, "devDependencies": { "@documenso/tailwind-config": "*", "@documenso/tsconfig": "*", + "@types/luxon": "^3.3.2", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "react": "18.2.0", diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx index 91d8fa29d..a36f79bc4 100644 --- a/packages/email/template-components/template-document-completed.tsx +++ b/packages/email/template-components/template-document-completed.tsx @@ -4,14 +4,12 @@ import * as config from '@documenso/tailwind-config'; export interface TemplateDocumentCompletedProps { downloadLink: string; - reviewLink: string; documentName: string; assetBaseUrl: string; } export const TemplateDocumentCompleted = ({ downloadLink, - reviewLink, documentName, assetBaseUrl, }: TemplateDocumentCompletedProps) => { @@ -56,17 +54,17 @@ export const TemplateDocumentCompleted = ({ - Continue by downloading or reviewing the document. + Continue by downloading the document.
- + */} + +
+ + + ); +}; + +export default TemplateDocumentSelfSigned; diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index 9152d5822..adaa5d0ed 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -21,7 +21,6 @@ export type DocumentCompletedEmailTemplateProps = Partial { @@ -56,7 +55,6 @@ export const DocumentCompletedEmailTemplate = ({ diff --git a/packages/email/templates/document-self-signed.tsx b/packages/email/templates/document-self-signed.tsx new file mode 100644 index 000000000..3a16f707e --- /dev/null +++ b/packages/email/templates/document-self-signed.tsx @@ -0,0 +1,74 @@ +import { + Body, + Container, + Head, + Html, + Img, + Preview, + Section, + Tailwind, +} from '@react-email/components'; + +import config from '@documenso/tailwind-config'; + +import { + TemplateDocumentSelfSigned, + TemplateDocumentSelfSignedProps, +} from '../template-components/template-document-self-signed'; +import TemplateFooter from '../template-components/template-footer'; + +export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps; + +export const DocumentSelfSignedEmailTemplate = ({ + downloadLink = 'https://documenso.com', + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', +}: DocumentSelfSignedTemplateProps) => { + const previewText = `Completed Document`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
+ +
+ Documenso Logo + + +
+
+ + + + +
+ +
+ + ); +}; + +export default DocumentSelfSignedEmailTemplate; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index cfa86f06d..7ed40e271 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -18,4 +18,4 @@ "eslint-plugin-react": "^7.32.2", "typescript": "^5.1.6" } -} +} \ No newline at end of file diff --git a/packages/lib/client-only/hooks/use-analytics.ts b/packages/lib/client-only/hooks/use-analytics.ts new file mode 100644 index 000000000..a659a6d70 --- /dev/null +++ b/packages/lib/client-only/hooks/use-analytics.ts @@ -0,0 +1,61 @@ +import { posthog } from 'posthog-js'; + +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; +import { + FEATURE_FLAG_GLOBAL_SESSION_RECORDING, + extractPostHogConfig, +} from '@documenso/lib/constants/feature-flags'; + +export function useAnalytics() { + const featureFlags = useFeatureFlags(); + const isPostHogEnabled = extractPostHogConfig(); + + /** + * Capture an analytic event. + * + * @param event The event name. + * @param properties Properties to attach to the event. + */ + const capture = (event: string, properties?: Record) => { + if (!isPostHogEnabled) { + return; + } + + posthog.capture(event, properties); + }; + + /** + * Start the session recording. + * + * @param eventFlag The event to check against feature flags to determine whether tracking is enabled. + */ + const startSessionRecording = (eventFlag?: string) => { + const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING); + const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag)); + + if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) { + return; + } + + posthog.startSessionRecording(); + }; + + /** + * Stop the current session recording. + */ + const stopSessionRecording = () => { + const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING); + + if (!isPostHogEnabled || !isSessionRecordingEnabled) { + return; + } + + posthog.stopSessionRecording(); + }; + + return { + capture, + startSessionRecording, + stopSessionRecording, + }; +} diff --git a/apps/web/src/hooks/use-debounced-value.ts b/packages/lib/client-only/hooks/use-debounced-value.ts similarity index 100% rename from apps/web/src/hooks/use-debounced-value.ts rename to packages/lib/client-only/hooks/use-debounced-value.ts diff --git a/packages/lib/client-only/hooks/use-element-scale-size.ts b/packages/lib/client-only/hooks/use-element-scale-size.ts new file mode 100644 index 000000000..3e9b34b3f --- /dev/null +++ b/packages/lib/client-only/hooks/use-element-scale-size.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { RefObject, useEffect, useState } from 'react'; + +/** + * Calculate the width and height of a text element. + * + * @param text The text to calculate the width and height of. + * @param fontSize The font size to apply to the text. + * @param fontFamily The font family to apply to the text. + * @returns Returns the width and height of the text. + */ +function calculateTextDimensions( + text: string, + fontSize: string, + fontFamily: string, +): { width: number; height: number } { + // Reuse old canvas if available. + let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas; + + if (!canvas) { + canvas = document.createElement('canvas'); + (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas; + } + + const context = canvas.getContext('2d'); + if (!context) { + return { width: 0, height: 0 }; + } + + context.font = `${fontSize} ${fontFamily}`; + const metrics = context.measureText(text); + + return { + width: metrics.width, + height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent, + }; +} + +/** + * Calculate the scaling size to apply to a text to fit it within a container. + * + * @param container The container dimensions to fit the text within. + * @param text The text to fit within the container. + * @param fontSize The font size to apply to the text. + * @param fontFamily The font family to apply to the text. + * @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text. + */ +export const calculateTextScaleSize = ( + container: { width: number; height: number }, + text: string, + fontSize: string, + fontFamily: string, +) => { + const { width, height } = calculateTextDimensions(text, fontSize, fontFamily); + return Math.min(container.width / width, container.height / height, 1); +}; + +/** + * Given a container and child element, calculate the scaling size to apply to the child. + */ +export function useElementScaleSize( + container: { width: number; height: number }, + child: RefObject, + fontSize: number, + fontFamily: string, +) { + const [scalingFactor, setScalingFactor] = useState(1); + + useEffect(() => { + if (!child.current) { + return; + } + + const scaleSize = calculateTextScaleSize( + container, + child.current.innerText, + `${fontSize}px`, + fontFamily, + ); + + setScalingFactor(scaleSize); + }, [child, container, fontFamily, fontSize]); + + return scalingFactor; +} diff --git a/apps/web/src/hooks/use-field-page-coords.ts b/packages/lib/client-only/hooks/use-field-page-coords.ts similarity index 100% rename from apps/web/src/hooks/use-field-page-coords.ts rename to packages/lib/client-only/hooks/use-field-page-coords.ts diff --git a/packages/lib/client-only/hooks/use-is-mounted.ts b/packages/lib/client-only/hooks/use-is-mounted.ts new file mode 100644 index 000000000..7b3aafa40 --- /dev/null +++ b/packages/lib/client-only/hooks/use-is-mounted.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react'; + +export const useIsMounted = () => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + return isMounted; +}; diff --git a/apps/marketing/src/hooks/use-window-size.ts b/packages/lib/client-only/hooks/use-window-size.ts similarity index 100% rename from apps/marketing/src/hooks/use-window-size.ts rename to packages/lib/client-only/hooks/use-window-size.ts diff --git a/apps/web/src/providers/feature-flag.tsx b/packages/lib/client-only/providers/feature-flag.tsx similarity index 96% rename from apps/web/src/providers/feature-flag.tsx rename to packages/lib/client-only/providers/feature-flag.tsx index 0a09fe0f0..e732bebbd 100644 --- a/apps/web/src/providers/feature-flag.tsx +++ b/packages/lib/client-only/providers/feature-flag.tsx @@ -7,8 +7,7 @@ import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled, } from '@documenso/lib/constants/feature-flags'; - -import { getAllFlags } from '~/helpers/get-feature-flag'; +import { getAllFlags } from '@documenso/lib/universal/get-feature-flag'; import { TFeatureFlagValue } from './feature-flag.types'; diff --git a/apps/web/src/providers/feature-flag.types.ts b/packages/lib/client-only/providers/feature-flag.types.ts similarity index 100% rename from apps/web/src/providers/feature-flag.types.ts rename to packages/lib/client-only/providers/feature-flag.types.ts diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts new file mode 100644 index 000000000..827fcef0a --- /dev/null +++ b/packages/lib/constants/app.ts @@ -0,0 +1,8 @@ +export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; +export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web'; + +export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web'; + +export const APP_BASE_URL = IS_APP_WEB + ? process.env.NEXT_PUBLIC_WEBAPP_URL + : process.env.NEXT_PUBLIC_MARKETING_URL; diff --git a/packages/lib/constants/email.ts b/packages/lib/constants/email.ts new file mode 100644 index 000000000..f9c7ba4f5 --- /dev/null +++ b/packages/lib/constants/email.ts @@ -0,0 +1,4 @@ +export const FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com'; +export const FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso'; + +export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com'; diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index d0004e83b..e23f59eba 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -1,3 +1,8 @@ +/** + * The flag name for global session recording feature flag. + */ +export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording'; + /** * How frequent to poll for new feature flags in milliseconds. */ @@ -10,6 +15,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; */ export const LOCAL_FEATURE_FLAGS: Record = { app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', + marketing_header_single_player_mode: false, } as const; /** diff --git a/packages/lib/constants/pdf.ts b/packages/lib/constants/pdf.ts new file mode 100644 index 000000000..eba72ab56 --- /dev/null +++ b/packages/lib/constants/pdf.ts @@ -0,0 +1,9 @@ +import { APP_BASE_URL } from './app'; + +export const DEFAULT_STANDARD_FONT_SIZE = 15; +export const DEFAULT_HANDWRITING_FONT_SIZE = 50; + +export const MIN_STANDARD_FONT_SIZE = 8; +export const MIN_HANDWRITING_FONT_SIZE = 20; + +export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`; diff --git a/packages/lib/package.json b/packages/lib/package.json index e8c6bcee3..5376acf13 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -38,4 +38,4 @@ "@types/bcrypt": "^5.0.0", "@types/luxon": "^3.3.1" } -} +} \ No newline at end of file diff --git a/packages/lib/server-only/auth/send-reset-password.ts b/packages/lib/server-only/auth/send-reset-password.ts index 303ceb821..9479f1a45 100644 --- a/packages/lib/server-only/auth/send-reset-password.ts +++ b/packages/lib/server-only/auth/send-reset-password.ts @@ -18,8 +18,6 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; - console.log({ assetBaseUrl }); - const template = createElement(ResetPasswordTemplate, { assetBaseUrl, userEmail: user.email, diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 62b3ddd48..89b3777ea 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,12 +1,21 @@ import { prisma } from '@documenso/prisma'; +import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; export interface GetDocumentAndSenderByTokenOptions { token: string; } +export interface GetDocumentAndRecipientByTokenOptions { + token: string; +} + export const getDocumentAndSenderByToken = async ({ token, }: GetDocumentAndSenderByTokenOptions) => { + if (!token) { + throw new Error('Missing token'); + } + const result = await prisma.document.findFirstOrThrow({ where: { Recipient: { @@ -29,3 +38,33 @@ export const getDocumentAndSenderByToken = async ({ User, }; }; + +/** + * Get a Document and a Recipient by the recipient token. + */ +export const getDocumentAndRecipientByToken = async ({ + token, +}: GetDocumentAndRecipientByTokenOptions): Promise => { + if (!token) { + throw new Error('Missing token'); + } + + const result = await prisma.document.findFirstOrThrow({ + where: { + Recipient: { + some: { + token, + }, + }, + }, + include: { + Recipient: true, + documentData: true, + }, + }); + + return { + ...result, + Recipient: result.Recipient[0], + }; +}; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fcc0f829c..febe619f0 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; @@ -76,8 +77,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) name, }, from: { - name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', - address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + name: FROM_NAME, + address: FROM_ADDRESS, }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) diff --git a/packages/lib/server-only/feature-flags/all.ts b/packages/lib/server-only/feature-flags/all.ts new file mode 100644 index 000000000..40e759221 --- /dev/null +++ b/packages/lib/server-only/feature-flags/all.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getToken } from 'next-auth/jwt'; + +import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags'; +import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; + +import { extractDistinctUserId, mapJwtToFlagProperties } from './get'; + +/** + * Get all the evaluated feature flags based on the current user if possible. + */ +export default async function handlerFeatureFlagAll(req: Request) { + const requestHeaders = Object.fromEntries(req.headers.entries()); + + const nextReq = new NextRequest(req, { + headers: requestHeaders, + }); + + const token = await getToken({ req: nextReq }); + + const postHog = PostHogServerClient(); + + // Return the local feature flags if PostHog is not enabled, true by default. + // The front end should not call this API if PostHog is not enabled to reduce network requests. + if (!postHog) { + return NextResponse.json(LOCAL_FEATURE_FLAGS); + } + + const distinctId = extractDistinctUserId(token, nextReq); + + const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token)); + + const res = NextResponse.json(featureFlags); + + res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); + + const origin = req.headers.get('origin'); + + if (origin) { + if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) { + res.headers.set('Access-Control-Allow-Origin', origin); + } + + if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) { + res.headers.set('Access-Control-Allow-Origin', origin); + } + } + + return res; +} diff --git a/apps/web/src/helpers/get-post-hog-server-client.ts b/packages/lib/server-only/feature-flags/get-post-hog-server-client.ts similarity index 100% rename from apps/web/src/helpers/get-post-hog-server-client.ts rename to packages/lib/server-only/feature-flags/get-post-hog-server-client.ts diff --git a/apps/web/src/helpers/get-server-component-feature-flag.ts b/packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts similarity index 89% rename from apps/web/src/helpers/get-server-component-feature-flag.ts rename to packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts index ebfaf4bd6..9cdddd7ae 100644 --- a/apps/web/src/helpers/get-server-component-feature-flag.ts +++ b/packages/lib/server-only/feature-flags/get-server-component-feature-flag.ts @@ -1,6 +1,6 @@ import { headers } from 'next/headers'; -import { getAllFlags, getFlag } from './get-feature-flag'; +import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag'; /** * Evaluate whether a flag is enabled for the current user in a server component. diff --git a/packages/lib/server-only/feature-flags/get.ts b/packages/lib/server-only/feature-flags/get.ts new file mode 100644 index 000000000..3157afb60 --- /dev/null +++ b/packages/lib/server-only/feature-flags/get.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { nanoid } from 'nanoid'; +import { JWT, getToken } from 'next-auth/jwt'; + +import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; +import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; + +/** + * Evaluate a single feature flag based on the current user if possible. + * + * @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name + * @returns A Response with the feature flag value. + */ +export default async function handleFeatureFlagGet(req: Request) { + const { searchParams } = new URL(req.url ?? ''); + const flag = searchParams.get('flag'); + + const requestHeaders = Object.fromEntries(req.headers.entries()); + + const nextReq = new NextRequest(req, { + headers: requestHeaders, + }); + + const token = await getToken({ req: nextReq }); + + if (!flag) { + return NextResponse.json( + { + error: 'Missing flag query parameter.', + }, + { + status: 400, + headers: { + 'content-type': 'application/json', + }, + }, + ); + } + + const postHog = PostHogServerClient(); + + // Return the local feature flags if PostHog is not enabled, true by default. + // The front end should not call this API if PostHog is disabled to reduce network requests. + if (!postHog) { + return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true); + } + + const distinctId = extractDistinctUserId(token, nextReq); + + const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token)); + + const res = NextResponse.json(featureFlag); + + res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); + + const origin = req.headers.get('Origin'); + + if (origin) { + if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) { + res.headers.set('Access-Control-Allow-Origin', origin); + } + + if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) { + res.headers.set('Access-Control-Allow-Origin', origin); + } + } + + return res; +} + +/** + * Map a JWT to properties which are consumed by PostHog to evaluate feature flags. + * + * @param jwt The JWT of the current user. + * @returns A map of properties which are consumed by PostHog. + */ +export const mapJwtToFlagProperties = ( + jwt?: JWT | null, +): { + groups?: Record; + personProperties?: Record; + groupProperties?: Record>; +} => { + return { + personProperties: { + email: jwt?.email ?? '', + }, + groupProperties: { + // Add properties to group users into different groups, such as billing plan. + }, + }; +}; + +/** + * Extract a distinct ID from a JWT and request. + * + * Will fallback to a random ID if no ID could be extracted from either the JWT or request. + * + * @param jwt The JWT of the current user. + * @param request Request potentially containing a PostHog `distinct_id` cookie. + * @returns A distinct user ID. + */ +export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => { + const config = extractPostHogConfig(); + + const email = jwt?.email; + const userId = jwt?.id.toString(); + + let fallbackDistinctId = nanoid(); + + if (config) { + try { + const postHogCookie = JSON.parse( + request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '', + ); + + const postHogDistinctId = postHogCookie['distinct_id']; + + if (typeof postHogDistinctId === 'string') { + fallbackDistinctId = postHogDistinctId; + } + } catch { + // Do nothing. + } + } + + return email ?? userId ?? fallbackDistinctId; +}; diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index e7b1e7c5a..9da0e0bf1 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,25 +1,31 @@ import fontkit from '@pdf-lib/fontkit'; -import { readFileSync } from 'fs'; import { PDFDocument, StandardFonts } from 'pdf-lib'; +import { + CAVEAT_FONT_PATH, + DEFAULT_HANDWRITING_FONT_SIZE, + DEFAULT_STANDARD_FONT_SIZE, + MIN_HANDWRITING_FONT_SIZE, + MIN_STANDARD_FONT_SIZE, +} from '@documenso/lib/constants/pdf'; import { FieldType } from '@documenso/prisma/client'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; -const DEFAULT_STANDARD_FONT_SIZE = 15; -const DEFAULT_HANDWRITING_FONT_SIZE = 50; - export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => { + // Fetch the font file from the public URL. + const fontResponse = await fetch(CAVEAT_FONT_PATH); + const fontCaveat = await fontResponse.arrayBuffer(); + const isSignatureField = isSignatureFieldType(field.type); pdf.registerFontkit(fontkit); - const fontCaveat = readFileSync('./public/fonts/caveat.ttf'); - const pages = pdf.getPages(); + const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE; const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE; - let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE; + let fontSize = maxFontSize; const page = pages.at(field.page - 1); @@ -50,11 +56,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu let imageWidth = image.width; let imageHeight = image.height; - // const initialDimensions = { - // width: imageWidth, - // height: imageHeight, - // }; - const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1); imageWidth = imageWidth * scalingFactor; @@ -76,14 +77,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu let textWidth = font.widthOfTextAtSize(field.customText, fontSize); const textHeight = font.heightAtSize(fontSize); - // const initialDimensions = { - // width: textWidth, - // height: textHeight, - // }; - const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1); - fontSize = Math.max(fontSize * scalingFactor, maxFontSize); + fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); textWidth = font.widthOfTextAtSize(field.customText, fontSize); const textX = fieldX + (fieldWidth - textWidth) / 2; diff --git a/packages/lib/server-only/pdf/insert-text-in-pdf.ts b/packages/lib/server-only/pdf/insert-text-in-pdf.ts index 229806554..248702b6e 100644 --- a/packages/lib/server-only/pdf/insert-text-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-text-in-pdf.ts @@ -1,7 +1,8 @@ import fontkit from '@pdf-lib/fontkit'; -import * as fs from 'fs'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import { CAVEAT_FONT_PATH } from '../../constants/pdf'; + export async function insertTextInPDF( pdfAsBase64: string, text: string, @@ -10,13 +11,15 @@ export async function insertTextInPDF( page = 0, useHandwritingFont = true, ): Promise { - const fontBytes = fs.readFileSync('./public/fonts/caveat.ttf'); + // Fetch the font file from the public URL. + const fontResponse = await fetch(CAVEAT_FONT_PATH); + const fontCaveat = await fontResponse.arrayBuffer(); const pdfDoc = await PDFDocument.load(pdfAsBase64); pdfDoc.registerFontkit(fontkit); - const font = await pdfDoc.embedFont(useHandwritingFont ? fontBytes : StandardFonts.Helvetica); + const font = await pdfDoc.embedFont(useHandwritingFont ? fontCaveat : StandardFonts.Helvetica); const pages = pdfDoc.getPages(); const pdfPage = pages[page]; diff --git a/packages/lib/universal/base64.ts b/packages/lib/universal/base64.ts new file mode 100644 index 000000000..fefb1fe89 --- /dev/null +++ b/packages/lib/universal/base64.ts @@ -0,0 +1 @@ +export * from '@scure/base'; diff --git a/apps/web/src/helpers/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts similarity index 68% rename from apps/web/src/helpers/get-feature-flag.ts rename to packages/lib/universal/get-feature-flag.ts index d5cd26c33..38707d41b 100644 --- a/apps/web/src/helpers/get-feature-flag.ts +++ b/packages/lib/universal/get-feature-flag.ts @@ -1,9 +1,12 @@ import { z } from 'zod'; +import { + TFeatureFlagValue, + ZFeatureFlagValueSchema, +} from '@documenso/lib/client-only/providers/feature-flag.types'; +import { APP_BASE_URL } from '@documenso/lib/constants/app'; import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags'; -import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types'; - /** * Evaluate whether a flag is enabled for the current user. * @@ -21,7 +24,7 @@ export const getFlag = async ( return LOCAL_FEATURE_FLAGS[flag] ?? true; } - const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`); + const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`); url.searchParams.set('flag', flag); const response = await fetch(url, { @@ -54,7 +57,7 @@ export const getAllFlags = async ( return LOCAL_FEATURE_FLAGS; } - const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`); + const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`); return fetch(url, { headers: { @@ -69,6 +72,28 @@ export const getAllFlags = async ( .catch(() => LOCAL_FEATURE_FLAGS); }; +/** + * Get all feature flags for anonymous users. + * + * @returns A record of flags and their values. + */ +export const getAllAnonymousFlags = async (): Promise> => { + if (!isFeatureFlagEnabled()) { + return LOCAL_FEATURE_FLAGS; + } + + const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`); + + return fetch(url, { + next: { + revalidate: 60, + }, + }) + .then(async (res) => res.json()) + .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) + .catch(() => LOCAL_FEATURE_FLAGS); +}; + interface GetFlagOptions { /** * The headers to attach to the request to evaluate flags. diff --git a/packages/lib/universal/id.ts b/packages/lib/universal/id.ts index 13738233e..0d40dd088 100644 --- a/packages/lib/universal/id.ts +++ b/packages/lib/universal/id.ts @@ -1,5 +1,5 @@ import { customAlphabet } from 'nanoid'; -export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10); +export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21); export { nanoid } from 'nanoid'; diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts new file mode 100644 index 000000000..b88fed3e9 --- /dev/null +++ b/packages/lib/utils/fields.ts @@ -0,0 +1,41 @@ +import { Field } from '@documenso/prisma/client'; + +/** + * Sort the fields by the Y position on the document. + */ +export const sortFieldsByPosition = (fields: Field[]): Field[] => { + const clonedFields: Field[] = JSON.parse(JSON.stringify(fields)); + + // Sort by page first, then position on page second. + return clonedFields.sort((a, b) => a.page - b.page || Number(a.positionY) - Number(b.positionY)); +}; + +/** + * Validate whether all the provided fields are inserted. + * + * If there are any non-inserted fields it will be highlighted and scrolled into view. + * + * @returns `true` if all fields are inserted, `false` otherwise. + */ +export const validateFieldsInserted = (fields: Field[]): boolean => { + const fieldCardElements = document.getElementsByClassName('field-card-container'); + + // Attach validate attribute on all fields. + Array.from(fieldCardElements).forEach((element) => { + element.setAttribute('data-validate', 'true'); + }); + + const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted)); + + const firstUninsertedField = uninsertedFields[0]; + + const firstUninsertedFieldElement = + firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`); + + if (firstUninsertedFieldElement) { + firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return false; + } + + return uninsertedFields.length === 0; +}; diff --git a/packages/prisma/migrations/20230830053354_add_service_user/migration.sql b/packages/prisma/migrations/20230830053354_add_service_user/migration.sql new file mode 100644 index 000000000..b08996f11 --- /dev/null +++ b/packages/prisma/migrations/20230830053354_add_service_user/migration.sql @@ -0,0 +1,4 @@ +INSERT INTO "User" ("email", "name") VALUES ( + 'serviceaccount@documenso.com', + 'Service Account' +) ON CONFLICT DO NOTHING; diff --git a/packages/prisma/types/document-with-recipient.ts b/packages/prisma/types/document-with-recipient.ts index 4ba6a9776..1db025279 100644 --- a/packages/prisma/types/document-with-recipient.ts +++ b/packages/prisma/types/document-with-recipient.ts @@ -1,5 +1,10 @@ -import { Document, Recipient } from '@documenso/prisma/client'; +import { Document, DocumentData, Recipient } from '@documenso/prisma/client'; -export type DocumentWithRecipient = Document & { +export type DocumentWithRecipients = Document & { Recipient: Recipient[]; }; + +export type DocumentWithRecipient = Document & { + Recipient: Recipient; + documentData: DocumentData; +}; diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx new file mode 100644 index 000000000..b76d54eeb --- /dev/null +++ b/packages/ui/components/document/document-dialog.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@documenso/ui/lib/utils'; + +import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog'; +import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer'; + +export type DocumentDialogProps = { + document: string; +} & Omit; + +/** + * A dialog which renders the provided document. + */ +export default function DocumentDialog({ document, ...props }: DocumentDialogProps) { + const [documentLoaded, setDocumentLoaded] = useState(false); + + const onDocumentLoad = () => { + setDocumentLoaded(true); + }; + + return ( + + + + + props.onOpenChange?.(false)} + > + e.stopPropagation()} + onDocumentLoad={onDocumentLoad} + /> + + + + Close + + + + + ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx b/packages/ui/components/document/document-download-button.tsx similarity index 97% rename from apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx rename to packages/ui/components/document/document-download-button.tsx index 49b7a8f15..d9a4c58e2 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -15,7 +15,7 @@ export type DownloadButtonProps = HTMLAttributes & { documentData?: DocumentData; }; -export const DownloadButton = ({ +export const DocumentDownloadButton = ({ className, fileName, documentData, diff --git a/packages/ui/components/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx new file mode 100644 index 000000000..446b14d2d --- /dev/null +++ b/packages/ui/components/field/field-tooltip.tsx @@ -0,0 +1,63 @@ +import { TooltipArrow } from '@radix-ui/react-tooltip'; +import { VariantProps, cva } from 'class-variance-authority'; +import { createPortal } from 'react-dom'; + +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; +import { cn } from '@documenso/ui/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; + +import { Field } from '.prisma/client'; + +const tooltipVariants = cva('font-semibold', { + variants: { + color: { + default: 'border-2 fill-white', + warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900', + }, + }, + defaultVariants: { + color: 'default', + }, +}); + +interface FieldToolTipProps extends VariantProps { + children: React.ReactNode; + className?: string; + field: Field; +} + +/** + * Renders a tooltip for a given field. + */ +export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) { + const coords = useFieldPageCoords(field); + + return createPortal( +
+ + + + + + {children} + + + + +
, + document.body, + ); +} diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx new file mode 100644 index 000000000..054cc6376 --- /dev/null +++ b/packages/ui/components/field/field.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import { createPortal } from 'react-dom'; + +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; +import { Field } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +export type FieldRootContainerProps = { + field: Field; + children: React.ReactNode; +}; + +export type FieldContainerPortalProps = { + field: Field; + className?: string; + children: React.ReactNode; +}; + +export function FieldContainerPortal({ + field, + children, + className = '', +}: FieldContainerPortalProps) { + const coords = useFieldPageCoords(field); + + return createPortal( +
+ {children} +
, + document.body, + ); +} + +export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { + const [isValidating, setIsValidating] = useState(false); + + const ref = React.useRef(null); + + useEffect(() => { + if (!ref.current) { + return; + } + + const observer = new MutationObserver((_mutations) => { + if (ref.current) { + setIsValidating(ref.current.getAttribute('data-validate') === 'true'); + } + }); + + observer.observe(ref.current, { + attributes: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + + return ( + + + + {children} + + + + ); +} diff --git a/packages/ui/components/signing-card.tsx b/packages/ui/components/signing-card.tsx new file mode 100644 index 000000000..496e451d0 --- /dev/null +++ b/packages/ui/components/signing-card.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Image, { StaticImageData } from 'next/image'; + +import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +export type SigningCardProps = { + className?: string; + name: string; + signingCelebrationImage?: StaticImageData; +}; + +/** + * 2D signing card. + */ +export const SigningCard = ({ className, name, signingCelebrationImage }: SigningCardProps) => { + return ( +
+ + + {signingCelebrationImage && ( + + )} +
+ ); +}; + +/** + * 3D signing card that follows the mouse movement within a certain range. + */ +export const SigningCard3D = ({ className, name, signingCelebrationImage }: SigningCardProps) => { + // Should use % based dimensions by calculating the window height/width. + const boundary = 400; + + const [trackMouse, setTrackMouse] = useState(false); + + const timeoutRef = useRef(); + + const cardX = useMotionValue(0); + const cardY = useMotionValue(0); + const rotateX = useTransform(cardY, [-600, 600], [8, -8]); + const rotateY = useTransform(cardX, [-600, 600], [-8, 8]); + + const diagonalMovement = useTransform( + [rotateX, rotateY], + ([newRotateX, newRotateY]) => newRotateX + newRotateY, + ); + + const sheenPosition = useTransform(diagonalMovement, [-16, 16], [-100, 200]); + const sheenOpacity = useTransform(sheenPosition, [-100, 50, 200], [0, 0.1, 0]); + const sheenGradient = useMotionTemplate`linear-gradient( + 30deg, + transparent, + rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%, + transparent)`; + + const cardRef = useRef(null); + + const cardCenterPosition = useCallback(() => { + if (!cardRef.current) { + return { x: 0, y: 0 }; + } + + const { x, y, width, height } = cardRef.current.getBoundingClientRect(); + + return { x: x + width / 2, y: y + height / 2 }; + }, [cardRef]); + + const onMouseMove = useCallback( + (event: MouseEvent) => { + const { x, y } = cardCenterPosition(); + + const offsetX = event.clientX - x; + const offsetY = event.clientY - y; + + // Calculate distance between the mouse pointer and center of the card. + const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY); + + // Mouse enters enter boundary. + if (distance <= boundary && !trackMouse) { + setTrackMouse(true); + } else if (!trackMouse) { + return; + } + + cardX.set(offsetX); + cardY.set(offsetY); + + clearTimeout(timeoutRef.current); + + // Revert the card back to the center position after the mouse stops moving. + timeoutRef.current = setTimeout(() => { + void animate(cardX, 0, { duration: 2, ease: 'backInOut' }); + void animate(cardY, 0, { duration: 2, ease: 'backInOut' }); + + setTrackMouse(false); + }, 1000); + }, + [cardX, cardY, cardCenterPosition, trackMouse], + ); + + useEffect(() => { + window.addEventListener('mousemove', onMouseMove); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + }; + }, [onMouseMove]); + + return ( +
+ + + + + {signingCelebrationImage && ( + + )} +
+ ); +}; + +type SigningCardContentProps = { + name: string; + className?: string; +}; + +const SigningCardContent = ({ className, name }: SigningCardContentProps) => { + return ( + + + + {name} + + + + ); +}; + +type SigningCardImageProps = { + signingCelebrationImage: StaticImageData; +}; + +const SigningCardImage = ({ signingCelebrationImage }: SigningCardImageProps) => { + return ( + + background pattern + + ); +}; diff --git a/packages/ui/icons/signature.tsx b/packages/ui/icons/signature.tsx index 0d172bab4..6c118b222 100644 --- a/packages/ui/icons/signature.tsx +++ b/packages/ui/icons/signature.tsx @@ -30,3 +30,5 @@ export const SignatureIcon: LucideIcon = forwardRef( ); }, ); + +SignatureIcon.displayName = 'SignatureIcon'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 53751f5a7..9826f2df8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,7 @@ "devDependencies": { "@documenso/tailwind-config": "*", "@documenso/tsconfig": "*", + "@types/luxon": "^3.3.2", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "react": "18.2.0", @@ -25,6 +26,7 @@ }, "dependencies": { "@documenso/lib": "*", + "@hookform/resolvers": "^3.3.0", "@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-aspect-ratio": "^1.0.2", @@ -45,21 +47,25 @@ "@radix-ui/react-select": "^1.2.1", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", + "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toggle": "^1.0.2", - "@radix-ui/react-tooltip": "^1.0.5", + "@radix-ui/react-tooltip": "^1.0.6", "@tanstack/react-table": "^8.9.1", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", "cmdk": "^0.2.0", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", + "luxon": "^3.4.2", "next": "13.4.19", "pdfjs-dist": "3.6.172", "react-day-picker": "^8.7.1", + "react-hook-form": "^7.45.4", "react-pdf": "^7.3.3", + "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5" } diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index c67117d6f..31df69dee 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -56,14 +56,14 @@ export interface ButtonProps } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading, ...props }, ref) => { if (asChild) { return ( ); } - const showLoader = props.loading === true; + const showLoader = loading === true; const isDisabled = props.disabled || showLoader; return ( diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx index c83b3d87c..cea2800fc 100644 --- a/packages/ui/primitives/dialog.tsx +++ b/packages/ui/primitives/dialog.tsx @@ -109,6 +109,8 @@ export { DialogContent, DialogHeader, DialogFooter, + DialogOverlay, DialogTitle, DialogDescription, + DialogPortal, }; diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 8035e48cb..834d32545 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -73,7 +73,7 @@ const DocumentDropzoneCardCenterVariants: Variants = { }; export type DocumentDropzoneProps = { - className: string; + className?: string; onDrop?: (_file: File) => void | Promise; [key: string]: unknown; }; diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index b821f6ca8..9abbeed1a 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -256,17 +256,28 @@ export const AddFieldsFormPartial = ({ }, [onMouseClick, onMouseMove, selectedField]); useEffect(() => { - const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + const observer = new MutationObserver((_mutations) => { + const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); - if (!$page) { - return; - } + if (!$page) { + return; + } - const { height, width } = $page.getBoundingClientRect(); + const { height, width } = $page.getBoundingClientRect(); - fieldBounds.current = { - height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), - width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + fieldBounds.current = { + height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), + width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + }; + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); }; }, []); @@ -396,7 +407,7 @@ export const AddFieldsFormPartial = ({ )} -
+
+ )} + + ); +} + +export function SinglePlayerModeCustomTextField({ + field, + onClick, +}: SinglePlayerModeFieldProps) { + 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(null); + + const { height, width } = useFieldPageCoords(field); + + const scalingFactor = useElementScaleSize( + { + height, + width, + }, + $paragraphEl, + maxFontSize, + fontVariableValue, + ); + + const fontSize = maxFontSize * scalingFactor; + + return ( + + {field.inserted ? ( +

+ {field.customText} +

+ ) : ( + + )} +
+ ); +} + +const isSignatureFieldType = (fieldType: Field['type']) => + fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE; diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 310a5662d..2c24f8c96 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -52,7 +52,6 @@ export interface DocumentFlowStep { title: string; description: string; stepIndex: number; - onSubmit?: () => void; onBackStep?: () => void; onNextStep?: () => void; } diff --git a/packages/ui/primitives/element-visible.tsx b/packages/ui/primitives/element-visible.tsx index 80bb568c3..f5f4fe1b8 100644 --- a/packages/ui/primitives/element-visible.tsx +++ b/packages/ui/primitives/element-visible.tsx @@ -27,6 +27,10 @@ export const ElementVisible = ({ target, children }: ElementVisibleProps) => { }; }, [target]); + useEffect(() => { + setVisible(!!document.querySelector(target)); + }, [target]); + if (!visible) { return null; } diff --git a/packages/ui/primitives/form/form.tsx b/packages/ui/primitives/form/form.tsx new file mode 100644 index 000000000..9467de3af --- /dev/null +++ b/packages/ui/primitives/form/form.tsx @@ -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 = FieldPath, +> = { + name: TName; +}; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const FormFieldContext = React.createContext({} as FormFieldContextValue); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +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 '); + } + + 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({} as FormItemContextValue); + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + }, +); +FormItem.displayName = 'FormItem'; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { required?: boolean } +>(({ className, ...props }, ref) => { + const { formItemId } = useFormField(); + + return ( +
), }); + +/** + * LazyPDFViewer variant with no loader. + */ +export const LazyPDFViewerNoLoader = dynamic(async () => import('./pdf-viewer'), { + ssr: false, +}); diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 7315f1d26..008e81f82 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -8,6 +8,7 @@ import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { cn } from '@documenso/ui/lib/utils'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -30,18 +31,27 @@ export type OnPDFViewerPageClick = (_event: { export type PDFViewerProps = { className?: string; document: string; + onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; -}; +} & Omit, 'onPageClick'>; -export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => { +export const PDFViewer = ({ + className, + document, + onDocumentLoad, + onPageClick, + ...props +}: PDFViewerProps) => { const $el = useRef(null); const [width, setWidth] = useState(0); const [numPages, setNumPages] = useState(0); + const [pdfError, setPdfError] = useState(false); const onDocumentLoaded = (doc: LoadedPDFDocument) => { setNumPages(doc.numPages); + onDocumentLoad?.(doc); }; const onDocumentPageClick = ( @@ -54,7 +64,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie return; } - const $page = $el.closest('.react-pdf__Page'); + const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR); if (!$page) { return; @@ -108,12 +118,34 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie 'h-[80vh] max-h-[60rem]': numPages === 0, })} onLoadSuccess={(d) => onDocumentLoaded(d)} + // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. + // Therefore we add some additional custom error handling. + onSourceError={() => { + setPdfError(true); + }} externalLinkTarget="_blank" loading={
- + {pdfError ? ( +
+

Something went wrong while loading the document.

+

Please try again or contact our support.

+
+ ) : ( + <> + -

Loading document...

+

Loading document...

+ + )} +
+ } + error={ +
+
+

Something went wrong while loading the document.

+

Please try again or contact our support.

+
} > @@ -129,6 +161,7 @@ export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFVie width={width} renderAnnotationLayer={false} renderTextLayer={false} + loading={() => ''} onClick={(e) => onDocumentPageClick(e, i + 1)} />
diff --git a/scripts/vercel.sh b/scripts/vercel.sh index e4ab23622..30fe99476 100755 --- a/scripts/vercel.sh +++ b/scripts/vercel.sh @@ -13,7 +13,8 @@ function log() { function build_webapp() { log "Building webapp for $VERCEL_ENV" - + + remap_webapp_env remap_database_integration npm run prisma:generate --workspace=@documenso/prisma @@ -39,7 +40,8 @@ function remap_webapp_env() { function build_marketing() { log "Building marketing for $VERCEL_ENV" - + + remap_marketing_env remap_database_integration npm run prisma:generate --workspace=@documenso/prisma @@ -72,7 +74,6 @@ function remap_database_integration() { export NEXT_PRIVATE_DIRECT_DATABASE_URL="$POSTGRES_URL_NON_POOLING" fi - if [[ "$NEXT_PRIVATE_DATABASE_URL" == *"neon.tech"* ]]; then log "Remapping for Neon integration" diff --git a/turbo.json b/turbo.json index f1556f229..c1e2c30c0 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,7 @@ "APP_VERSION", "NEXTAUTH_URL", "NEXTAUTH_SECRET", + "NEXT_PUBLIC_PROJECT", "NEXT_PUBLIC_WEBAPP_URL", "NEXT_PUBLIC_MARKETING_URL", "NEXT_PUBLIC_POSTHOG_KEY", @@ -59,7 +60,6 @@ "NEXT_PRIVATE_SMTP_FROM_NAME", "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY", - "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -69,4 +69,4 @@ "POSTGRES_PRISMA_URL", "POSTGRES_URL_NON_POOLING" ] -} +} \ No newline at end of file