diff --git a/.env.example b/.env.example index fb22bbedf..9ea1ae4b9 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" +# [[MARKETING]] +NEXT_PUBLIC_MARKETING_SITE_URL="http://localhost:3001" + # [[DATABASE]] NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. 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 c76c5e631..b2bce26c6 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -18,14 +18,16 @@ "@hookform/resolvers": "^3.1.0", "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", + "lucide-react": "^0.277.0", "micro": "^10.0.1", - "next": "13.4.12", + "next": "13.4.19", "next-auth": "4.22.3", "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)/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)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index 238fa11a4..0e927b836 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..138591a7e --- /dev/null +++ b/apps/marketing/src/app/(marketing)/single-player-mode/[token]/success/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from 'next/navigation'; + +import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token'; + +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 !== '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..02e9cff63 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx @@ -0,0 +1,237 @@ +'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 { 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<{ name: string; file: 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?.(); + }; + + /** + * Create, sign and send the document. + */ + const onSignSubmit = async (data: TAddSignatureFormSchema) => { + if (!uploadedFile) { + return; + } + + try { + const documentToken = await createSinglePlayerDocument({ + document: uploadedFile.file, + documentName: uploadedFile.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 = Buffer.from(arrayBuffer).toString('base64'); + + setUploadedFile({ + name: file.name, + file: `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 46d9a3d32..350fb8c2d 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -1,12 +1,19 @@ -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 { 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', @@ -32,9 +39,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 ( - + @@ -42,9 +55,15 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + + + + - {children} - + + {children} + + ); 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 7de30bba3..31b13abfc 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)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index ab0dd6e24..18032a167 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -17,6 +17,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 4b9458ce2..d5c6d505a 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,11 +16,26 @@ 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! + + )} +
diff --git a/apps/marketing/src/components/(marketing)/hero.tsx b/apps/marketing/src/components/(marketing)/hero.tsx index b406b51cc..9367e02e7 100644 --- a/apps/marketing/src/components/(marketing)/hero.tsx +++ b/apps/marketing/src/components/(marketing)/hero.tsx @@ -6,7 +6,9 @@ import Link from 'next/link'; import { Variants, motion } from 'framer-motion'; import { Github } from 'lucide-react'; import { usePlausible } from 'next-plausible'; +import { match } from 'ts-pattern'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -51,6 +53,10 @@ const HeroTitleVariants: Variants = { export const Hero = ({ className, ...props }: HeroProps) => { const event = usePlausible(); + const { getFlag } = useFeatureFlags(); + + const heroMarketingCTA = getFlag('marketing_landing_hero_cta'); + const onSignUpClick = () => { const el = document.getElementById('email'); @@ -122,23 +128,40 @@ export const Hero = ({ className, ...props }: HeroProps) => { - - - Documenso - The open source DocuSign alternative | Product Hunt - - + {match(heroMarketingCTA) + .with('spm', () => ( + + +

Single Player Mode

+

Self sign documents here

+ +
+ )) + .with('productHunt', () => ( + + + Documenso - The open source DocuSign alternative | Product Hunt + + + )) + .otherwise(() => null)} ; + +/** + * Create and self signs a document. + * + * Returns the document token. + */ +export const createSinglePlayerDocument = async ( + value: TCreateSinglePlayerDocumentSchema, +): Promise => { + const { signer, fields, document, documentName } = ZCreateSinglePlayerDocumentSchema.parse(value); + + 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 = nanoid(); + + // 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; + }); + + // Todo: Handle `downloadLink` + const template = createElement(DocumentSelfSignedEmailTemplate, { + downloadLink: 'https://documenso.com', + 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..8dfd1fc74 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; + +import { Share } from 'lucide-react'; + +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 default function SinglePlayerModeSuccess({ + className, + document, +}: SinglePlayerModeSuccessProps) { + const [showDocumentDialog, setShowDocumentDialog] = useState(false); + const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false); + const [documentFile, setDocumentFile] = useState(null); + + const { toast } = useToast(); + + const handleShowDocumentDialog = async () => { + if (isFetchingDocumentFile) { + return; + } + + setIsFetchingDocumentFile(true); + + try { + const data = await getFile(document.documentData); + + setDocumentFile(Buffer.from(data).toString('base64')); + + 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 ( +
+ + +

+ You have signed +

+

+ {document.title} +

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

+ View the{' '} + + community plan + {' '} + to access the full range of features provided by Documenso +

+ + +
+ ); +} 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/package.json b/apps/web/package.json index d3ab34f96..eeacec152 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,10 +21,11 @@ "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", + "lucide-react": "^0.277.0", "luxon": "^3.4.0", "micro": "^10.0.1", - "next": "13.4.12", + "nanoid": "^4.0.2", + "next": "13.4.19", "next-auth": "4.22.3", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", @@ -49,4 +50,4 @@ "@types/react": "18.2.18", "@types/react-dom": "18.2.7" } -} +} \ No newline at end of file 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 b4837ab23..c4d0ca2c0 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 71a368da5..c4f62e9e1 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,11 @@ 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 { Button } from '@documenso/ui/primitives/button'; -import { DownloadButton } from './download-button'; -import { SigningCard } from './signing-card'; +import signingCelebration from '~/assets/signing-celebration.png'; export type CompletedSigningPageProps = { params: { @@ -53,7 +54,7 @@ export default async function CompletedSigningPage({ return (
{/* Card with recipient */} - +
{match(document.status) @@ -94,7 +95,7 @@ export default async function CompletedSigningPage({ Share -

- 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]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index d5efcb3df..749ab660f 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 @@ -2,12 +2,11 @@ import React from 'react'; +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; 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'; - export type SignatureFieldProps = { field: FieldWithSignature; loading?: boolean; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 2a1d082f9..5ff1b88b6 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 9ae9b4297..d3073e7c1 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -16,6 +16,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'; @@ -30,8 +31,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/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 c6558b466..faaa7705c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,14 +41,16 @@ "@hookform/resolvers": "^3.1.0", "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", + "lucide-react": "^0.277.0", "micro": "^10.0.1", - "next": "13.4.12", + "next": "13.4.19", "next-auth": "4.22.3", "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", @@ -78,10 +80,11 @@ "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", + "lucide-react": "^0.277.0", "luxon": "^3.4.0", "micro": "^10.0.1", - "next": "13.4.12", + "nanoid": "^4.0.2", + "next": "13.4.19", "next-auth": "4.22.3", "next-plausible": "^3.10.1", "next-themes": "^0.2.1", @@ -2444,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" } @@ -2818,22 +2821,22 @@ } }, "node_modules/@next/env": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.12.tgz", - "integrity": "sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ==" + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.19.tgz", + "integrity": "sha512-FsAT5x0jF2kkhNkKkukhsyYOrRqtSxrEhfliniIq0bwWbuXLgyt3Gv0Ml+b91XwjwArmuP7NxCiGd++GGKdNMQ==" }, "node_modules/@next/eslint-plugin-next": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.12.tgz", - "integrity": "sha512-6rhK9CdxEgj/j1qvXIyLTWEaeFv7zOK8yJMulz3Owel0uek0U9MJCGzmKgYxM3aAUBo3gKeywCZKyQnJKto60A==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.19.tgz", + "integrity": "sha512-N/O+zGb6wZQdwu6atMZHbR7T9Np5SUFUjZqCbj0sXm+MwQO35M8TazVB4otm87GkXYs2l6OPwARd3/PUWhZBVQ==", "dependencies": { "glob": "7.1.7" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz", - "integrity": "sha512-deUrbCXTMZ6ZhbOoloqecnUeNpUOupi8SE2tx4jPfNS9uyUR9zK4iXBvH65opVcA/9F5I/p8vDXSYbUlbmBjZg==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.19.tgz", + "integrity": "sha512-vv1qrjXeGbuF2mOkhkdxMDtv9np7W4mcBtaDnHU+yJG+bBwa6rYsYSCI/9Xm5+TuF5SbZbrWO6G1NfTh1TMjvQ==", "cpu": [ "arm64" ], @@ -2846,9 +2849,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.12.tgz", - "integrity": "sha512-WRvH7RxgRHlC1yb5oG0ZLx8F7uci9AivM5/HGGv9ZyG2Als8Ij64GC3d+mQ5sJhWjusyU6T6V1WKTUoTmOB0zQ==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz", + "integrity": "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==", "cpu": [ "x64" ], @@ -2861,9 +2864,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.12.tgz", - "integrity": "sha512-YEKracAWuxp54tKiAvvq73PUs9lok57cc8meYRibTWe/VdPB2vLgkTVWFcw31YDuRXdEhdX0fWS6Q+ESBhnEig==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz", + "integrity": "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==", "cpu": [ "arm64" ], @@ -2876,9 +2879,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.12.tgz", - "integrity": "sha512-LhJR7/RAjdHJ2Isl2pgc/JaoxNk0KtBgkVpiDJPVExVWA1c6gzY57+3zWuxuyWzTG+fhLZo2Y80pLXgIJv7g3g==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz", + "integrity": "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==", "cpu": [ "arm64" ], @@ -2891,9 +2894,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.12.tgz", - "integrity": "sha512-1DWLL/B9nBNiQRng+1aqs3OaZcxC16Nf+mOnpcrZZSdyKHek3WQh6j/fkbukObgNGwmCoVevLUa/p3UFTTqgqg==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz", + "integrity": "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==", "cpu": [ "x64" ], @@ -2906,9 +2909,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.12.tgz", - "integrity": "sha512-kEAJmgYFhp0VL+eRWmUkVxLVunn7oL9Mdue/FS8yzRBVj7Z0AnIrHpTIeIUl1bbdQq1VaoOztnKicAjfkLTRCQ==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz", + "integrity": "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==", "cpu": [ "x64" ], @@ -2921,9 +2924,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.12.tgz", - "integrity": "sha512-GMLuL/loR6yIIRTnPRY6UGbLL9MBdw2anxkOnANxvLvsml4F0HNIgvnU3Ej4BjbqMTNjD4hcPFdlEow4XHPdZA==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz", + "integrity": "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==", "cpu": [ "arm64" ], @@ -2936,9 +2939,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.12.tgz", - "integrity": "sha512-PhgNqN2Vnkm7XaMdRmmX0ZSwZXQAtamBVSa9A/V1dfKQCV1rjIZeiy/dbBnVYGdj63ANfsOR/30XpxP71W0eww==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz", + "integrity": "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==", "cpu": [ "ia32" ], @@ -2951,9 +2954,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.12.tgz", - "integrity": "sha512-Z+56e/Ljt0bUs+T+jPjhFyxYBcdY2RIq9ELFU+qAMQMteHo7ymbV7CKmlcX59RI9C4YzN8PgMgLyAoi916b5HA==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz", + "integrity": "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==", "cpu": [ "x64" ], @@ -6272,9 +6275,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": { @@ -9594,19 +9597,19 @@ } }, "node_modules/eslint-config-next": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.12.tgz", - "integrity": "sha512-ZF0r5vxKaVazyZH/37Au/XItiG7qUOBw+HaH3PeyXltIMwXorsn6bdrl0Nn9N5v5v9spc+6GM2ryjugbjF6X2g==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.19.tgz", + "integrity": "sha512-WE8367sqMnjhWHvR5OivmfwENRQ1ixfNE9hZwQqNCsd+iM3KnuMc1V8Pt6ytgjxjf23D+xbesADv9x3xaKfT3g==", "dependencies": { - "@next/eslint-plugin-next": "13.4.12", + "@next/eslint-plugin-next": "13.4.19", "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.42.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "5.0.0-canary-7118f5dd7-20230705" + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0", @@ -12685,17 +12688,17 @@ } }, "node_modules/lucide-react": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.214.0.tgz", - "integrity": "sha512-/vRi1wnFV2lqyIIkghQ3dDLu0eA9zykRQN9GZBwydzv+kB/2Q3S4X6OYB+aRqLXwl438vfVBqyYov2z0LJeoqA==", + "version": "0.277.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.277.0.tgz", + "integrity": "sha512-9epmznme+vW14V9d2rsMeLr3fMnf59lYDUOVUg6s7oVN22Zq8h4B30+3CIdFFV9UXCjPG5ZNKHfO/hf96cl46A==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "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" } @@ -13981,11 +13984,11 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/next": { - "version": "13.4.12", - "resolved": "https://registry.npmjs.org/next/-/next-13.4.12.tgz", - "integrity": "sha512-eHfnru9x6NRmTMcjQp6Nz0J4XH9OubmzOa7CkWL+AUrUxpibub3vWwttjduu9No16dug1kq04hiUUpo7J3m3Xw==", + "version": "13.4.19", + "resolved": "https://registry.npmjs.org/next/-/next-13.4.19.tgz", + "integrity": "sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==", "dependencies": { - "@next/env": "13.4.12", + "@next/env": "13.4.19", "@swc/helpers": "0.5.1", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001406", @@ -14001,19 +14004,18 @@ "node": ">=16.8.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "13.4.12", - "@next/swc-darwin-x64": "13.4.12", - "@next/swc-linux-arm64-gnu": "13.4.12", - "@next/swc-linux-arm64-musl": "13.4.12", - "@next/swc-linux-x64-gnu": "13.4.12", - "@next/swc-linux-x64-musl": "13.4.12", - "@next/swc-win32-arm64-msvc": "13.4.12", - "@next/swc-win32-ia32-msvc": "13.4.12", - "@next/swc-win32-x64-msvc": "13.4.12" + "@next/swc-darwin-arm64": "13.4.19", + "@next/swc-darwin-x64": "13.4.19", + "@next/swc-linux-arm64-gnu": "13.4.19", + "@next/swc-linux-arm64-musl": "13.4.19", + "@next/swc-linux-x64-gnu": "13.4.19", + "@next/swc-linux-x64-musl": "13.4.19", + "@next/swc-win32-arm64-msvc": "13.4.19", + "@next/swc-win32-ia32-msvc": "13.4.19", + "@next/swc-win32-x64-msvc": "13.4.19" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "fibers": ">= 3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" @@ -14022,9 +14024,6 @@ "@opentelemetry/api": { "optional": true }, - "fibers": { - "optional": true - }, "sass": { "optional": true } @@ -15043,9 +15042,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" } @@ -15559,6 +15558,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", @@ -16089,9 +16102,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" }, @@ -18639,6 +18652,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", @@ -19439,7 +19457,7 @@ "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", "eslint": "^8.40.0", - "eslint-config-next": "13.4.12", + "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", "eslint-plugin-package-json": "^0.1.4", @@ -19466,7 +19484,7 @@ "bcrypt": "^5.1.0", "luxon": "^3.4.0", "nanoid": "^4.0.2", - "next": "13.4.12", + "next": "13.4.19", "next-auth": "4.22.3", "pdf-lib": "^1.17.1", "react": "18.2.0", @@ -19549,6 +19567,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", @@ -19569,6 +19588,7 @@ "@radix-ui/react-select": "^1.2.1", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", + "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.3", @@ -19579,17 +19599,21 @@ "clsx": "^1.2.1", "cmdk": "^0.2.0", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", - "next": "13.4.12", + "lucide-react": "^0.277.0", + "luxon": "^3.4.2", + "next": "13.4.19", "pdfjs-dist": "3.6.172", "react-day-picker": "^8.7.1", + "react-hook-form": "^7.45.4", "react-pdf": "^7.3.3", + "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5" }, "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 b64b13cff..b37b75d4f 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) => { @@ -44,17 +42,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 d9b53c540..6f61e2bc5 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -7,7 +7,7 @@ "@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/parser": "^5.59.2", "eslint": "^8.40.0", - "eslint-config-next": "13.4.12", + "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", "eslint-plugin-package-json": "^0.1.4", @@ -15,4 +15,4 @@ "eslint-plugin-react": "^7.32.2", "typescript": "^5.1.6" } -} +} \ 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 757ab7932..3ecacc1d5 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -25,7 +25,7 @@ "bcrypt": "^5.1.0", "luxon": "^3.4.0", "nanoid": "^4.0.2", - "next": "13.4.12", + "next": "13.4.19", "next-auth": "4.22.3", "pdf-lib": "^1.17.1", "react": "18.2.0", @@ -36,4 +36,4 @@ "@types/bcrypt": "^5.0.0", "@types/luxon": "^3.3.1" } -} +} \ No newline at end of file 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 83a88f24a..a7e18e0df 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 { prisma } from '@documenso/prisma'; import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; @@ -65,8 +66,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) name, }, from: { - name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', - address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + name: FROM_NAME, + address: FROM_ADDRESS, }, subject: 'Please sign this document', html: render(template), 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..af77883ec --- /dev/null +++ b/packages/lib/server-only/feature-flags/all.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getToken } from 'next-auth/jwt'; + +import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags'; +import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; + +import { extractDistinctUserId, mapJwtToFlagProperties } from './get'; + +/** + * Get all the evaluated feature flags based on the current user if possible. + */ +export default async function handlerFeatureFlagAll(req: Request) { + const requestHeaders = Object.fromEntries(req.headers.entries()); + + const nextReq = new NextRequest(req, { + headers: requestHeaders, + }); + + const token = await getToken({ req: nextReq }); + + const postHog = PostHogServerClient(); + + // Return the local feature flags if PostHog is not enabled, true by default. + // The front end should not call this API if PostHog is not enabled to reduce network requests. + if (!postHog) { + return NextResponse.json(LOCAL_FEATURE_FLAGS); + } + + const distinctId = extractDistinctUserId(token, nextReq); + + const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token)); + + const res = NextResponse.json(featureFlags); + + res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); + + return res; +} 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..83a82b5a8 --- /dev/null +++ b/packages/lib/server-only/feature-flags/get.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { nanoid } from 'nanoid'; +import { JWT, getToken } from 'next-auth/jwt'; + +import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; +import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; + +/** + * Evaluate a single feature flag based on the current user if possible. + * + * @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name + * @returns A Response with the feature flag value. + */ +export default async function handleFeatureFlagGet(req: Request) { + const { searchParams } = new URL(req.url ?? ''); + const flag = searchParams.get('flag'); + + const requestHeaders = Object.fromEntries(req.headers.entries()); + + const nextReq = new NextRequest(req, { + headers: requestHeaders, + }); + + const token = await getToken({ req: nextReq }); + + if (!flag) { + return NextResponse.json( + { + error: 'Missing flag query parameter.', + }, + { + status: 400, + headers: { + 'content-type': 'application/json', + }, + }, + ); + } + + const postHog = PostHogServerClient(); + + // Return the local feature flags if PostHog is not enabled, true by default. + // The front end should not call this API if PostHog is disabled to reduce network requests. + if (!postHog) { + return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true); + } + + const distinctId = extractDistinctUserId(token, nextReq); + + const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token)); + + const res = NextResponse.json(featureFlag); + + res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300'); + + return res; +} + +/** + * Map a JWT to properties which are consumed by PostHog to evaluate feature flags. + * + * @param jwt The JWT of the current user. + * @returns A map of properties which are consumed by PostHog. + */ +export const mapJwtToFlagProperties = ( + jwt?: JWT | null, +): { + groups?: Record; + 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 61726f53c..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/apps/web/src/helpers/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts similarity index 70% rename from apps/web/src/helpers/get-feature-flag.ts rename to packages/lib/universal/get-feature-flag.ts index d5cd26c33..1235dfd9e 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. * @@ -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/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/signing-card.tsx b/packages/ui/components/signing-card.tsx new file mode 100644 index 000000000..a9065a62d --- /dev/null +++ b/packages/ui/components/signing-card.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Image, { StaticImageData } from 'next/image'; + +import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +export type SigningCardProps = { + name: string; + signingCelebrationImage?: StaticImageData; +}; + +/** + * 2D signing card. + */ +export const SigningCard = ({ name, signingCelebrationImage }: SigningCardProps) => { + return ( +
+ + + {signingCelebrationImage && ( + + )} +
+ ); +}; + +/** + * 3D signing card that follows the mouse movement within a certain range. + */ +export const SigningCard3D = ({ name, signingCelebrationImage }: SigningCardProps) => { + // Should use % based dimensions by calculating the window height/width. + const boundary = 400; + + const [trackMouse, setTrackMouse] = useState(false); + + const timeoutRef = useRef(); + + 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(200 200 200 / ${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); + } + + if (!trackMouse) { + return; + } + + void animate(cardX, offsetX, { duration: 0.125 }); + void animate(cardY, offsetY, { duration: 0.125 }); + + clearTimeout(timeoutRef.current); + + // Revert the card back to the center position after the mouse stops moving. + timeoutRef.current = setTimeout(() => { + void animate(cardX, 0, { duration: 2, ease: 'backInOut' }); + void animate(cardY, 0, { duration: 2, ease: 'backInOut' }); + + setTrackMouse(false); + }, 1000); + }, + [cardX, cardY, cardCenterPosition, trackMouse], + ); + + useEffect(() => { + window.addEventListener('mousemove', onMouseMove); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + }; + }, [onMouseMove]); + + return ( +
+ + + + + {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 b91998bb5..ec71086cb 100644 --- a/packages/ui/icons/signature.tsx +++ b/packages/ui/icons/signature.tsx @@ -1,12 +1,13 @@ -import type { LucideIcon } from 'lucide-react/dist/lucide-react'; +import type { LucideIcon, LucideProps } from 'lucide-react/dist/lucide-react'; -export const SignatureIcon: LucideIcon = ({ +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +export const SignatureIcon = (({ size = 24, color = 'currentColor', strokeWidth = 1.33, absoluteStrokeWidth, ...props -}) => { +}: LucideProps) => { return ( ); -}; +}) as LucideIcon; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5a60f6c07..6b6b6d4a4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,12 +17,14 @@ "devDependencies": { "@documenso/tailwind-config": "*", "@documenso/tsconfig": "*", + "@types/luxon": "^3.3.2", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "react": "18.2.0", "typescript": "^5.1.6" }, "dependencies": { + "@hookform/resolvers": "^3.3.0", "@documenso/lib": "*", "@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.0.3", @@ -44,6 +46,7 @@ "@radix-ui/react-select": "^1.2.1", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", + "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.2", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.3", @@ -54,12 +57,15 @@ "clsx": "^1.2.1", "cmdk": "^0.2.0", "framer-motion": "^10.12.8", - "lucide-react": "^0.214.0", - "next": "13.4.12", + "lucide-react": "^0.277.0", + "luxon": "^3.4.2", + "next": "13.4.19", "pdfjs-dist": "3.6.172", "react-day-picker": "^8.7.1", + "react-hook-form": "^7.45.4", "react-pdf": "^7.3.3", + "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5" } -} +} \ No newline at end of file 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 ac938b41a..683dc8131 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 = ({ )} -
+
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 01b8bd487..3f2a8674f 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,7 @@ "APP_VERSION", "NEXTAUTH_URL", "NEXTAUTH_SECRET", + "NEXT_PUBLIC_PROJECT", "NEXT_PUBLIC_WEBAPP_URL", "NEXT_PUBLIC_MARKETING_URL", "NEXT_PUBLIC_POSTHOG_KEY", @@ -56,7 +57,6 @@ "NEXT_PRIVATE_SMTP_FROM_NAME", "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY", - "VERCEL", "VERCEL_ENV", "VERCEL_URL", @@ -66,4 +66,4 @@ "POSTGRES_PRISMA_URL", "POSTGRES_URL_NON_POOLING" ] -} +} \ No newline at end of file