feat: add single player mode

This commit is contained in:
David Nguyen
2023-09-20 13:48:30 +10:00
committed by Mythie
parent cdae3a9a45
commit 34232c79e5
86 changed files with 2576 additions and 410 deletions

View File

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

View File

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

View File

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

View File

@ -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<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
stepIndex: 1,
onBackStep: uploadedFile
? () => {
setUploadedFile(null);
setFields([]);
}
: undefined,
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
stepIndex: 2,
onBackStep: () => setStep('fields'),
},
};
const currentDocumentFlow = documentFlow[step];
useEffect(() => {
analytics.startSessionRecording('marketing_session_recording_spm');
return () => {
analytics.stopSessionRecording();
};
}, [analytics]);
/**
* Insert the selected fields into the local state.
*/
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
setFields(
data.fields.map((field, i) => ({
id: i,
documentId: -1,
recipientId: -1,
type: field.type,
page: field.pageNumber,
positionX: new Prisma.Decimal(field.pageX),
positionY: new Prisma.Decimal(field.pageY),
width: new Prisma.Decimal(field.pageWidth),
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
})),
);
analytics.capture('Marketing: SPM - Fields added');
documentFlow.fields.onNextStep?.();
};
/**
* 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 (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="mt-4 text-lg leading-normal text-[#31373D]">
View our{' '}
<Link
href={'/pricing'}
target="_blank"
className="font-semibold transition-colors hover:text-[#31373D]/80"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer document={uploadedFile.file} />
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
}

View File

@ -1,13 +1,20 @@
import { Inter } from 'next/font/google';
import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
@ -33,9 +40,15 @@ export const metadata = {
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();
return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<html
lang="en"
className={cn(fontInter.variable, fontCaveat.variable)}
suppressHydrationWarning
>
<head>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
@ -43,12 +56,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<PlausibleProvider>{children}</PlausibleProvider>
</ThemeProvider>
<Suspense>
<PostHogPageview />
</Suspense>
<Toaster />
<body>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>{children}</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</body>
</html>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@ -10,6 +10,7 @@ import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { 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',

View File

@ -0,0 +1,46 @@
'use client';
import React, { useEffect, useState } from 'react';
import Confetti from 'react-confetti';
import { createPortal } from 'react-dom';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
export default function ConfettiScreen({
numberOfPieces: numberOfPiecesProp = 200,
...props
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
const isMounted = useIsMounted();
const { width, height } = useWindowSize();
const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
useEffect(() => {
if (!props.duration) {
return;
}
const timer = setTimeout(() => {
setNumberOfPieces(0);
}, props.duration);
return () => clearTimeout(timer);
}, [props.duration]);
if (!isMounted) {
return null;
}
return createPortal(
<Confetti
{...props}
className="w-full"
numberOfPieces={numberOfPieces}
width={width}
height={height}
/>,
document.body,
);
}

View File

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

View File

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

View File

@ -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) => {
</Link>
</motion.div>
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
>
<Link
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
alt="Documenso - The open source DocuSign alternative | Product Hunt"
style={{ width: '250px', height: '54px' }}
/>
</Link>
</motion.div>
{match(heroMarketingCTA)
.with('spm', () => (
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="border-primary mx-auto mt-8 w-fit rounded-xl border-2 bg-white transition-colors hover:bg-slate-50/60"
>
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
<h2 className="text-xs font-semibold text-[#727272]">Single Player Mode</h2>
<h1 className="font-semibold leading-5 text-[#606060]">Self sign documents here</h1>
</Link>
</motion.div>
))
.with('productHunt', () => (
<motion.div
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
>
<Link
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
alt="Documenso - The open source DocuSign alternative | Product Hunt"
style={{ width: '250px', height: '54px' }}
/>
</Link>
</motion.div>
))
.otherwise(() => null)}
<motion.div
className="mt-12"

View File

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

View File

@ -0,0 +1,218 @@
'use server';
import { createElement } from 'react';
import { DateTime } from 'luxon';
import { nanoid } from 'nanoid';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
const ZCreateSinglePlayerDocumentSchema = z.object({
document: z.string(),
documentName: z.string(),
signer: z.object({
email: z.string().email().min(1),
name: z.string(),
signature: z.string(),
}),
fields: z.array(
z.object({
page: z.number(),
type: z.nativeEnum(FieldType),
positionX: z.number(),
positionY: z.number(),
width: z.number(),
height: z.number(),
}),
),
});
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
/**
* Create and self signs a document.
*
* Returns the document token.
*/
export const createSinglePlayerDocument = async (
value: TCreateSinglePlayerDocumentSchema,
): Promise<string> => {
const { signer, fields, 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,
};
};

View File

@ -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<string | null>(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 (
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
<h2 className="text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
You have signed
</h2>
<h3 className="text-foreground/80 mb-6 text-center text-lg font-semibold md:text-xl lg:mb-8 lg:text-3xl">
{document.title}
</h3>
<SigningCard3D
name={document.Recipient.name || document.Recipient.email}
signingCelebrationImage={signingCelebration}
/>
<div className="mt-8 w-full">
<div className={cn('flex flex-col items-center', className)}>
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1" disabled>
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
<Button
onClick={async () => handleShowDocumentDialog()}
loading={isFetchingDocumentFile}
className="col-span-2"
>
Show document
</Button>
</div>
</div>
</div>
<p className="text-muted-foreground/60 mt-36 text-center text-sm">
View the{' '}
<Link
href="/pricing"
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>
community plan
</Link>{' '}
to access the full range of features provided by Documenso
</p>
<DocumentDialog
document={documentFile ?? ''}
open={showDocumentDialog}
onOpenChange={setShowDocumentDialog}
/>
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
const postHogConfig = extractPostHogConfig();
const pathname = usePathname();
const searchParams = useSearchParams();
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
});
}
useEffect(() => {
if (!postHogConfig || !pathname) {
return;
}
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture('$pageview', {
$current_url: url,
});
}, [pathname, searchParams, postHogConfig]);
return null;
}