mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 20:51:33 +10:00
feat: add single player mode
This commit is contained in:
@ -66,7 +66,7 @@ export default async function OpenPage() {
|
||||
.then((res) => ZStargazersLiveResponse.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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
237
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal file
237
apps/marketing/src/app/(marketing)/single-player-mode/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<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" />
|
||||
@ -42,9 +55,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user