mirror of
https://github.com/documenso/documenso.git
synced 2026-07-08 20:14:58 +10:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68d624ac78 | |||
| 1e5ecd79c2 | |||
| 58f10268e2 | |||
| 43ce76d928 | |||
| 201ba65e1c | |||
| 0d130b17c8 | |||
| 2c90f767fd | |||
| 946b2fe129 | |||
| 2a2dbb65c6 | |||
| ef2300a600 | |||
| eea99ac871 | |||
| f4c7799537 | |||
| 78325d9b39 | |||
| b3f26055d9 | |||
| 1b276a0469 | |||
| 17fbf2673c | |||
| cdc50ec876 | |||
| 181af24b78 | |||
| 4b13a42731 | |||
| e330e90688 | |||
| f2d3c51651 | |||
| c9c111cdf2 | |||
| c247295131 | |||
| d417255910 | |||
| 677a15327b | |||
| d5238939ad | |||
| 6860726e83 | |||
| a55197fb2d | |||
| bcb163693a | |||
| 7771d7acbe | |||
| 4cd56fa98c | |||
| a1ce6f696a | |||
| 776324c875 | |||
| 6f4c280583 | |||
| 2bad1b9f55 | |||
| 73b0dc315e | |||
| 525ff21563 | |||
| 863e53a2d5 | |||
| da2033692c | |||
| dbbe17a0a8 | |||
| 0c145fab0b | |||
| f6e49e3f21 | |||
| 1f027d75d3 | |||
| 1ba7767f8e | |||
| 8220b2f086 | |||
| a74374e39f | |||
| 551918ab9b | |||
| 739f3763dd | |||
| 0cdd980a4b | |||
| b43ddcbea2 | |||
| 75feaefbf2 | |||
| d524ea77ab | |||
| 2524458b0c | |||
| 12c45fb882 | |||
| 118483b6cc | |||
| fd6350b397 | |||
| 8d4df7d3dd | |||
| f1f6e2e40a | |||
| 66dfdc5ad0 | |||
| b45978374b | |||
| 81c3e701e2 | |||
| 874d919a6a | |||
| e8559cecd5 |
@@ -10,9 +10,6 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||
|
||||
# [[MARKETING]]
|
||||
NEXT_PUBLIC_MARKETING_SITE_URL="http://localhost:3001"
|
||||
|
||||
# [[DATABASE]]
|
||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
TEST
|
||||
|
||||
<p align="center" style="margin-top: 120px">
|
||||
<a href="https://github.com/documenso/documenso">
|
||||
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
|
||||
@@ -22,7 +20,7 @@ TEST
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://documen.so/discord"><img src="https://img.shields.io/badge/Discord-documen.so/discord-%235865F2" alt="Join Documenso on Discord"></a>
|
||||
<a href="https://documen.so/discord"><img src="https://img.shields.io/badge/Discord-documen.so/discord-%235865F2" alt="Join Documenso on Discord"></a>
|
||||
<a href="https://github.com/documenso/documenso/stargazers"><img src="https://img.shields.io/github/stars/documenso/documenso" alt="Github Stars"></a>
|
||||
<a href="https://github.com/documenso/documenso/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<a href="https://github.com/documenso/documenso/pulse"><img src="https://img.shields.io/github/commit-activity/m/documenso/documenso" alt="Commits-per-month"></a>
|
||||
@@ -229,12 +227,11 @@ docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --nam
|
||||
```
|
||||
|
||||
Command Breakdown:
|
||||
|
||||
- `-d` - Let's you run the container in background
|
||||
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
|
||||
- `-v` - Volume let's you persist the data
|
||||
- `--name` - Name of the container
|
||||
- `documenso:latest` - Image you have built
|
||||
- `documenso:latest` - Image you have built
|
||||
|
||||
# Deployment
|
||||
|
||||
@@ -280,5 +277,5 @@ containers:
|
||||
- start
|
||||
- --
|
||||
- -H
|
||||
- '::'
|
||||
- "::"
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const { withContentlayer } = require('next-contentlayer');
|
||||
|
||||
require('dotenv').config({
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
@@ -10,13 +10,9 @@ require('dotenv').config({
|
||||
const config = {
|
||||
experimental: {
|
||||
serverActions: true,
|
||||
serverActionsBodySizeLimit: '10mb',
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||
env: {
|
||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,16 +19,14 @@
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.277.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.77.3",
|
||||
"react": "18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
|
||||
@@ -66,7 +66,7 @@ export default async function OpenPage() {
|
||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||
<div className="mx-auto mt-12 max-w-screen-lg">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export type PricingPageProps = {
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Pricing</h1>
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
|
||||
import SinglePlayerModeSuccess from '~/components/(marketing)/single-player-mode/single-player-mode-success';
|
||||
|
||||
export type SinglePlayerModeSuccessPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function SinglePlayerModeSuccessPage({
|
||||
params: { token },
|
||||
}: SinglePlayerModeSuccessPageProps) {
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || document.status !== 'COMPLETED') {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <SinglePlayerModeSuccess document={document} />;
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||
|
||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||
|
||||
export default function SinglePlayerModePage() {
|
||||
const analytics = useAnalytics();
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [uploadedFile, setUploadedFile] = useState<{ name: string; file: string } | null>();
|
||||
|
||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||
const [fields, setFields] = useState<Field[]>([]);
|
||||
|
||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||
fields: {
|
||||
title: 'Add document',
|
||||
description: 'Upload a document and add fields.',
|
||||
stepIndex: 1,
|
||||
onBackStep: uploadedFile
|
||||
? () => {
|
||||
setUploadedFile(null);
|
||||
setFields([]);
|
||||
}
|
||||
: undefined,
|
||||
onNextStep: () => setStep('sign'),
|
||||
},
|
||||
sign: {
|
||||
title: 'Sign',
|
||||
description: 'Enter your details.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('fields'),
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
useEffect(() => {
|
||||
analytics.startSessionRecording('marketing_session_recording_spm');
|
||||
|
||||
return () => {
|
||||
analytics.stopSessionRecording();
|
||||
};
|
||||
}, [analytics]);
|
||||
|
||||
/**
|
||||
* Insert the selected fields into the local state.
|
||||
*/
|
||||
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFields(
|
||||
data.fields.map((field, i) => ({
|
||||
id: i,
|
||||
documentId: -1,
|
||||
recipientId: -1,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: new Prisma.Decimal(field.pageX),
|
||||
positionY: new Prisma.Decimal(field.pageY),
|
||||
width: new Prisma.Decimal(field.pageWidth),
|
||||
height: new Prisma.Decimal(field.pageHeight),
|
||||
customText: '',
|
||||
inserted: false,
|
||||
})),
|
||||
);
|
||||
|
||||
analytics.capture('Marketing: SPM - Fields added');
|
||||
|
||||
documentFlow.fields.onNextStep?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, sign and send the document.
|
||||
*/
|
||||
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const documentToken = await createSinglePlayerDocument({
|
||||
document: uploadedFile.file,
|
||||
documentName: uploadedFile.name,
|
||||
signer: data,
|
||||
fields: fields.map((field) => ({
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: field.positionX.toNumber(),
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
})),
|
||||
});
|
||||
|
||||
analytics.capture('Marketing: SPM - Document signed', {
|
||||
signer: data.email,
|
||||
});
|
||||
|
||||
router.push(`/single-player-mode/${documentToken}/success`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const placeholderRecipient: Recipient = {
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
email: '',
|
||||
name: '',
|
||||
token: '',
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
};
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64String = Buffer.from(arrayBuffer).toString('base64');
|
||||
|
||||
setUploadedFile({
|
||||
name: file.name,
|
||||
file: `data:application/pdf;base64,${base64String}`,
|
||||
});
|
||||
|
||||
analytics.capture('Marketing: SPM - Document uploaded');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 sm:mt-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
|
||||
|
||||
<p className="mt-4 text-lg leading-normal text-[#31373D]">
|
||||
View our{' '}
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
target="_blank"
|
||||
className="font-semibold transition-colors hover:text-[#31373D]/80"
|
||||
>
|
||||
community plan
|
||||
</Link>{' '}
|
||||
for exclusive features, including the ability to collaborate with multiple signers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid w-full grid-cols-12 gap-8">
|
||||
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
{uploadedFile ? (
|
||||
<Card gradient>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={uploadedFile.file} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{/* Add fields to PDF page. */}
|
||||
{step === 'fields' && (
|
||||
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
|
||||
<AddFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
hideRecipients={true}
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Enter user details and signature. */}
|
||||
{step === 'sign' && (
|
||||
<AddSignatureFormPartial
|
||||
documentFlow={documentFlow.sign}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
fields={fields}
|
||||
onSubmit={onSignSubmit}
|
||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||
/>
|
||||
)}
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
@@ -39,15 +32,9 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getAllAnonymousFlags();
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
@@ -55,15 +42,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
<PlausibleProvider>{children}</PlausibleProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||
<div className="absolute -inset-24 -z-10">
|
||||
<motion.div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||
>
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void router.back();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button className="w-32" asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/*',
|
||||
disallow: ['/_next/*'],
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
},
|
||||
],
|
||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 MiB |
@@ -10,7 +10,6 @@ import { usePlausible } from 'next-plausible';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -44,10 +43,8 @@ export type ClaimPlanDialogProps = {
|
||||
|
||||
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
|
||||
const params = useSearchParams();
|
||||
const analytics = useAnalytics();
|
||||
const event = usePlausible();
|
||||
|
||||
const { toast } = useToast();
|
||||
const event = usePlausible();
|
||||
|
||||
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
|
||||
|
||||
@@ -76,12 +73,10 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
]);
|
||||
|
||||
event('claim-plan-pricing');
|
||||
analytics.capture('Marketing: Claim plan', { planId, email });
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error) {
|
||||
event('claim-plan-failed');
|
||||
analytics.capture('Marketing: Claim plan failure', { planId, email });
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Confetti from 'react-confetti';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
|
||||
|
||||
export default function ConfettiScreen({
|
||||
numberOfPieces: numberOfPiecesProp = 200,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
|
||||
const isMounted = useIsMounted();
|
||||
const { width, height } = useWindowSize();
|
||||
|
||||
const [numberOfPieces, setNumberOfPieces] = useState(numberOfPiecesProp);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setNumberOfPieces(0);
|
||||
}, props.duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [props.duration]);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Confetti
|
||||
{...props}
|
||||
className="w-full"
|
||||
numberOfPieces={numberOfPieces}
|
||||
width={width}
|
||||
height={height}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ const SOCIAL_LINKS = [
|
||||
|
||||
const FOOTER_LINKS = [
|
||||
{ href: '/pricing', text: 'Pricing' },
|
||||
{ href: '/single-player-mode', text: 'Single Player Mode' },
|
||||
{ href: '/blog', text: 'Blog' },
|
||||
{ href: '/open', text: 'Open' },
|
||||
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { HTMLAttributes, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { HamburgerMenu } from './mobile-hamburger';
|
||||
@@ -16,26 +15,11 @@ export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isSinglePlayerModeMarketingEnabled = getFlag('marketing_header_single_player_mode');
|
||||
|
||||
return (
|
||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
{isSinglePlayerModeMarketingEnabled && (
|
||||
<Link
|
||||
href="/single-player-mode"
|
||||
className="bg-primary rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
|
||||
>
|
||||
Try now!
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-x-6 md:flex">
|
||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
|
||||
@@ -6,9 +6,7 @@ import Link from 'next/link';
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@@ -53,10 +51,6 @@ const HeroTitleVariants: Variants = {
|
||||
export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
const event = usePlausible();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const heroMarketingCTA = getFlag('marketing_landing_hero_cta');
|
||||
|
||||
const onSignUpClick = () => {
|
||||
const el = document.getElementById('email');
|
||||
|
||||
@@ -128,40 +122,23 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{match(heroMarketingCTA)
|
||||
.with('spm', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="border-primary mx-auto mt-8 w-fit rounded-xl border-2 bg-white transition-colors hover:bg-slate-50/60"
|
||||
>
|
||||
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
|
||||
<h2 className="text-xs font-semibold text-[#727272]">Single Player Mode</h2>
|
||||
<h1 className="font-semibold leading-5 text-[#606060]">Self sign documents here</h1>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
.with('productHunt', () => (
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
||||
>
|
||||
<Link
|
||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
<motion.div
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="mt-8 flex flex-col items-center justify-center gap-x-6 gap-y-4"
|
||||
>
|
||||
<Link
|
||||
href="https://www.producthunt.com/posts/documenso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-documenso"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=395047&theme=light&period=daily"
|
||||
alt="Documenso - The open source DocuSign alternative | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-12"
|
||||
|
||||
@@ -14,10 +14,6 @@ export type MobileNavigationProps = {
|
||||
};
|
||||
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/single-player-mode',
|
||||
text: 'Single Player Mode',
|
||||
},
|
||||
{
|
||||
href: '/blog',
|
||||
text: 'Blog',
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
Prisma,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
const ZCreateSinglePlayerDocumentSchema = z.object({
|
||||
document: z.string(),
|
||||
documentName: z.string(),
|
||||
signer: z.object({
|
||||
email: z.string().email().min(1),
|
||||
name: z.string(),
|
||||
signature: z.string(),
|
||||
}),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
page: z.number(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
||||
|
||||
/**
|
||||
* Create and self signs a document.
|
||||
*
|
||||
* Returns the document token.
|
||||
*/
|
||||
export const createSinglePlayerDocument = async (
|
||||
value: TCreateSinglePlayerDocumentSchema,
|
||||
): Promise<string> => {
|
||||
const { signer, fields, document, documentName } = ZCreateSinglePlayerDocumentSchema.parse(value);
|
||||
|
||||
const doc = await PDFDocument.load(document);
|
||||
const createdAt = new Date();
|
||||
|
||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||
const typedSignature = !isBase64 ? signer.signature : null;
|
||||
|
||||
// Update the document with the fields inserted.
|
||||
for (const field of fields) {
|
||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||
|
||||
await insertFieldInPDF(doc, {
|
||||
...mapField(field, signer),
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
created: createdAt,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
recipientId: -1,
|
||||
fieldId: -1,
|
||||
}
|
||||
: null,
|
||||
// Dummy data.
|
||||
id: -1,
|
||||
documentId: -1,
|
||||
recipientId: -1,
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
const documentToken = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const documentToken = nanoid();
|
||||
|
||||
// Fetch service user who will be the owner of the document.
|
||||
const serviceUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: SERVICE_USER_EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
|
||||
|
||||
const { id: documentDataId } = await tx.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: documentDataBytes,
|
||||
initialData: documentDataBytes,
|
||||
},
|
||||
});
|
||||
|
||||
// Create document.
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title: documentName,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId,
|
||||
userId: serviceUser.id,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Create recipient.
|
||||
const recipient = await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
name: signer.name,
|
||||
email: signer.email,
|
||||
token: documentToken,
|
||||
signedAt: createdAt,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
// Create fields and signatures.
|
||||
await Promise.all(
|
||||
fields.map(async (field) => {
|
||||
const insertedField = await tx.field.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
...mapField(field, signer),
|
||||
},
|
||||
});
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await tx.signature.create({
|
||||
data: {
|
||||
fieldId: insertedField.id,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return documentToken;
|
||||
},
|
||||
// Test values.
|
||||
{
|
||||
maxWait: 30000,
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
// Todo: Handle `downloadLink`
|
||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||
downloadLink: 'https://documenso.com',
|
||||
documentName: documentName,
|
||||
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
// Send email to signer.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: signer.email,
|
||||
name: signer.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
return documentToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map the fields provided by the user to fields compatible with Prisma.
|
||||
*
|
||||
* Signature fields are handled separately.
|
||||
*
|
||||
* @param field The field passed in by the user.
|
||||
* @param signer The details of the person who is signing this document.
|
||||
* @returns A field compatible with Prisma.
|
||||
*/
|
||||
const mapField = (
|
||||
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
||||
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
||||
) => {
|
||||
const customText = match(field.type)
|
||||
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||
.with(FieldType.EMAIL, () => signer.email)
|
||||
.with(FieldType.NAME, () => signer.name)
|
||||
.otherwise(() => '');
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: new Prisma.Decimal(field.positionX),
|
||||
positionY: new Prisma.Decimal(field.positionY),
|
||||
width: new Prisma.Decimal(field.width),
|
||||
height: new Prisma.Decimal(field.height),
|
||||
customText,
|
||||
inserted: true,
|
||||
};
|
||||
};
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Share } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
import ConfettiScreen from '~/components/(marketing)/confetti-screen';
|
||||
|
||||
import { DocumentStatus } from '.prisma/client';
|
||||
|
||||
interface SinglePlayerModeSuccessProps {
|
||||
className?: string;
|
||||
document: DocumentWithRecipient;
|
||||
}
|
||||
|
||||
export default function SinglePlayerModeSuccess({
|
||||
className,
|
||||
document,
|
||||
}: SinglePlayerModeSuccessProps) {
|
||||
const [showDocumentDialog, setShowDocumentDialog] = useState(false);
|
||||
const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
|
||||
const [documentFile, setDocumentFile] = useState<string | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleShowDocumentDialog = async () => {
|
||||
if (isFetchingDocumentFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingDocumentFile(true);
|
||||
|
||||
try {
|
||||
const data = await getFile(document.documentData);
|
||||
|
||||
setDocumentFile(Buffer.from(data).toString('base64'));
|
||||
|
||||
setShowDocumentDialog(true);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong.',
|
||||
description: 'We were unable to retrieve the document at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFetchingDocumentFile(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-10rem)] flex-col items-center justify-center sm:min-h-[calc(100vh-13rem)]">
|
||||
<ConfettiScreen duration={3000} gravity={0.075} initialVelocityY={50} wind={0.005} />
|
||||
|
||||
<h2 className="text-center text-2xl font-semibold leading-normal md:text-3xl lg:mb-2 lg:text-4xl">
|
||||
You have signed
|
||||
</h2>
|
||||
<h3 className="text-foreground/80 mb-6 text-center text-lg font-semibold md:text-xl lg:mb-8 lg:text-3xl">
|
||||
{document.title}
|
||||
</h3>
|
||||
|
||||
<SigningCard3D
|
||||
name={document.Recipient.name || document.Recipient.email}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<div className="mt-8 w-full">
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
{/* TODO: Hook this up */}
|
||||
<Button variant="outline" className="flex-1" disabled>
|
||||
<Share className="mr-2 h-5 w-5" />
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={document.documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={async () => handleShowDocumentDialog()}
|
||||
loading={isFetchingDocumentFile}
|
||||
className="col-span-2"
|
||||
>
|
||||
Show document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-center text-sm">
|
||||
View the{' '}
|
||||
<Link
|
||||
href="/pricing"
|
||||
target="_blank"
|
||||
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||
>
|
||||
community plan
|
||||
</Link>{' '}
|
||||
to access the full range of features provided by Documenso
|
||||
</p>
|
||||
|
||||
<DocumentDialog
|
||||
document={documentFile ?? ''}
|
||||
open={showDocumentDialog}
|
||||
onOpenChange={setShowDocumentDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagAll;
|
||||
@@ -1,7 +0,0 @@
|
||||
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagGet;
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function PostHogPageview() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
if (typeof window !== 'undefined' && postHogConfig) {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
disable_session_recording: true,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!postHogConfig || !pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}, [pathname, searchParams, postHogConfig]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
|
||||
require('dotenv').config({
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ const config = {
|
||||
],
|
||||
env: {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,10 +22,9 @@
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.277.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-plausible": "^3.10.1",
|
||||
@@ -50,4 +50,4 @@
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 743 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -55,18 +55,21 @@ export const EditDocumentForm = ({
|
||||
title: 'Add Signers',
|
||||
description: 'Add the people who will sign the document.',
|
||||
stepIndex: 1,
|
||||
onSubmit: () => onAddSignersFormSubmit,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
onBackStep: () => setStep('signers'),
|
||||
onSubmit: () => onAddFieldsFormSubmit,
|
||||
},
|
||||
subject: {
|
||||
title: 'Add Subject',
|
||||
description: 'Add the subject and message you wish to send to signers.',
|
||||
stepIndex: 3,
|
||||
onBackStep: () => setStep('fields'),
|
||||
onSubmit: () => onAddSubjectFormSubmit,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
|
||||
export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
@@ -18,11 +22,16 @@ export type DataTableActionButtonProps = {
|
||||
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
@@ -32,6 +41,20 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
const onShareClick = async () => {
|
||||
const { slug } = await createOrGetShareLink({
|
||||
token: recipient?.token,
|
||||
documentId: row.id,
|
||||
});
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The sharing link has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
@@ -57,8 +80,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Button className="w-24" disabled>
|
||||
<Share className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
|
||||
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Share
|
||||
</Button>
|
||||
));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Download,
|
||||
Edit,
|
||||
History,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Share,
|
||||
@@ -18,7 +19,8 @@ import { useSession } from 'next-auth/react';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc } from '@documenso/trpc/client';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -26,6 +28,9 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
@@ -36,11 +41,16 @@ export type DataTableActionDropdownProps = {
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||
trpcReact.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
@@ -50,15 +60,29 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
const onShareClick = async () => {
|
||||
const { slug } = await createOrGetShareLink({
|
||||
token: recipient?.token,
|
||||
documentId: row.id,
|
||||
});
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The sharing link has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
let document: DocumentWithData | null = null;
|
||||
|
||||
if (!recipient) {
|
||||
document = await trpc.document.getDocumentById.query({
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpc.document.getDocumentByToken.query({
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
}
|
||||
@@ -135,8 +159,12 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuItem onClick={onShareClick}>
|
||||
{isCreatingShareLink ? (
|
||||
<Loader className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||
|
||||
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
icon: Icon,
|
||||
} = match(status)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
title: 'Nothing to do',
|
||||
message:
|
||||
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
title: 'No active drafts',
|
||||
message:
|
||||
'There are no active drafts at then current moment. You can upload a document to start drafting.',
|
||||
icon: CheckCircle2,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
title: "We're all empty",
|
||||
message:
|
||||
'You have not yet created or received any documents. To create a document please upload one.',
|
||||
icon: Bird,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: 'Nothing to do',
|
||||
message:
|
||||
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
|
||||
icon: CheckCircle2,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
|
||||
<p className="mt-2 max-w-[60ch]">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/ty
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
@@ -96,7 +97,8 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DocumentsDataTable results={results} />
|
||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ImageResponse } from 'next/server';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { getAssetBuffer } from '~/helpers/get-asset-buffer';
|
||||
|
||||
const CARD_OFFSET_TOP = 152;
|
||||
const CARD_OFFSET_LEFT = 350;
|
||||
const CARD_WIDTH = 500;
|
||||
const CARD_HEIGHT = 250;
|
||||
|
||||
const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
type SharePageOpenGraphImageProps = {
|
||||
params: { slug: string };
|
||||
};
|
||||
|
||||
export default async function Image({ params: { slug } }: SharePageOpenGraphImageProps) {
|
||||
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
|
||||
getAssetBuffer('/fonts/inter-semibold.ttf'),
|
||||
getAssetBuffer('/fonts/inter-regular.ttf'),
|
||||
getAssetBuffer('/fonts/caveat-regular.ttf'),
|
||||
getAssetBuffer('/static/og-share-frame.png'),
|
||||
]);
|
||||
|
||||
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
|
||||
|
||||
if (!recipientOrSender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isRecipient = 'Signature' in recipientOrSender;
|
||||
|
||||
const signatureImage = match(recipientOrSender)
|
||||
.with({ Signature: P.array(P._) }, (recipient) => {
|
||||
return recipient.Signature?.[0]?.signatureImageAsBase64 || null;
|
||||
})
|
||||
.otherwise((sender) => {
|
||||
return sender.signature || null;
|
||||
});
|
||||
|
||||
const signatureName = match(recipientOrSender)
|
||||
.with({ Signature: P.array(P._) }, (recipient) => {
|
||||
return recipient.name || recipient.email;
|
||||
})
|
||||
.otherwise((sender) => {
|
||||
return sender.name || sender.email;
|
||||
});
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="relative flex h-full w-full">
|
||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
|
||||
|
||||
<div tw="absolute top-20 flex w-full items-center justify-center">
|
||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||
<Logo tw="h-8 w-60" />
|
||||
</div>
|
||||
|
||||
{signatureImage ? (
|
||||
<div
|
||||
tw="absolute py-6 px-12 flex items-center justify-center text-center"
|
||||
style={{
|
||||
top: `${CARD_OFFSET_TOP}px`,
|
||||
left: `${CARD_OFFSET_LEFT}px`,
|
||||
width: `${CARD_WIDTH}px`,
|
||||
height: `${CARD_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<img src={signatureImage} alt="signature" tw="opacity-60 h-full max-w-[100%]" />
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
tw="absolute py-6 px-12 -mt-2 flex items-center justify-center text-center text-slate-500"
|
||||
style={{
|
||||
fontFamily: 'Caveat',
|
||||
fontSize: `${Math.max(
|
||||
Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80),
|
||||
36,
|
||||
)}px`,
|
||||
top: `${CARD_OFFSET_TOP}px`,
|
||||
left: `${CARD_OFFSET_LEFT}px`,
|
||||
width: `${CARD_WIDTH}px`,
|
||||
height: `${CARD_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
{signatureName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* <div
|
||||
tw="absolute flex items-center justify-center text-slate-500"
|
||||
style={{
|
||||
top: `${CARD_OFFSET_TOP + CARD_HEIGHT - 45}px`,
|
||||
left: `${CARD_OFFSET_LEFT}`,
|
||||
width: `${CARD_WIDTH}px`,
|
||||
fontSize: '30px',
|
||||
}}
|
||||
>
|
||||
{signatureName}
|
||||
</div> */}
|
||||
|
||||
<div
|
||||
tw="absolute flex flex-col items-center justify-center pt-12 w-full"
|
||||
style={{
|
||||
top: `${CARD_OFFSET_TOP + CARD_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
tw="text-3xl text-slate-500"
|
||||
style={{
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isRecipient
|
||||
? 'I just signed with Documenso and you can too!'
|
||||
: 'I just sent a document with Documenso and you can too!'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Caveat',
|
||||
data: caveatRegular,
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interRegular,
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interSemiBold,
|
||||
style: 'normal',
|
||||
weight: 600,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { Redirect } from './redirect';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documenso - Share',
|
||||
};
|
||||
|
||||
export default function SharePage() {
|
||||
return <Redirect />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const Redirect = () => {
|
||||
useEffect(() => {
|
||||
window.location.href = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
documentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const DocumentDownloadButton = ({
|
||||
export const DownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
documentData,
|
||||
@@ -1,18 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8, Share } from 'lucide-react';
|
||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { SigningCard } from '@documenso/ui/components/signing-card';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
import { DownloadButton } from './download-button';
|
||||
import { ShareButton } from './share-button';
|
||||
import { SigningCard } from './signing-card';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
@@ -54,7 +53,7 @@ export default async function CompletedSigningPage({
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard name={recipientName} signingCelebrationImage={signingCelebration} />
|
||||
<SigningCard name={recipientName} />
|
||||
|
||||
<div className="mt-6">
|
||||
{match(document.status)
|
||||
@@ -89,13 +88,9 @@ export default async function CompletedSigningPage({
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
{/* TODO: Hook this up */}
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Share className="mr-2 h-5 w-5" />
|
||||
Share
|
||||
</Button>
|
||||
<ShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
<DocumentDownloadButton
|
||||
<DownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
@@ -104,7 +99,7 @@ export default async function CompletedSigningPage({
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
Want so send slick signing links like this one?{' '}
|
||||
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Share } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
|
||||
|
||||
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
token: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { mutateAsync: createOrGetShareLink, isLoading } =
|
||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
const onShareClick = async () => {
|
||||
const { slug } = await createOrGetShareLink({
|
||||
token: token,
|
||||
documentId,
|
||||
});
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The sharing link has been copied to your clipboard.',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!token || !documentId}
|
||||
loading={isLoading}
|
||||
onClick={onShareClick}
|
||||
>
|
||||
{!isLoading && <Share className="mr-2 h-5 w-5" />}
|
||||
Share
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
export type SigningCardProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const SigningCard = ({ name }: SigningCardProps) => {
|
||||
return (
|
||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
||||
<Card
|
||||
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
|
||||
degrees={-145}
|
||||
gradient
|
||||
>
|
||||
<CardContent
|
||||
className="font-signature p-6 text-center"
|
||||
style={{
|
||||
container: 'main',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<motion.div
|
||||
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{
|
||||
delay: 0.5,
|
||||
duration: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={signingCelebration}
|
||||
alt="background pattern"
|
||||
className="w-full"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -38,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
viewedDocument({ token }),
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
loading?: boolean;
|
||||
|
||||
@@ -2,15 +2,15 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||
import { ThemeProvider } from '~/providers/next-theme';
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import NotFoundPartial from '~/components/partials/not-found';
|
||||
|
||||
export default async function NotFound() {
|
||||
const session = await getServerComponentSession();
|
||||
|
||||
return (
|
||||
<NotFoundPartial>
|
||||
{session && (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href="/documents">Documents</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href="/signin">Sign In</Link>
|
||||
</Button>
|
||||
)}
|
||||
</NotFoundPartial>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 743 KiB |
@@ -10,13 +10,13 @@ import {
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
@@ -27,17 +27,25 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
@@ -97,28 +105,30 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{theme === 'light' ? null : (
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
Light Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{theme === 'dark' ? null : (
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{theme === 'system' ? null : (
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System Theme
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Themes
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Sun className="mr-2 h-4 w-4" /> Light
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -7,10 +7,11 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
|
||||
@@ -7,10 +7,11 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useFeatureFlags } from '~/providers/feature-flag';
|
||||
|
||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
@@ -8,12 +9,20 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
|
||||
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { id: userId } = await getRequiredServerComponentSession();
|
||||
|
||||
await sendDocument({
|
||||
if (email.message || email.subject) {
|
||||
await upsertDocumentMeta({
|
||||
documentId,
|
||||
subject: email.subject,
|
||||
message: email.message,
|
||||
});
|
||||
}
|
||||
|
||||
return await sendDocument({
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export * from 'framer-motion';
|
||||
|
||||
export const MotionDiv = motion.div;
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
|
||||
export type NotFoundPartialProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function NotFoundPartial({ children }: NotFoundPartialProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||
<div className="absolute -inset-24 -z-10">
|
||||
<motion.div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||
>
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void router.back();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* getAssetBuffer is used to retrieve array buffers for various assets
|
||||
* that are hosted in the `public` folder.
|
||||
*
|
||||
* This exists due to a breakage with `import.meta.url` imports and open graph images,
|
||||
* once we can identify a fix for this, we can remove this helper.
|
||||
*
|
||||
* @param path The path to the asset, relative to the `public` folder.
|
||||
*/
|
||||
export const getAssetBuffer = async (path: string) => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
|
||||
};
|
||||
+3
-28
@@ -1,12 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
TFeatureFlagValue,
|
||||
ZFeatureFlagValueSchema,
|
||||
} from '@documenso/lib/client-only/providers/feature-flag.types';
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user.
|
||||
*
|
||||
@@ -57,7 +54,7 @@ export const getAllFlags = async (
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
@@ -72,28 +69,6 @@ export const getAllFlags = async (
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for anonymous users.
|
||||
*
|
||||
* @returns A record of flags and their values.
|
||||
*/
|
||||
export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
||||
};
|
||||
|
||||
interface GetFlagOptions {
|
||||
/**
|
||||
* The headers to attach to the request to evaluate flags.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getAllFlags, getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { getAllFlags, getFlag } from './get-feature-flag';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user in a server component.
|
||||
@@ -1,7 +1,44 @@
|
||||
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagAll;
|
||||
/**
|
||||
* Get all the evaluated feature flags based on the current user if possible.
|
||||
*/
|
||||
export default async function handler(req: Request) {
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is not enabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlags);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,122 @@
|
||||
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
export default handlerFeatureFlagGet;
|
||||
/**
|
||||
* Evaluate a single feature flag based on the current user if possible.
|
||||
*
|
||||
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
|
||||
* @returns A Response with the feature flag value.
|
||||
*/
|
||||
export default async function handler(req: Request) {
|
||||
const { searchParams } = new URL(req.url ?? '');
|
||||
const flag = searchParams.get('flag');
|
||||
|
||||
const requestHeaders = Object.fromEntries(req.headers.entries());
|
||||
|
||||
const nextReq = new NextRequest(req, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({ req: nextReq });
|
||||
|
||||
if (!flag) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing flag query parameter.',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
// Return the local feature flags if PostHog is not enabled, true by default.
|
||||
// The front end should not call this API if PostHog is disabled to reduce network requests.
|
||||
if (!postHog) {
|
||||
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
|
||||
}
|
||||
|
||||
const distinctId = extractDistinctUserId(token, nextReq);
|
||||
|
||||
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
|
||||
|
||||
const res = NextResponse.json(featureFlag);
|
||||
|
||||
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @returns A map of properties which are consumed by PostHog.
|
||||
*/
|
||||
export const mapJwtToFlagProperties = (
|
||||
jwt?: JWT | null,
|
||||
): {
|
||||
groups?: Record<string, string>;
|
||||
personProperties?: Record<string, string>;
|
||||
groupProperties?: Record<string, Record<string, string>>;
|
||||
} => {
|
||||
return {
|
||||
personProperties: {
|
||||
email: jwt?.email ?? '',
|
||||
},
|
||||
groupProperties: {
|
||||
// Add properties to group users into different groups, such as billing plan.
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a distinct ID from a JWT and request.
|
||||
*
|
||||
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
|
||||
*
|
||||
* @param jwt The JWT of the current user.
|
||||
* @param request Request potentially containing a PostHog `distinct_id` cookie.
|
||||
* @returns A distinct user ID.
|
||||
*/
|
||||
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
|
||||
const config = extractPostHogConfig();
|
||||
|
||||
const email = jwt?.email;
|
||||
const userId = jwt?.id.toString();
|
||||
|
||||
let fallbackDistinctId = nanoid();
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const postHogCookie = JSON.parse(
|
||||
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
|
||||
);
|
||||
|
||||
const postHogDistinctId = postHogCookie['distinct_id'];
|
||||
|
||||
if (typeof postHogDistinctId === 'string') {
|
||||
fallbackDistinctId = postHogDistinctId;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
return email ?? userId ?? fallbackDistinctId;
|
||||
};
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ import {
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
|
||||
import { getAllFlags } from '~/helpers/get-feature-flag';
|
||||
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
Generated
+328
-54
@@ -22,6 +22,7 @@
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^14.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -41,16 +42,14 @@
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"contentlayer": "^0.3.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.277.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.77.3",
|
||||
"react": "18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
@@ -80,10 +79,9 @@
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.277.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-plausible": "^3.10.1",
|
||||
@@ -2447,9 +2445,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
|
||||
"integrity": "sha512-tgK3nWlfFLlqhqpXZmFMP3RN5E7mlbGfnM2h2ILVsW1TNGuFSod0ePW0grlIY2GAbL4pJdtmOT4HQSZsTwOiKg==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.1.tgz",
|
||||
"integrity": "sha512-tS16bAUkqjITNSvbJuO1x7MXbn7Oe8ZziDTJdA9mMvsoYthnOOiznOTGBYwbdlYBgU+tgpI/BtTU3paRbCuSlg==",
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.0.0"
|
||||
}
|
||||
@@ -2531,6 +2529,96 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
|
||||
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
||||
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||
@@ -2749,6 +2837,20 @@
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdx-js/esbuild": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/esbuild/-/esbuild-2.3.0.tgz",
|
||||
@@ -3649,6 +3751,16 @@
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/utils": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz",
|
||||
@@ -6275,9 +6387,9 @@
|
||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.2.tgz",
|
||||
"integrity": "sha512-l5cpE57br4BIjK+9BSkFBOsWtwv6J9bJpC7gdXIzZyI0vuKvNTk0wZZrkQxMGsUAuGW9+WMNWF2IJMD7br2yeQ==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
||||
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
@@ -10445,6 +10557,20 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache/node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
|
||||
@@ -10477,6 +10603,34 @@
|
||||
"is-callable": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
|
||||
"integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.0",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
@@ -11960,6 +12114,24 @@
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.3.tgz",
|
||||
"integrity": "sha512-R2bUw+kVZFS/h1AZqBKrSgDmdmjApzgY0AlCPumopFiAlbUxE2gf+SCuBzQ0cP5hHmUmFYF5yw55T97Th5Kstg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/javascript-natural-sort": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
|
||||
@@ -12688,17 +12860,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.277.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.277.0.tgz",
|
||||
"integrity": "sha512-9epmznme+vW14V9d2rsMeLr3fMnf59lYDUOVUg6s7oVN22Zq8h4B30+3CIdFFV9UXCjPG5ZNKHfO/hf96cl46A==",
|
||||
"version": "0.279.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.279.0.tgz",
|
||||
"integrity": "sha512-LJ8g66+Bxc3t3x9vKTeK3wn3xucrOQGfJ9ou9GsBwCt2offsrT2BB90XrTrIzE1noYYDe2O8jZaRHi6sAHXNxw==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-uBoAVCVcajsrqy3pv7eo5jEUz1oeLmCcnMv8n4AJpT5hbpN9lUssAXibNElpbLce3Mhm9dyBzwYLs9zctM/0tA==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.0.tgz",
|
||||
"integrity": "sha512-7eDo4Pt7aGhoCheGFIuq4Xa2fJm4ZpmldpGhjTYBNUYNCN6TIEP6v7chwwwt3KRp7YR+rghbfvjyo3V5y9hgBw==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -14752,6 +14924,31 @@
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
|
||||
"integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^9.1.1 || ^10.0.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz",
|
||||
"integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
@@ -15042,9 +15239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.77.3",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.77.3.tgz",
|
||||
"integrity": "sha512-DKsGpBIUjQSihhGruEW8wpVCkeDxU4jz7gADdXX2jEWV6bl4WpUPxjo1ukidVDFvvc/ihCM5PQWMQrItexdpSA==",
|
||||
"version": "1.75.3",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.75.3.tgz",
|
||||
"integrity": "sha512-q5xP4R/Tx8E6H0goZQjY+URMLATFiYXc2raHA+31aNvpBs118fPTmExa4RK6MgRZDFhBiMUBZNT6aj7dM3SyUQ==",
|
||||
"dependencies": {
|
||||
"fflate": "^0.4.1"
|
||||
}
|
||||
@@ -15558,20 +15755,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-confetti": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
|
||||
"integrity": "sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==",
|
||||
"dependencies": {
|
||||
"tween-functions": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.3.0 || ^17.0.1 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.8.0.tgz",
|
||||
@@ -16102,9 +16285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.45.4",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
|
||||
"integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==",
|
||||
"version": "7.45.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.2.tgz",
|
||||
"integrity": "sha512-9s45OdTaKN+4NSTbXVqeDITd/nwIg++nxJGL8+OD5uf1DxvhsXQ641kaYHk5K28cpIOTYm71O/fYk7rFaygb3A==",
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
@@ -16834,14 +17017,64 @@
|
||||
"integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA=="
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.1.tgz",
|
||||
"integrity": "sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
"glob": "^10.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
"rimraf": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "10.3.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.5.tgz",
|
||||
"integrity": "sha512-bYUpUD7XDEHI4Q2O5a7PXGvyw4deKR70kHiDxzQbe925wbZknhOzUt2xBgTkYL6RBcVeXYuD9iNYeqoWbBZQnA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^2.0.3",
|
||||
"minimatch": "^9.0.1",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
|
||||
"path-scurry": "^1.10.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -17452,6 +17685,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -17541,6 +17795,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -18652,11 +18919,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/tween-functions": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz",
|
||||
"integrity": "sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA=="
|
||||
},
|
||||
"node_modules/typanion": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/typanion/-/typanion-3.13.0.tgz",
|
||||
@@ -19308,6 +19570,24 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -19567,7 +19847,6 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@radix-ui/react-accordion": "^1.1.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.3",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
||||
@@ -19588,7 +19867,6 @@
|
||||
"@radix-ui/react-select": "^1.2.1",
|
||||
"@radix-ui/react-separator": "^1.0.2",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
@@ -19599,21 +19877,17 @@
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "^0.2.0",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.277.0",
|
||||
"luxon": "^3.4.2",
|
||||
"lucide-react": "^0.279.0",
|
||||
"next": "13.4.19",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "^7.3.3",
|
||||
"react-rnd": "^10.4.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/luxon": "^3.3.2",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"react": "18.2.0",
|
||||
|
||||
+3
-1
@@ -7,7 +7,8 @@
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
"prepare": "husky install",
|
||||
"commitlint": "commitlint --edit"
|
||||
"commitlint": "commitlint --edit",
|
||||
"clean": "turbo run clean && rimraf node_modules"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^14.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
"name": "@documenso/root",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"server-only/",
|
||||
"universal/"
|
||||
],
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"clean": "rimraf node_modules",
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateDocumentCompletedProps {
|
||||
downloadLink: string;
|
||||
reviewLink: string;
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentCompleted = ({
|
||||
downloadLink,
|
||||
reviewLink,
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentCompletedProps) => {
|
||||
@@ -27,11 +29,23 @@ export const TemplateDocumentCompleted = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
<Section>
|
||||
<Row className="table-fixed">
|
||||
<Column />
|
||||
|
||||
<Column>
|
||||
<Img
|
||||
className="h-42 mx-auto"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</Column>
|
||||
|
||||
<Column />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||
Completed
|
||||
@@ -42,17 +56,17 @@ export const TemplateDocumentCompleted = ({
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by downloading the document.
|
||||
Continue by downloading or reviewing the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
{/* <Button
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Review
|
||||
</Button> */}
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
@@ -30,13 +30,26 @@ export const TemplateDocumentInvite = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
<Section className="mt-4">
|
||||
<Row className="table-fixed">
|
||||
<Column />
|
||||
|
||||
<Column>
|
||||
<Img
|
||||
className="h-42 mx-auto"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</Column>
|
||||
|
||||
<Column />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to sign "{documentName}"
|
||||
{inviterName} has invited you to sign
|
||||
<br />"{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
import { Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
@@ -25,11 +25,23 @@ export const TemplateDocumentPending = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
<Section>
|
||||
<Row className="table-fixed">
|
||||
<Column />
|
||||
|
||||
<Column>
|
||||
<Img
|
||||
className="h-42 mx-auto"
|
||||
src={getAssetUrl('/static/document.png')}
|
||||
alt="Documenso"
|
||||
/>
|
||||
</Column>
|
||||
|
||||
<Column />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||
Waiting for others
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateDocumentSelfSignedProps {
|
||||
downloadLink: string;
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentSelfSigned = ({
|
||||
downloadLink,
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentSelfSignedProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||
Completed
|
||||
</Text>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
You have signed “{documentName}”
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Check out our plans to access the full suite of features.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href="https://documenso.com/pricing"
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
View plans
|
||||
</Button>
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentSelfSigned;
|
||||
@@ -21,6 +21,7 @@ export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentComple
|
||||
|
||||
export const DocumentCompletedEmailTemplate = ({
|
||||
downloadLink = 'https://documenso.com',
|
||||
reviewLink = 'https://documenso.com',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentCompletedEmailTemplateProps) => {
|
||||
@@ -55,6 +56,7 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
|
||||
<TemplateDocumentCompleted
|
||||
downloadLink={downloadLink}
|
||||
reviewLink={reviewLink}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
} from '../template-components/template-document-invite';
|
||||
import TemplateFooter from '../template-components/template-footer';
|
||||
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
customBody?: string;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
inviterName = 'Lucas Smith',
|
||||
@@ -28,6 +30,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
signDocumentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
|
||||
@@ -78,7 +81,11 @@ export const DocumentInviteEmailTemplate = ({
|
||||
</Text>
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
{inviterName} has invited you to sign the document "{documentName}".
|
||||
{customBody ? (
|
||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||
) : (
|
||||
`${inviterName} has invited you to sign the document "${documentName}".`
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentSelfSigned,
|
||||
TemplateDocumentSelfSignedProps,
|
||||
} from '../template-components/template-document-self-signed';
|
||||
import TemplateFooter from '../template-components/template-footer';
|
||||
|
||||
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
|
||||
|
||||
export const DocumentSelfSignedEmailTemplate = ({
|
||||
downloadLink = 'https://documenso.com',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentSelfSignedTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateDocumentSelfSigned
|
||||
downloadLink={downloadLink}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentSelfSignedEmailTemplate;
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./index.cjs",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
@@ -15,4 +18,4 @@
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { posthog } from 'posthog-js';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import {
|
||||
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
|
||||
extractPostHogConfig,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function useAnalytics() {
|
||||
const featureFlags = useFeatureFlags();
|
||||
const isPostHogEnabled = extractPostHogConfig();
|
||||
|
||||
/**
|
||||
* Capture an analytic event.
|
||||
*
|
||||
* @param event The event name.
|
||||
* @param properties Properties to attach to the event.
|
||||
*/
|
||||
const capture = (event: string, properties?: Record<string, unknown>) => {
|
||||
if (!isPostHogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture(event, properties);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the session recording.
|
||||
*
|
||||
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
|
||||
*/
|
||||
const startSessionRecording = (eventFlag?: string) => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.startSessionRecording();
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the current session recording.
|
||||
*/
|
||||
const stopSessionRecording = () => {
|
||||
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
|
||||
|
||||
if (!isPostHogEnabled || !isSessionRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.stopSessionRecording();
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
startSessionRecording,
|
||||
stopSessionRecording,
|
||||
};
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Calculate the width and height of a text element.
|
||||
*
|
||||
* @param text The text to calculate the width and height of.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns the width and height of the text.
|
||||
*/
|
||||
function calculateTextDimensions(
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
): { width: number; height: number } {
|
||||
// Reuse old canvas if available.
|
||||
let canvas = (calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas;
|
||||
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
(calculateTextDimensions as { canvas?: HTMLCanvasElement }).canvas = canvas;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
context.font = `${fontSize} ${fontFamily}`;
|
||||
const metrics = context.measureText(text);
|
||||
|
||||
return {
|
||||
width: metrics.width,
|
||||
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scaling size to apply to a text to fit it within a container.
|
||||
*
|
||||
* @param container The container dimensions to fit the text within.
|
||||
* @param text The text to fit within the container.
|
||||
* @param fontSize The font size to apply to the text.
|
||||
* @param fontFamily The font family to apply to the text.
|
||||
* @returns Returns a value between 0 and 1 which represents the scaling factor to apply to the text.
|
||||
*/
|
||||
export const calculateTextScaleSize = (
|
||||
container: { width: number; height: number },
|
||||
text: string,
|
||||
fontSize: string,
|
||||
fontFamily: string,
|
||||
) => {
|
||||
const { width, height } = calculateTextDimensions(text, fontSize, fontFamily);
|
||||
return Math.min(container.width / width, container.height / height, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a container and child element, calculate the scaling size to apply to the child.
|
||||
*/
|
||||
export function useElementScaleSize(
|
||||
container: { width: number; height: number },
|
||||
child: RefObject<HTMLElement | null>,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
) {
|
||||
const [scalingFactor, setScalingFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!child.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleSize = calculateTextScaleSize(
|
||||
container,
|
||||
child.current.innerText,
|
||||
`${fontSize}px`,
|
||||
fontFamily,
|
||||
);
|
||||
|
||||
setScalingFactor(scaleSize);
|
||||
}, [child, container, fontFamily, fontSize]);
|
||||
|
||||
return scalingFactor;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useIsMounted = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
return isMounted;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
|
||||
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
|
||||
|
||||
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
||||
|
||||
export const APP_BASE_URL = IS_APP_WEB
|
||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||
@@ -1,4 +0,0 @@
|
||||
export const FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
export const FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
|
||||
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* The flag name for global session recording feature flag.
|
||||
*/
|
||||
export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
|
||||
|
||||
/**
|
||||
* How frequent to poll for new feature flags in milliseconds.
|
||||
*/
|
||||
@@ -15,7 +10,6 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
marketing_header_single_player_mode: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { APP_BASE_URL } from './app';
|
||||
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
|
||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||
export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
|
||||
@@ -10,7 +10,9 @@
|
||||
"universal/",
|
||||
"next-auth/"
|
||||
],
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
@@ -36,4 +38,4 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
documentId: number;
|
||||
subject: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const upsertDocumentMeta = async ({
|
||||
subject,
|
||||
message,
|
||||
documentId,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
return await prisma.documentMeta.upsert({
|
||||
where: {
|
||||
documentId,
|
||||
},
|
||||
create: {
|
||||
subject,
|
||||
message,
|
||||
documentId,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -69,6 +70,19 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.count({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingRecipients > 0) {
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
}
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
where: {
|
||||
id: document.id,
|
||||
|
||||
@@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface GetDocumentAndRecipientByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
}: GetDocumentAndSenderByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
@@ -38,33 +29,3 @@ export const getDocumentAndSenderByToken = async ({
|
||||
User,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a Document and a Recipient by the recipient token.
|
||||
*/
|
||||
export const getDocumentAndRecipientByToken = async ({
|
||||
token,
|
||||
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
Recipient: result.Recipient[0],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
@@ -86,4 +87,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
|
||||
await sendCompletedEmail({ documentId });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
}
|
||||
|
||||
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -3,14 +3,14 @@ import { createElement } from 'react';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -26,9 +26,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
@@ -45,6 +48,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
if (recipient.sendStatus === SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
@@ -58,6 +67,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
@@ -66,10 +76,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Please sign this document',
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const [recipient] = document.Recipient;
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Waiting for others to complete signing.',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user