mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 10:11:35 +10:00
Compare commits
22 Commits
fix/467-bu
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
| a03e74d660 | |||
| 1c34eddd10 | |||
| 1fbf6ed4ba | |||
| 1d6f7f9e37 | |||
| e3c3ec7825 | |||
| 08d176c803 | |||
| b952ed9035 | |||
| 43062dda12 | |||
| 1b53ff9c2d | |||
| acd3e6d613 | |||
| e33b02df56 | |||
| 2c6849ca76 | |||
| 9434f9e2e4 | |||
| f6daef7333 | |||
| c3df8d4c2a | |||
| 4b09693862 | |||
| 8d2e50d1fe | |||
| bfc749f30b | |||
| e0d4255700 | |||
| 6ba4ff1c17 | |||
| 652af26754 | |||
| 093488a67c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,6 +31,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# turbo
|
# turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
.turbo-cookie
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@ -2,8 +2,12 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
@ -22,6 +26,14 @@ const config = {
|
|||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
// fixes: Module not found: Can’t resolve ‘../build/Release/canvas.node’
|
||||||
|
if (isServer) {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.19",
|
"next": "13.5.4",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
247
apps/marketing/src/app/(marketing)/singleplayer/client.tsx
Normal file
247
apps/marketing/src/app/(marketing)/singleplayer/client.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
|
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
|
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainer,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
|
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||||
|
|
||||||
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
|
export const SinglePlayerClient = () => {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
|
fields: {
|
||||||
|
title: 'Add document',
|
||||||
|
description: 'Upload a document and add fields.',
|
||||||
|
stepIndex: 1,
|
||||||
|
onBackStep: uploadedFile
|
||||||
|
? () => {
|
||||||
|
setUploadedFile(null);
|
||||||
|
setFields([]);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onNextStep: () => setStep('sign'),
|
||||||
|
},
|
||||||
|
sign: {
|
||||||
|
title: 'Sign',
|
||||||
|
description: 'Enter your details.',
|
||||||
|
stepIndex: 2,
|
||||||
|
onBackStep: () => setStep('fields'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.startSessionRecording('marketing_session_recording_spm');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
analytics.stopSessionRecording();
|
||||||
|
};
|
||||||
|
}, [analytics]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the selected fields into the local state.
|
||||||
|
*/
|
||||||
|
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(
|
||||||
|
data.fields.map((field, i) => ({
|
||||||
|
id: i,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
type: field.type,
|
||||||
|
page: field.pageNumber,
|
||||||
|
positionX: new Prisma.Decimal(field.pageX),
|
||||||
|
positionY: new Prisma.Decimal(field.pageY),
|
||||||
|
width: new Prisma.Decimal(field.pageWidth),
|
||||||
|
height: new Prisma.Decimal(field.pageHeight),
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Fields added');
|
||||||
|
|
||||||
|
documentFlow.fields.onNextStep?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload, create, sign and send the document.
|
||||||
|
*/
|
||||||
|
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
||||||
|
if (!uploadedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const putFileData = await putFile(uploadedFile.file);
|
||||||
|
|
||||||
|
const documentToken = await createSinglePlayerDocument({
|
||||||
|
documentData: {
|
||||||
|
type: putFileData.type,
|
||||||
|
data: putFileData.data,
|
||||||
|
},
|
||||||
|
documentName: uploadedFile.file.name,
|
||||||
|
signer: data,
|
||||||
|
fields: fields.map((field) => ({
|
||||||
|
page: field.page,
|
||||||
|
type: field.type,
|
||||||
|
positionX: field.positionX.toNumber(),
|
||||||
|
positionY: field.positionY.toNumber(),
|
||||||
|
width: field.width.toNumber(),
|
||||||
|
height: field.height.toNumber(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document signed', {
|
||||||
|
signer: data.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/singleplayer/${documentToken}/success`);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderRecipient: Recipient = {
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
expired: null,
|
||||||
|
signedAt: null,
|
||||||
|
readStatus: 'OPENED',
|
||||||
|
signingStatus: 'NOT_SIGNED',
|
||||||
|
sendStatus: 'NOT_SENT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileDrop = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
setUploadedFile({
|
||||||
|
file,
|
||||||
|
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('Marketing: SPM - Document uploaded');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 sm:mt-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
||||||
|
|
||||||
|
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||||
|
View our{' '}
|
||||||
|
<Link
|
||||||
|
href={'/pricing'}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
community plan
|
||||||
|
</Link>{' '}
|
||||||
|
for exclusive features, including the ability to collaborate with multiple signers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||||
|
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<Card gradient>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer document={uploadedFile.fileBase64} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
|
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={currentDocumentFlow.title}
|
||||||
|
description={currentDocumentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add fields to PDF page. */}
|
||||||
|
{step === 'fields' && (
|
||||||
|
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||||
|
<AddFieldsFormPartial
|
||||||
|
documentFlow={documentFlow.fields}
|
||||||
|
hideRecipients={true}
|
||||||
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onFieldsSubmit}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enter user details and signature. */}
|
||||||
|
{step === 'sign' && (
|
||||||
|
<AddSignatureFormPartial
|
||||||
|
documentFlow={documentFlow.sign}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onSignSubmit}
|
||||||
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentFlowFormContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,244 +1,10 @@
|
|||||||
'use client';
|
import { SinglePlayerClient } from './client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
export const revalidate = 0;
|
||||||
|
|
||||||
import Link from 'next/link';
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
import { useRouter } from 'next/navigation';
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
export default function SingleplayerPage() {
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
return <SinglePlayerClient />;
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
|
||||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
|
||||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
|
||||||
import {
|
|
||||||
DocumentFlowFormContainer,
|
|
||||||
DocumentFlowFormContainerHeader,
|
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
|
||||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
|
||||||
|
|
||||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
|
||||||
|
|
||||||
export default function SinglePlayerModePage() {
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
|
||||||
|
|
||||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
|
||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
|
||||||
fields: {
|
|
||||||
title: 'Add document',
|
|
||||||
description: 'Upload a document and add fields.',
|
|
||||||
stepIndex: 1,
|
|
||||||
onBackStep: uploadedFile
|
|
||||||
? () => {
|
|
||||||
setUploadedFile(null);
|
|
||||||
setFields([]);
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onNextStep: () => setStep('sign'),
|
|
||||||
},
|
|
||||||
sign: {
|
|
||||||
title: 'Sign',
|
|
||||||
description: 'Enter your details.',
|
|
||||||
stepIndex: 2,
|
|
||||||
onBackStep: () => setStep('fields'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
analytics.startSessionRecording('marketing_session_recording_spm');
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
analytics.stopSessionRecording();
|
|
||||||
};
|
|
||||||
}, [analytics]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert the selected fields into the local state.
|
|
||||||
*/
|
|
||||||
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFields(
|
|
||||||
data.fields.map((field, i) => ({
|
|
||||||
id: i,
|
|
||||||
documentId: -1,
|
|
||||||
recipientId: -1,
|
|
||||||
type: field.type,
|
|
||||||
page: field.pageNumber,
|
|
||||||
positionX: new Prisma.Decimal(field.pageX),
|
|
||||||
positionY: new Prisma.Decimal(field.pageY),
|
|
||||||
width: new Prisma.Decimal(field.pageWidth),
|
|
||||||
height: new Prisma.Decimal(field.pageHeight),
|
|
||||||
customText: '',
|
|
||||||
inserted: false,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
analytics.capture('Marketing: SPM - Fields added');
|
|
||||||
|
|
||||||
documentFlow.fields.onNextStep?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload, create, sign and send the document.
|
|
||||||
*/
|
|
||||||
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const putFileData = await putFile(uploadedFile.file);
|
|
||||||
|
|
||||||
const documentToken = await createSinglePlayerDocument({
|
|
||||||
documentData: {
|
|
||||||
type: putFileData.type,
|
|
||||||
data: putFileData.data,
|
|
||||||
},
|
|
||||||
documentName: uploadedFile.file.name,
|
|
||||||
signer: data,
|
|
||||||
fields: fields.map((field) => ({
|
|
||||||
page: field.page,
|
|
||||||
type: field.type,
|
|
||||||
positionX: field.positionX.toNumber(),
|
|
||||||
positionY: field.positionY.toNumber(),
|
|
||||||
width: field.width.toNumber(),
|
|
||||||
height: field.height.toNumber(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('Marketing: SPM - Document signed', {
|
|
||||||
signer: data.email,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/singleplayer/${documentToken}/success`);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const placeholderRecipient: Recipient = {
|
|
||||||
id: -1,
|
|
||||||
documentId: -1,
|
|
||||||
email: '',
|
|
||||||
name: '',
|
|
||||||
token: '',
|
|
||||||
expired: null,
|
|
||||||
signedAt: null,
|
|
||||||
readStatus: 'OPENED',
|
|
||||||
signingStatus: 'NOT_SIGNED',
|
|
||||||
sendStatus: 'NOT_SENT',
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
|
||||||
try {
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
|
||||||
|
|
||||||
setUploadedFile({
|
|
||||||
file,
|
|
||||||
fileBase64: `data:application/pdf;base64,${base64String}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('Marketing: SPM - Document uploaded');
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6 sm:mt-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
|
||||||
|
|
||||||
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
|
||||||
View our{' '}
|
|
||||||
<Link
|
|
||||||
href={'/pricing'}
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-foreground/80 font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
community plan
|
|
||||||
</Link>{' '}
|
|
||||||
for exclusive features, including the ability to collaborate with multiple signers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
|
||||||
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
|
||||||
{uploadedFile ? (
|
|
||||||
<Card gradient>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer document={uploadedFile.fileBase64} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
|
||||||
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
|
|
||||||
<DocumentFlowFormContainerHeader
|
|
||||||
title={currentDocumentFlow.title}
|
|
||||||
description={currentDocumentFlow.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Add fields to PDF page. */}
|
|
||||||
{step === 'fields' && (
|
|
||||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
|
||||||
<AddFieldsFormPartial
|
|
||||||
documentFlow={documentFlow.fields}
|
|
||||||
hideRecipients={true}
|
|
||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
fields={fields}
|
|
||||||
onSubmit={onFieldsSubmit}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Enter user details and signature. */}
|
|
||||||
{step === 'sign' && (
|
|
||||||
<AddSignatureFormPartial
|
|
||||||
documentFlow={documentFlow.sign}
|
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
|
||||||
fields={fields}
|
|
||||||
onSubmit={onSignSubmit}
|
|
||||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
|
||||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DocumentFlowFormContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { claimPlan } from '~/api/claim-plan/fetcher';
|
|||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
export const ZClaimPlanDialogFormSchema = z.object({
|
export const ZClaimPlanDialogFormSchema = z.object({
|
||||||
name: z.string().min(3),
|
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const FOOTER_LINKS = [
|
|||||||
{ href: '/open', text: 'Open' },
|
{ href: '/open', text: 'Open' },
|
||||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||||
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
{ href: 'mailto:support@documenso.com', text: 'Support' },
|
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export const MENU_NAVIGATION_LINKS = [
|
|||||||
{
|
{
|
||||||
href: 'mailto:support@documenso.com',
|
href: 'mailto:support@documenso.com',
|
||||||
text: 'Support',
|
text: 'Support',
|
||||||
|
target: '_blank',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/privacy',
|
href: '/privacy',
|
||||||
@ -78,7 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
staggerChildren: 0.03,
|
staggerChildren: 0.03,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={href}
|
key={href}
|
||||||
variants={{
|
variants={{
|
||||||
@ -100,6 +101,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => handleMenuItemClick()}
|
onClick={() => handleMenuItemClick()}
|
||||||
|
target={target}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
const ZWidgetFormSchema = z
|
const ZWidgetFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
email: z.string().email({ message: 'Please enter a valid email address.' }),
|
||||||
name: z.string().min(3, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
|
||||||
})
|
})
|
||||||
.and(
|
.and(
|
||||||
z.union([
|
z.union([
|
||||||
@ -41,7 +41,7 @@ const ZWidgetFormSchema = z
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
signatureDataUrl: z.null().or(z.string().max(0)),
|
signatureDataUrl: z.null().or(z.string().max(0)),
|
||||||
signatureText: z.string().min(1),
|
signatureText: z.string().trim().min(1),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default async function handler(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,8 +2,12 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
@ -29,6 +33,14 @@ const config = {
|
|||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
// fixes: Module not found: Can’t resolve ‘../build/Release/canvas.node’
|
||||||
|
if (isServer) {
|
||||||
|
config.resolve.alias.canvas = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -37,6 +49,32 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
permanent: true,
|
||||||
|
source: '/documents/:id/sign',
|
||||||
|
destination: '/sign/:token',
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
key: 'token',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permanent: true,
|
||||||
|
source: '/documents/:id/signed',
|
||||||
|
destination: '/sign/:token',
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'query',
|
||||||
|
key: 'token',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.19",
|
"next": "13.5.4",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
@ -40,7 +40,7 @@
|
|||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.1.6",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Loader } from 'lucide-react';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Document, User } from '@documenso/prisma/client';
|
import { Document, User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
@ -51,7 +52,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
header: 'Title',
|
header: 'Title',
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <div>{row.original.title}</div>;
|
return (
|
||||||
|
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
|
||||||
|
{row.original.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -62,7 +67,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
<Link href={`/admin/users/${row.original.User.id}`}>
|
<Link href={`/admin/users/${row.original.User.id}`}>
|
||||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
||||||
<AvatarFallback className="text-gray-400">
|
<AvatarFallback className="text-gray-400">
|
||||||
<span className="text-xs">{row.original.User.name}</span>
|
<span className="text-sm">
|
||||||
|
{recipientInitials(row.original.User.name ?? '')}
|
||||||
|
</span>
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
import {
|
import {
|
||||||
@ -19,7 +21,9 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types';
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
|
|
||||||
export default function UserPage({ params }: { params: { id: number } }) {
|
export default function UserPage({ params }: { params: { id: number } }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|||||||
@ -14,14 +14,14 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
|
|||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
interface User {
|
type UserData = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
Subscription: SubscriptionLite[];
|
Subscription?: SubscriptionLite | null;
|
||||||
Document: DocumentLite[];
|
Document: DocumentLite[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type SubscriptionLite = Pick<
|
type SubscriptionLite = Pick<
|
||||||
Subscription,
|
Subscription,
|
||||||
@ -31,7 +31,7 @@ type SubscriptionLite = Pick<
|
|||||||
type DocumentLite = Pick<Document, 'id'>;
|
type DocumentLite = Pick<Document, 'id'>;
|
||||||
|
|
||||||
type UsersDataTableProps = {
|
type UsersDataTableProps = {
|
||||||
users: User[];
|
users: UserData[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
@ -100,19 +100,7 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
|
|||||||
{
|
{
|
||||||
header: 'Subscription',
|
header: 'Subscription',
|
||||||
accessorKey: 'subscription',
|
accessorKey: 'subscription',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
|
||||||
if (row.original.Subscription && row.original.Subscription.length > 0) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{row.original.Subscription.map((subscription: SubscriptionLite, i: number) => {
|
|
||||||
return <span key={i}>{subscription.status}</span>;
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <span>NONE</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Documents',
|
header: 'Documents',
|
||||||
|
|||||||
@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { quota, remaining } = useLimits();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
if (error instanceof TRPCClientError) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
<DocumentDropzone
|
||||||
|
className="min-h-[40vh]"
|
||||||
|
disabled={remaining.documents === 0}
|
||||||
|
onDrop={onFileDrop}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-6 right-0">
|
||||||
|
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||||
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
You have reached your document limit.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
|
You can upload up to {quota.documents} documents per month on your current plan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className="text-primary hover:text-primary/80 mt-6 block font-medium"
|
||||||
|
href="/settings/billing"
|
||||||
|
>
|
||||||
|
Upgrade your account to upload more documents.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
|
||||||
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
|
||||||
@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
|
<LimitsProvider>
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
<RefreshOnFocus />
|
<RefreshOnFocus />
|
||||||
|
</LimitsProvider>
|
||||||
</NextAuthProvider>
|
</NextAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { redirect } from 'next/navigation';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
@ -30,12 +31,10 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
let subscriptionProduct: Stripe.Product | null = null;
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
if (subscription?.planId) {
|
if (subscription?.priceId) {
|
||||||
const foundSubscriptionProduct = (await stripe.products.list()).data.find(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
(item) => item.default_price === subscription.planId,
|
() => null,
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptionProduct = foundSubscriptionProduct ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
|
||||||
|
|||||||
43
apps/web/src/app/(signing)/sign/[token]/chat-pdf.tsx
Normal file
43
apps/web/src/app/(signing)/sign/[token]/chat-pdf.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
import { loadFileIntoPinecone } from '@documenso/lib/server-only/pinecone';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { Chat } from './chat';
|
||||||
|
|
||||||
|
type ChatPDFProps = {
|
||||||
|
id: string;
|
||||||
|
type: DocumentDataType;
|
||||||
|
data: string;
|
||||||
|
initialData: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ChatPDF({ documentData }: { documentData: ChatPDFProps }) {
|
||||||
|
const docData = await getFile(documentData);
|
||||||
|
const fileName = `${documentData.id}}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fileName, fs.constants.F_OK);
|
||||||
|
} catch (err) {
|
||||||
|
await fs.writeFile(fileName, docData);
|
||||||
|
}
|
||||||
|
await loadFileIntoPinecone(fileName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="my-8" gradient={true} degrees={200}>
|
||||||
|
<CardContent className="mt-8 flex flex-col">
|
||||||
|
<h2 className="text-foreground text-2xl font-semibold">Chat with the PDF</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">Ask any questions regarding the PDF</p>
|
||||||
|
<hr className="border-border mb-4 mt-4" />
|
||||||
|
<Chat />
|
||||||
|
<hr className="border-border mb-4 mt-4" />
|
||||||
|
<p className="text-muted-foreground text-sm italic">
|
||||||
|
Disclaimer: Never trust AI 100%. Always double check the documents yourself. Documenso is
|
||||||
|
not liable for any issue arising from you relying 100% on the AI.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/src/app/(signing)/sign/[token]/chat.tsx
Normal file
56
apps/web/src/app/(signing)/sign/[token]/chat.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useChat } from 'ai/react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
export function Chat({}: Props) {
|
||||||
|
const { input, handleInputChange, handleSubmit, messages } = useChat({
|
||||||
|
api: '/api/chat',
|
||||||
|
});
|
||||||
|
|
||||||
|
// continue https://youtu.be/bZFedu-0emE?si=2JGSJfSQ38aXSlp2&t=10941
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<ul>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
message.role === 'user'
|
||||||
|
? 'mb-6 ml-10 mt-6 flex justify-end'
|
||||||
|
: 'mr-10 justify-start',
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-background text-foreground group relative rounded-lg border-2 p-4 backdrop-blur-[2px]'
|
||||||
|
: 'bg-primary text-primary-foreground rounded-lg p-4 backdrop-blur-[2px]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form className="mb-2 mt-8 flex" onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
className="mr-6 w-1/2"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Ask away..."
|
||||||
|
/>
|
||||||
|
<Button type="submit">Send</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -87,7 +87,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
Please review the document before signing.
|
Please review the document before signing.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4 h-8 w-full" />
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
@ -99,7 +99,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
type="text"
|
type="text"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
value={localFullName}
|
value={localFullName}
|
||||||
onChange={(e) => setLocalFullName(e.target.value)}
|
onChange={(e) => setLocalFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { ChatPDF } from './chat-pdf';
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
@ -106,6 +107,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
<ChatPDF documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z.object({
|
export const ZSignUpFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
|
|||||||
54
apps/web/src/pages/api/chat/index.ts
Normal file
54
apps/web/src/pages/api/chat/index.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Message, OpenAIStream, StreamingTextResponse } from 'ai';
|
||||||
|
import { Configuration, OpenAIApi } from 'openai-edge';
|
||||||
|
|
||||||
|
import { getContext } from '@documenso/lib/server-only/context';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
const config = new Configuration({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = new OpenAIApi(config);
|
||||||
|
|
||||||
|
export default async function handler(request: Request) {
|
||||||
|
// console.log(request.method);
|
||||||
|
// request.json().then((data) => console.log(data));
|
||||||
|
// return Response.json({ message: 'world' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const lastMessage = data.messages[data.messages.length - 1];
|
||||||
|
const context = await getContext(lastMessage.content);
|
||||||
|
console.log('context', context);
|
||||||
|
const prompt = {
|
||||||
|
role: 'system',
|
||||||
|
content: `AI assistant is a brand new, powerful, human-like artificial intelligence.
|
||||||
|
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
|
||||||
|
AI is a well-behaved and well-mannered individual.
|
||||||
|
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
|
||||||
|
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
|
||||||
|
AI assistant is a big fan of Pinecone and Vercel.
|
||||||
|
START CONTEXT BLOCK
|
||||||
|
${context}
|
||||||
|
END OF CONTEXT BLOCK
|
||||||
|
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
|
||||||
|
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
|
||||||
|
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
|
||||||
|
AI assistant will not invent anything that is not drawn directly from the context.
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
const response = await openai.createChatCompletion({
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
messages: [prompt, ...data.messages.filter((message: Message) => message.role === 'user')],
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = OpenAIStream(response);
|
||||||
|
|
||||||
|
return new StreamingTextResponse(stream);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error getting embeddings: ', error);
|
||||||
|
throw new Error('There was an error getting embeddings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,7 +41,7 @@ export default async function handler(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/web/src/pages/api/limits/index.ts
Normal file
3
apps/web/src/pages/api/limits/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
|
||||||
|
|
||||||
|
export default limitsHandler;
|
||||||
@ -1,237 +1,7 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { buffer } from 'micro';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
|
||||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
DocumentDataType,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
ReadStatus,
|
|
||||||
SendStatus,
|
|
||||||
SigningStatus,
|
|
||||||
SubscriptionStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: { bodyParser: false },
|
api: { bodyParser: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default stripeWebhookHandler;
|
||||||
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
|
|
||||||
// return res.status(500).json({
|
|
||||||
// success: false,
|
|
||||||
// message: 'Subscriptions are not enabled',
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const sig =
|
|
||||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
|
||||||
|
|
||||||
if (!sig) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'No signature found in request',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log('constructing body...');
|
|
||||||
const body = await buffer(req);
|
|
||||||
log('constructed body');
|
|
||||||
|
|
||||||
const event = stripe.webhooks.constructEvent(
|
|
||||||
body,
|
|
||||||
sig,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
|
|
||||||
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
|
|
||||||
);
|
|
||||||
log('event-type:', event.type);
|
|
||||||
|
|
||||||
if (event.type === 'customer.subscription.updated') {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
|
|
||||||
await handleCustomerSubscriptionUpdated(subscription);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Webhook received',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
|
||||||
// This is required since we don't want to create a guard for every event type
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
|
||||||
|
|
||||||
if (session.metadata?.source === 'landing') {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
id: Number(session.client_reference_id),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: 'User not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatureText = session.metadata?.signatureText || user.name;
|
|
||||||
let signatureDataUrl = '';
|
|
||||||
|
|
||||||
if (session.metadata?.signatureDataUrl) {
|
|
||||||
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
signatureDataUrl = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
|
||||||
|
|
||||||
const { id: documentDataId } = await prisma.documentData.create({
|
|
||||||
data: {
|
|
||||||
type: DocumentDataType.BYTES_64,
|
|
||||||
data: bytes64,
|
|
||||||
initialData: bytes64,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
|
||||||
data: {
|
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
userId: user.id,
|
|
||||||
documentDataId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { documentData } = document;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw new Error(`Document ${document.id} has no document data`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
|
||||||
data: {
|
|
||||||
name: user.name ?? '',
|
|
||||||
email: user.email,
|
|
||||||
token: randomBytes(16).toString('hex'),
|
|
||||||
signedAt: now,
|
|
||||||
readStatus: ReadStatus.OPENED,
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const field = await prisma.field.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 0,
|
|
||||||
positionX: 77,
|
|
||||||
positionY: 638,
|
|
||||||
inserted: false,
|
|
||||||
customText: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signatureDataUrl) {
|
|
||||||
documentData.data = await insertImageInPDF(
|
|
||||||
documentData.data,
|
|
||||||
signatureDataUrl,
|
|
||||||
field.positionX.toNumber(),
|
|
||||||
field.positionY.toNumber(),
|
|
||||||
field.page,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
documentData.data = await insertTextInPDF(
|
|
||||||
documentData.data,
|
|
||||||
signatureText ?? '',
|
|
||||||
field.positionX.toNumber(),
|
|
||||||
field.positionY.toNumber(),
|
|
||||||
field.page,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
prisma.signature.create({
|
|
||||||
data: {
|
|
||||||
fieldId: field.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
signatureImageAsBase64: signatureDataUrl || undefined,
|
|
||||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.document.update({
|
|
||||||
where: {
|
|
||||||
id: document.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
documentData: {
|
|
||||||
update: {
|
|
||||||
data: documentData.data,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: 'Webhook received',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Unhandled webhook event', event.type);
|
|
||||||
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Unhandled webhook event',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
const { plan } = subscription as unknown as Stripe.SubscriptionItem;
|
|
||||||
|
|
||||||
const customerId =
|
|
||||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
|
||||||
|
|
||||||
const status = match(subscription.status)
|
|
||||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
|
||||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
|
||||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
|
||||||
|
|
||||||
await prisma.subscription.update({
|
|
||||||
where: {
|
|
||||||
customerId: customerId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
planId: plan.id,
|
|
||||||
status,
|
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
||||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({
|
|||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// res.json({ hello: 'world' });
|
|
||||||
// }
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
|
||||||
|
|
||||||
export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
|
||||||
export type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
|
||||||
9348
package-lock.json
generated
9348
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -15,9 +15,12 @@
|
|||||||
"dx:up": "docker compose -f docker/compose-services.yml up -d",
|
"dx:up": "docker compose -f docker/compose-services.yml up -d",
|
||||||
"dx:down": "docker compose -f docker/compose-services.yml down",
|
"dx:down": "docker compose -f docker/compose-services.yml down",
|
||||||
"ci": "turbo run build test:e2e",
|
"ci": "turbo run build test:e2e",
|
||||||
|
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
||||||
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||||
"with:env": "dotenv -e .env -e .env.local --"
|
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||||
|
"with:env": "dotenv -e .env -e .env.local --",
|
||||||
|
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": ">=8.6.0",
|
"npm": ">=8.6.0",
|
||||||
@ -43,6 +46,13 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pinecone-database/pinecone": "^1.1.1",
|
||||||
|
"@types/md5": "^2.3.4",
|
||||||
|
"ai": "^2.2.16",
|
||||||
|
"langchain": "^0.0.169",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"openai-edge": "^1.2.2",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"recharts": "^2.7.2"
|
"recharts": "^2.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*"
|
"@documenso/prisma": "*",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"micro": "^10.0.1",
|
||||||
|
"next": "13.5.4",
|
||||||
|
"next-auth": "4.22.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
packages/ee/server-only/limits/client.ts
Normal file
31
packages/ee/server-only/limits/client.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
import { FREE_PLAN_LIMITS } from './constants';
|
||||||
|
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||||
|
|
||||||
|
export type GetLimitsOptions = {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||||
|
const requestHeaders = headers ?? {};
|
||||||
|
|
||||||
|
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...requestHeaders,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((res) => ZLimitsResponseSchema.parse(res))
|
||||||
|
.catch(() => {
|
||||||
|
return {
|
||||||
|
quota: FREE_PLAN_LIMITS,
|
||||||
|
remaining: FREE_PLAN_LIMITS,
|
||||||
|
} satisfies TLimitsResponseSchema;
|
||||||
|
});
|
||||||
|
};
|
||||||
11
packages/ee/server-only/limits/constants.ts
Normal file
11
packages/ee/server-only/limits/constants.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { TLimitsSchema } from './schema';
|
||||||
|
|
||||||
|
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: 5,
|
||||||
|
recipients: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: Infinity,
|
||||||
|
recipients: Infinity,
|
||||||
|
};
|
||||||
6
packages/ee/server-only/limits/errors.ts
Normal file
6
packages/ee/server-only/limits/errors.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export const ERROR_CODES: Record<string, string> = {
|
||||||
|
UNAUTHORIZED: 'You must be logged in to access this resource',
|
||||||
|
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
|
||||||
|
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
|
||||||
|
UNKNOWN: 'An unknown error occurred',
|
||||||
|
};
|
||||||
54
packages/ee/server-only/limits/handler.ts
Normal file
54
packages/ee/server-only/limits/handler.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
|
||||||
|
import { SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||||
|
import { ERROR_CODES } from './errors';
|
||||||
|
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||||
|
import { getServerLimits } from './server';
|
||||||
|
|
||||||
|
export const limitsHandler = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken({ req });
|
||||||
|
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
|
||||||
|
quota: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token?.email) {
|
||||||
|
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits = await getServerLimits({ email: token.email });
|
||||||
|
|
||||||
|
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('error', err);
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const status = match(err.message)
|
||||||
|
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
|
||||||
|
.otherwise(() => 500);
|
||||||
|
|
||||||
|
return res.status(status).json({
|
||||||
|
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: ERROR_CODES.UNKNOWN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
53
packages/ee/server-only/limits/provider/client.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getLimits } from '../client';
|
||||||
|
import { FREE_PLAN_LIMITS } from '../constants';
|
||||||
|
import { TLimitsResponseSchema } from '../schema';
|
||||||
|
|
||||||
|
export type LimitsContextValue = TLimitsResponseSchema;
|
||||||
|
|
||||||
|
const LimitsContext = createContext<LimitsContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useLimits = () => {
|
||||||
|
const limits = useContext(LimitsContext);
|
||||||
|
|
||||||
|
if (!limits) {
|
||||||
|
throw new Error('useLimits must be used within a LimitsProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LimitsProviderProps = {
|
||||||
|
initialValue?: LimitsContextValue;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||||
|
const defaultValue: TLimitsResponseSchema = {
|
||||||
|
quota: FREE_PLAN_LIMITS,
|
||||||
|
remaining: FREE_PLAN_LIMITS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getLimits().then((limits) => setLimits(limits));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocus = () => {
|
||||||
|
void getLimits().then((limits) => setLimits(limits));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
|
||||||
|
};
|
||||||
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
import { getLimits } from '../client';
|
||||||
|
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||||
|
|
||||||
|
export type LimitsProviderProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||||
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
|
const limits = await getLimits({ headers: requestHeaders });
|
||||||
|
|
||||||
|
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||||
|
};
|
||||||
28
packages/ee/server-only/limits/schema.ts
Normal file
28
packages/ee/server-only/limits/schema.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
|
||||||
|
export const ZLimitsSchema = z.object({
|
||||||
|
documents: z
|
||||||
|
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||||
|
.optional()
|
||||||
|
.default(0),
|
||||||
|
recipients: z
|
||||||
|
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||||
|
.optional()
|
||||||
|
.default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
|
||||||
|
|
||||||
|
export const ZLimitsResponseSchema = z.object({
|
||||||
|
quota: ZLimitsSchema,
|
||||||
|
remaining: ZLimitsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||||
|
|
||||||
|
export const ZLimitsErrorResponseSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;
|
||||||
78
packages/ee/server-only/limits/server.ts
Normal file
78
packages/ee/server-only/limits/server.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||||
|
import { ERROR_CODES } from './errors';
|
||||||
|
import { ZLimitsSchema } from './schema';
|
||||||
|
|
||||||
|
export type GetServerLimitsOptions = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return {
|
||||||
|
quota: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||||
|
|
||||||
|
if (user.Subscription?.priceId) {
|
||||||
|
const { product } = await stripe.prices
|
||||||
|
.retrieve(user.Subscription.priceId, {
|
||||||
|
expand: ['product'],
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof product === 'string') {
|
||||||
|
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||||
|
remaining = structuredClone(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await prisma.document.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quota,
|
||||||
|
remaining,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -17,6 +17,7 @@ export const getCheckoutSession = async ({
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
|
mode: 'subscription',
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
|
|||||||
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
export type GetProductByPriceIdOptions = {
|
||||||
|
priceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
||||||
|
const { product } = await stripe.prices.retrieve(priceId, {
|
||||||
|
expand: ['product'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof product === 'string' || 'deleted' in product) {
|
||||||
|
throw new Error('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return product;
|
||||||
|
};
|
||||||
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
224
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { buffer } from 'micro';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||||
|
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||||
|
|
||||||
|
type StripeWebhookResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stripeWebhookHandler = async (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<StripeWebhookResponse>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
|
if (!isBillingEnabled) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Billing is disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature =
|
||||||
|
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'No signature found in request',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await buffer(req);
|
||||||
|
|
||||||
|
const event = stripe.webhooks.constructEvent(
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
|
||||||
|
);
|
||||||
|
|
||||||
|
await match(event.type)
|
||||||
|
.with('checkout.session.completed', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
|
const userId = Number(session.client_reference_id);
|
||||||
|
const subscriptionId =
|
||||||
|
typeof session.subscription === 'string'
|
||||||
|
? session.subscription
|
||||||
|
: session.subscription?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId || Number.isNaN(userId)) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid session',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('customer.subscription.updated', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string'
|
||||||
|
? subscription.customer
|
||||||
|
: subscription.customer.id;
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('invoice.payment_succeeded', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||||
|
|
||||||
|
const subscriptionId =
|
||||||
|
typeof invoice.subscription === 'string'
|
||||||
|
? invoice.subscription
|
||||||
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
|
if (!customerId || !subscriptionId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid invoice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with('invoice.payment_failed', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
|
||||||
|
const customerId =
|
||||||
|
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||||
|
|
||||||
|
const subscriptionId =
|
||||||
|
typeof invoice.subscription === 'string'
|
||||||
|
? invoice.subscription
|
||||||
|
: invoice.subscription?.id;
|
||||||
|
|
||||||
|
if (!customerId || !subscriptionId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid invoice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const result = await prisma.subscription.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.userId) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||||
|
})
|
||||||
|
.with('customer.subscription.deleted', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
|
await onSubscriptionDeleted({ subscription });
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.otherwise(() => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook received',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type OnSubscriptionDeletedOptions = {
|
||||||
|
subscription: Stripe.Subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: SubscriptionStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type OnSubscriptionUpdatedOptions = {
|
||||||
|
userId: number;
|
||||||
|
subscription: Stripe.Subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onSubscriptionUpdated = async ({
|
||||||
|
userId,
|
||||||
|
subscription,
|
||||||
|
}: OnSubscriptionUpdatedOptions) => {
|
||||||
|
const customerId =
|
||||||
|
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||||
|
|
||||||
|
const status = match(subscription.status)
|
||||||
|
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||||
|
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||||
|
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||||
|
|
||||||
|
await prisma.subscription.upsert({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
customerId,
|
||||||
|
status: status,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscription.items.data[0].price.id,
|
||||||
|
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
customerId,
|
||||||
|
status: status,
|
||||||
|
planId: subscription.id,
|
||||||
|
priceId: subscription.items.data[0].price.id,
|
||||||
|
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1 +1 @@
|
|||||||
export { render, renderAsync } from '@react-email/components';
|
export { render } from '@react-email/components';
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `Completed Document`;
|
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||||
"@typescript-eslint/parser": "^5.59.2",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"eslint-config-next": "13.4.19",
|
"eslint-config-next": "13.4.19",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
@ -16,6 +16,6 @@
|
|||||||
"eslint-plugin-package-json": "^0.1.4",
|
"eslint-plugin-package-json": "^0.1.4",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import { Role, User } from '@documenso/prisma/client';
|
import { Role, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||||
|
|
||||||
export { isAdmin };
|
|
||||||
|
|||||||
@ -28,12 +28,13 @@
|
|||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "13.4.19",
|
"next": "13.5.4",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"stripe": "^12.7.0",
|
"stripe": "^12.7.0",
|
||||||
"ts-pattern": "^5.0.5"
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
|||||||
@ -9,10 +9,8 @@ export const getUsersWithSubscriptionsCount = async () => {
|
|||||||
return await prisma.user.count({
|
return await prisma.user.count({
|
||||||
where: {
|
where: {
|
||||||
Subscription: {
|
Subscription: {
|
||||||
some: {
|
|
||||||
status: SubscriptionStatus.ACTIVE,
|
status: SubscriptionStatus.ACTIVE,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
35
packages/lib/server-only/context.ts
Normal file
35
packages/lib/server-only/context.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Pinecone } from '@pinecone-database/pinecone';
|
||||||
|
|
||||||
|
import { getEmbeddings } from './embeddings';
|
||||||
|
|
||||||
|
export async function getMatchesFromEmbeddings(embeddings: number[]) {
|
||||||
|
const pc = new Pinecone({
|
||||||
|
apiKey: process.env.PINECONE_API_KEY!,
|
||||||
|
environment: process.env.PINECONE_ENV!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pineconeIndex = pc.index('documenso-chat-with-pdf-test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryResult = await pineconeIndex.query({
|
||||||
|
topK: 5,
|
||||||
|
vector: embeddings,
|
||||||
|
includeMetadata: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return queryResult.matches || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error getting matches from embeddings: ', error);
|
||||||
|
throw new Error('There was an error getting matches from embeddings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContext(query: string) {
|
||||||
|
const queryEmbeddings = await getEmbeddings(query);
|
||||||
|
const matches = await getMatchesFromEmbeddings(queryEmbeddings);
|
||||||
|
|
||||||
|
const qualifyingMatches = matches.filter((match) => match.score && match.score > 0.7);
|
||||||
|
const docs = qualifyingMatches.map((match) => match.metadata?.text);
|
||||||
|
|
||||||
|
return docs.join('\n').substring(0, 3000);
|
||||||
|
}
|
||||||
23
packages/lib/server-only/embeddings.ts
Normal file
23
packages/lib/server-only/embeddings.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Configuration, OpenAIApi } from 'openai-edge';
|
||||||
|
|
||||||
|
const config = new Configuration({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openai = new OpenAIApi(config);
|
||||||
|
|
||||||
|
export async function getEmbeddings(text: string) {
|
||||||
|
try {
|
||||||
|
const response = await openai.createEmbedding({
|
||||||
|
model: 'text-embedding-ada-002',
|
||||||
|
input: text.replace(/\n/g, ' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
return result.data[0].embedding;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error getting embeddings: ', error);
|
||||||
|
throw new Error('There was an error getting embeddings');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/lib/server-only/http/to-next-request.ts
Normal file
9
packages/lib/server-only/http/to-next-request.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const toNextRequest = (req: Request) => {
|
||||||
|
const headers = Object.fromEntries(req.headers.entries());
|
||||||
|
|
||||||
|
return new NextRequest(req, {
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
28
packages/lib/server-only/http/with-swr.ts
Normal file
28
packages/lib/server-only/http/with-swr.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
type NarrowedResponse<T> = T extends NextResponse
|
||||||
|
? NextResponse
|
||||||
|
: T extends NextApiResponse<infer U>
|
||||||
|
? NextApiResponse<U>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export const withStaleWhileRevalidate = <T>(
|
||||||
|
res: NarrowedResponse<T>,
|
||||||
|
cacheInSeconds = 60,
|
||||||
|
staleCacheInSeconds = 300,
|
||||||
|
) => {
|
||||||
|
if ('headers' in res) {
|
||||||
|
res.headers.set(
|
||||||
|
'Cache-Control',
|
||||||
|
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.setHeader(
|
||||||
|
'Cache-Control',
|
||||||
|
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
113
packages/lib/server-only/pinecone.ts
Normal file
113
packages/lib/server-only/pinecone.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Pinecone } from '@pinecone-database/pinecone';
|
||||||
|
import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
|
||||||
|
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||||
|
import md5 from 'md5';
|
||||||
|
|
||||||
|
import { getEmbeddings } from './embeddings';
|
||||||
|
|
||||||
|
let pc: Pinecone | null = null;
|
||||||
|
|
||||||
|
// export type PDFPage = {
|
||||||
|
// pageContent: string;
|
||||||
|
// metadata: {
|
||||||
|
// source: string;
|
||||||
|
// pdf: {
|
||||||
|
// version: string;
|
||||||
|
// info: {
|
||||||
|
// pdfformatversion: string;
|
||||||
|
// isacroformpresent: boolean;
|
||||||
|
// isxfapresent: boolean;
|
||||||
|
// creator: string;
|
||||||
|
// producer: string;
|
||||||
|
// ceationdate: string;
|
||||||
|
// moddate: string;
|
||||||
|
// };
|
||||||
|
// metadata: null;
|
||||||
|
// totalPages: number;
|
||||||
|
// };
|
||||||
|
// loc: {
|
||||||
|
// pageNumber: number;
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
export type PDFPage = unknown;
|
||||||
|
export const getPineconeClient = () => {
|
||||||
|
if (!pc) {
|
||||||
|
pc = new Pinecone({
|
||||||
|
apiKey: process.env.PINECONE_API_KEY!,
|
||||||
|
environment: process.env.PINECONE_ENV!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pc;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadFileIntoPinecone(file: string) {
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No file provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = new PDFLoader(file);
|
||||||
|
const pages: PDFPage[] = await loader.load();
|
||||||
|
|
||||||
|
const documents = await Promise.all(pages.map(prepareDocument));
|
||||||
|
|
||||||
|
const vectors = await Promise.all(documents.flat().map(embedDocuments));
|
||||||
|
|
||||||
|
const client = getPineconeClient();
|
||||||
|
const pineconeIndex = client.index('documenso-chat-with-pdf-test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pineconeIndex.upsert(vectors);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error upserting vectors: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embedDocuments(doc) {
|
||||||
|
try {
|
||||||
|
const embeddings = await getEmbeddings(doc.pageContent);
|
||||||
|
const hash = md5(doc.pageContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hash,
|
||||||
|
values: embeddings,
|
||||||
|
metadata: {
|
||||||
|
text: doc.metadata.text,
|
||||||
|
pageNumber: doc.metadata.pageNumber,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('There was an error embedding documents: ', error);
|
||||||
|
throw new Error('There was an error embedding documents');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const truncateStringByBytes = (str: string, numBytes: number) => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
return new TextDecoder('utf-8').decode(encoder.encode(str).slice(0, numBytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function prepareDocument(page: PDFPage) {
|
||||||
|
let { pageContent, metadata } = page;
|
||||||
|
pageContent = pageContent.replace(/\n/g, '');
|
||||||
|
|
||||||
|
const splitter = new RecursiveCharacterTextSplitter();
|
||||||
|
const docs = await splitter.splitDocuments([
|
||||||
|
{
|
||||||
|
pageContent,
|
||||||
|
metadata: {
|
||||||
|
pageNumber: metadata.loc.pageNumber,
|
||||||
|
text: truncateStringByBytes(pageContent, 36000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToAscii(input: string) {
|
||||||
|
return input.replace(/[^\x00-\x7F]/g, '');
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
||||||
return prisma.subscription.findFirst({
|
return await prisma.subscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { DocumentDataType } from '@documenso/prisma/client';
|
import { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { getPresignGetUrl } from './server-actions';
|
|
||||||
|
|
||||||
export type GetFileOptions = {
|
export type GetFileOptions = {
|
||||||
type: DocumentDataType;
|
type: DocumentDataType;
|
||||||
data: string;
|
data: string;
|
||||||
@ -33,6 +31,8 @@ const getFileFromBytes64 = (data: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFileFromS3 = async (key: string) => {
|
const getFileFromS3 = async (key: string) => {
|
||||||
|
const { getPresignGetUrl } = await import('./server-actions');
|
||||||
|
|
||||||
const { url } = await getPresignGetUrl(key);
|
const { url } = await getPresignGetUrl(key);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
|
|||||||
import { DocumentDataType } from '@documenso/prisma/client';
|
import { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||||
import { getPresignPostUrl } from './server-actions';
|
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -34,6 +33,8 @@ const putFileInDatabase = async (file: File) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const putFileInS3 = async (file: File) => {
|
const putFileInS3 = async (file: File) => {
|
||||||
|
const { getPresignPostUrl } = await import('./server-actions');
|
||||||
|
|
||||||
const { url, key } = await getPresignPostUrl(file.name, file.type);
|
const { url, key } = await getPresignPostUrl(file.name, file.type);
|
||||||
|
|
||||||
const body = await file.arrayBuffer();
|
const body = await file.arrayBuffer();
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
||||||
import slugify from '@sindresorhus/slugify';
|
import slugify from '@sindresorhus/slugify';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@ -17,6 +16,8 @@ import { alphaid } from '../id';
|
|||||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
// Get the basename and extension for the file
|
// Get the basename and extension for the file
|
||||||
@ -44,6 +45,8 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
|
|||||||
export const getAbsolutePresignPostUrl = async (key: string) => {
|
export const getAbsolutePresignPostUrl = async (key: string) => {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
const putObjectCommand = new PutObjectCommand({
|
const putObjectCommand = new PutObjectCommand({
|
||||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
@ -59,6 +62,8 @@ export const getAbsolutePresignPostUrl = async (key: string) => {
|
|||||||
export const getPresignGetUrl = async (key: string) => {
|
export const getPresignGetUrl = async (key: string) => {
|
||||||
const client = getS3Client();
|
const client = getS3Client();
|
||||||
|
|
||||||
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
const getObjectCommand = new GetObjectCommand({
|
const getObjectCommand = new GetObjectCommand({
|
||||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
|
|||||||
@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { DocumentDataType } from '@documenso/prisma/client';
|
import { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { getAbsolutePresignPostUrl } from './server-actions';
|
|
||||||
|
|
||||||
export type UpdateFileOptions = {
|
export type UpdateFileOptions = {
|
||||||
type: DocumentDataType;
|
type: DocumentDataType;
|
||||||
oldData: string;
|
oldData: string;
|
||||||
@ -40,6 +38,8 @@ const updateFileWithBytes64 = (data: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateFileWithS3 = async (key: string, data: string) => {
|
const updateFileWithS3 = async (key: string, data: string) => {
|
||||||
|
const { getAbsolutePresignPostUrl } = await import('./server-actions');
|
||||||
|
|
||||||
const { url } = await getAbsolutePresignPostUrl(key);
|
const { url } = await getAbsolutePresignPostUrl(key);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
|||||||
@ -14,16 +14,16 @@
|
|||||||
"prisma:seed": "prisma db seed"
|
"prisma:seed": "prisma db seed"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node --transpileOnly --skipProject ./seed-database.ts"
|
"seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "5.3.1",
|
"@prisma/client": "5.4.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"dotenv-cli": "^7.3.0",
|
"dotenv-cli": "^7.3.0",
|
||||||
"prisma": "5.3.1"
|
"prisma": "5.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription[]
|
Subscription Subscription?
|
||||||
PasswordResetToken PasswordResetToken[]
|
PasswordResetToken PasswordResetToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
packages/prisma/tsconfig.seed.json
Normal file
6
packages/prisma/tsconfig.seed.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
|
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
@ -63,13 +64,25 @@ export const documentRouter = router({
|
|||||||
try {
|
try {
|
||||||
const { title, documentDataId } = input;
|
const { title, documentDataId } = input;
|
||||||
|
|
||||||
|
const { remaining } = await getServerLimits({ email: ctx.user.email });
|
||||||
|
|
||||||
|
if (remaining.documents <= 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'You have reached your document limit for this month. Please upgrade your plan.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await createDocument({
|
return await createDocument({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
title,
|
title,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
if (err instanceof TRPCError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
export const ZSignFieldWithTokenMutationSchema = z.object({
|
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
fieldId: z.number(),
|
fieldId: z.number(),
|
||||||
value: z.string(),
|
value: z.string().trim(),
|
||||||
isBase64: z.boolean().optional(),
|
isBase64: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"typescript": "^5.1.6"
|
"typescript": "5.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
@ -60,11 +60,11 @@
|
|||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"next": "13.4.19",
|
"next": "13.5.4",
|
||||||
"pdfjs-dist": "3.6.172",
|
"pdfjs-dist": "3.6.172",
|
||||||
"react-day-picker": "^8.7.1",
|
"react-day-picker": "^8.7.1",
|
||||||
"react-hook-form": "^7.45.4",
|
"react-hook-form": "^7.45.4",
|
||||||
"react-pdf": "^7.3.3",
|
"react-pdf": "7.3.3",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5"
|
"tailwindcss-animate": "^1.0.5"
|
||||||
|
|||||||
@ -11,12 +11,8 @@ const AlertDialog = AlertDialogPrimitive.Root;
|
|||||||
|
|
||||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
const AlertDialogPortal = ({
|
const AlertDialogPortal = ({ children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||||
className,
|
<AlertDialogPrimitive.Portal {...props}>
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
|
||||||
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
|
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,12 +12,11 @@ const Dialog = DialogPrimitive.Root;
|
|||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
const DialogPortal = ({
|
const DialogPortal = ({
|
||||||
className,
|
|
||||||
children,
|
children,
|
||||||
position = 'start',
|
position = 'start',
|
||||||
...props
|
...props
|
||||||
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
|
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
|
||||||
<DialogPrimitive.Portal className={cn(className)} {...props}>
|
<DialogPrimitive.Portal {...props}>
|
||||||
<div
|
<div
|
||||||
className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', {
|
className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', {
|
||||||
'items-start': position === 'start',
|
'items-start': position === 'start',
|
||||||
|
|||||||
@ -74,16 +74,23 @@ const DocumentDropzoneCardCenterVariants: Variants = {
|
|||||||
|
|
||||||
export type DocumentDropzoneProps = {
|
export type DocumentDropzoneProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
onDrop?: (_file: File) => void | Promise<void>;
|
onDrop?: (_file: File) => void | Promise<void>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => {
|
export const DocumentDropzone = ({
|
||||||
|
className,
|
||||||
|
onDrop,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: DocumentDropzoneProps) => {
|
||||||
const { getRootProps, getInputProps } = useDropzone({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
},
|
},
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
disabled,
|
||||||
onDrop: ([acceptedFile]) => {
|
onDrop: ([acceptedFile]) => {
|
||||||
if (acceptedFile && onDrop) {
|
if (acceptedFile && onDrop) {
|
||||||
void onDrop(acceptedFile);
|
void onDrop(acceptedFile);
|
||||||
@ -102,11 +109,12 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
|||||||
<Card
|
<Card
|
||||||
role="button"
|
role="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-60',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
gradient={true}
|
gradient={true}
|
||||||
degrees={120}
|
degrees={120}
|
||||||
|
aria-disabled={disabled}
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -246,12 +246,12 @@ export const AddFieldsFormPartial = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedField) {
|
if (selectedField) {
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
window.addEventListener('click', onMouseClick);
|
window.addEventListener('mouseup', onMouseClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
window.removeEventListener('click', onMouseClick);
|
window.removeEventListener('mouseup', onMouseClick);
|
||||||
};
|
};
|
||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
@ -417,7 +417,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
>
|
>
|
||||||
@ -441,7 +441,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
>
|
>
|
||||||
@ -464,7 +464,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() => setSelectedField(FieldType.NAME)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
>
|
>
|
||||||
@ -487,7 +487,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={() => setSelectedField(FieldType.DATE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -28,8 +28,8 @@ interface SheetPortalProps
|
|||||||
extends SheetPrimitive.DialogPortalProps,
|
extends SheetPrimitive.DialogPortalProps,
|
||||||
VariantProps<typeof portalVariants> {}
|
VariantProps<typeof portalVariants> {}
|
||||||
|
|
||||||
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
|
const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
|
||||||
<SheetPrimitive.Portal className={cn(className)} {...props}>
|
<SheetPrimitive.Portal {...props}>
|
||||||
<div className={portalVariants({ position })}>{children}</div>
|
<div className={portalVariants({ position })}>{children}</div>
|
||||||
</SheetPrimitive.Portal>
|
</SheetPrimitive.Portal>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -66,6 +66,7 @@
|
|||||||
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
||||||
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
||||||
"NEXT_PRIVATE_STRIPE_API_KEY",
|
"NEXT_PRIVATE_STRIPE_API_KEY",
|
||||||
|
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
|
||||||
"VERCEL",
|
"VERCEL",
|
||||||
"VERCEL_ENV",
|
"VERCEL_ENV",
|
||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user