Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
7b28ba968e feat: wip 2023-09-11 09:45:03 +00:00
Ephraim Atta-Duncan
d4f6fa7dc4 feat: add templates model 2023-09-07 08:25:15 +00:00
50 changed files with 1131 additions and 531 deletions

View File

@@ -1,20 +0,0 @@
{
"name": "Documenso",
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"enableNonRootDocker": "true",
"moby": "true"
},
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "./.devcontainer/on-create.sh",
"forwardPorts": [
3000,
54320,
9000,
2500,
1100
]
}

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Start the database and mailserver
docker compose -f ./docker/compose-without-app.yml up -d
# Install dependencies
npm install
# Copy the env file
cp .env.example .env
# Source the env file, export the variables
set -a
source .env
set +a
# Run the migrations
npm run -w @documenso/prisma prisma:migrate-dev

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env bash
npm run dev

View File

@@ -12,7 +12,7 @@ tags:
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Today, I'm pleased to share with you a preview of the next Documenso.

View File

@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
@@ -19,10 +18,7 @@ const config = {
'@documenso/ui',
'@documenso/email',
],
env: {
...env,
APP_VERSION: version,
},
env,
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';
export type AdminSectionLayoutProps = {
children: React.ReactNode;
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
const user = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
redirect('/documents');
}
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
</div>
</div>
);
}

View File

@@ -1,47 +0,0 @@
'use client';
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, User2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
const pathname = usePathname();
return (
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
Stats
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
disabled
>
<User2 className="mr-2 h-5 w-5" />
Users (Coming Soon)
</Button>
</div>
);
};

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Admin() {
redirect('/admin/stats');
}

View File

@@ -1,75 +0,0 @@
import {
File,
FileCheck,
FileClock,
FileEdit,
Mail,
MailOpen,
PenTool,
User as UserIcon,
UserPlus2,
UserSquare2,
} from 'lucide-react';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
export default async function AdminStatsPage() {
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
]);
return (
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric
icon={UserPlus2}
title="Active Subscriptions"
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import Link from 'next/link';
import { Clock, File, FileCheck } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { UploadDocument } from './upload-document';
const CARD_DATA = [
{
icon: FileCheck,
title: 'Completed',
status: InternalDocumentStatus.COMPLETED,
},
{
icon: File,
title: 'Drafts',
status: InternalDocumentStatus.DRAFT,
},
{
icon: Clock,
title: 'Pending',
status: InternalDocumentStatus.PENDING,
},
];
export default async function DashboardPage() {
const user = await getRequiredServerComponentSession();
const [stats, results] = await Promise.all([
getStats({
user,
}),
findDocuments({
userId: user.id,
perPage: 10,
}),
]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Dashboard</h1>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
{CARD_DATA.map((card) => (
<Link key={card.status} href={`/documents?status=${card.status}`}>
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
</Link>
))}
</div>
<div className="mt-12">
<UploadDocument />
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Reciepient</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.data.map((document) => {
return (
<TableRow key={document.id}>
<TableCell className="font-medium">{document.id}</TableCell>
<TableCell>
<Link
href={`/documents/${document.id}`}
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
{document.title}
</Link>
</TableCell>
<TableCell>
<StackAvatarsWithTooltip recipients={document.Recipient} />
</TableCell>
<TableCell>
<DocumentStatus status={document.status} />
</TableCell>
<TableCell className="text-right">
<LocaleDate date={document.created} />
</TableCell>
</TableRow>
);
})}
{results.data.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -136,7 +136,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push('/documents');
router.push('/dashboard');
} catch (err) {
console.error(err);

View File

@@ -1,56 +0,0 @@
'use client';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, Recipient, User } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
return match({
isOwner,
isRecipient,
})
.with({ isOwner: true }, () => (
<Link
href={`/documents/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.with({ isRecipient: true }, () => (
<Link
href={`/sign/${recipient?.token}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.otherwise(() => (
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{row.title}
</span>
));
};

View File

@@ -2,8 +2,9 @@
import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
@@ -17,7 +18,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { DataTableActionButton } from './data-table-action-button';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
export type DocumentsDataTableProps = {
results: FindResultSet<
@@ -29,7 +29,6 @@ export type DocumentsDataTableProps = {
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@@ -43,22 +42,25 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
});
};
if (!session) {
return null;
}
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
header: 'ID',
accessorKey: 'id',
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
cell: ({ row }) => (
<Link
href={`/documents/${row.original.id}`}
title={row.original.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.original.title}
</Link>
),
},
{
header: 'Recipient',
@@ -72,6 +74,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
},
{
header: 'Created',
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{
header: 'Actions',
cell: ({ row }) => (
@@ -88,7 +95,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
{(table) => <DataTablePagination table={table} />}
</DataTable>
{isPending && (

View File

@@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { UploadDocument } from '../dashboard/upload-document';
import { DocumentsDataTable } from './data-table';
import { UploadDocument } from './upload-document';
export type DocumentsPageProps = {
searchParams?: {
@@ -81,7 +81,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>

View File

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Field, Recipient } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddTemplateFormPartial } from '@documenso/ui/primitives/document-flow/add-template-details';
import { TAddTemplateSchema } from '@documenso/ui/primitives/document-flow/add-template-details.types';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-template-fields';
import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-template-fields.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 { toast } from '@documenso/ui/primitives/use-toast';
type TemplatePageFlowStep = 'details' | 'fields';
export type DocumentPageProps = {
params: {
id: string;
};
};
export default function TemplatePage() {
const router = useRouter();
const [uploadedFile, setUploadedFile] = useState<{ name: string; file: string } | null>();
const [step, setStep] = useState<TemplatePageFlowStep>('details');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<TemplatePageFlowStep, DocumentFlowStep> = {
details: {
title: 'Add Details',
description: 'Add the name and description of your template.',
stepIndex: 1,
onSubmit: () => onAddTemplateDetailsFormSubmit,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('details'),
onSubmit: () => onAddTemplateFieldsFormSubmit,
},
};
const currentDocumentFlow = documentFlow[step];
const onAddTemplateDetailsFormSubmit = (data: TAddTemplateSchema) => {
if (!uploadedFile) {
return;
}
router.refresh();
const templateDetails = {
document: uploadedFile,
...data,
};
console.log(templateDetails);
setStep('fields');
};
const onAddTemplateFieldsFormSubmit = (data: TAddTemplateFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
console.log(data);
console.log('Submit fields in document flow');
};
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}`,
});
} 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',
},
];
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 max-w-xs truncate text-2xl font-semibold md:text-3xl" title="Templates">
Templates
</h1>
<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}
/>
{step === 'details' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddTemplateFormPartial
documentFlow={documentFlow.details}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddTemplateDetailsFormSubmit}
/>
</fieldset>
)}
{step === 'fields' && (
<AddTemplateFieldsFormPartial
documentFlow={documentFlow.fields}
recipients={placeholderRecipient}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddTemplateFieldsFormSubmit}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
}

View File

@@ -2,8 +2,6 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
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';
@@ -47,8 +45,6 @@ export const metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
const locale = getLocale();
return (
<html
lang="en"
@@ -67,18 +63,16 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Suspense>
<body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</LocaleProvider>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</body>
</html>
);

View File

@@ -15,7 +15,7 @@ export type StackAvatarProps = {
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
${firstClass}
dark:border-border h-10 w-10 border-2 border-solid border-white`}
>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
</Avatar>
);
};

View File

@@ -1,5 +1,5 @@
import { initials } from '@documenso/lib/client-only/recipient-initials';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client';
import {
Tooltip,
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { initials } from '@documenso/lib/client-only/recipient-initials';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar';
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
first={first}
zIndex={String(zIndex - index * 10)}
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
fallbackText={lastItemText ? lastItemText : initials(recipient.name)}
/>
);
});

View File

@@ -11,13 +11,10 @@ import {
Monitor,
Moon,
Sun,
UserCog,
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -38,21 +35,24 @@ export type ProfileDropdownProps = {
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing');
const avatarFallback = user.name
? recipientInitials(user.name)
: user.email.slice(0, 1).toUpperCase();
const initials =
user.name
?.split(' ')
.map((name: string) => name.slice(0, 1).toUpperCase())
.slice(0, 2)
.join('') ?? 'UK';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback>{avatarFallback}</AvatarFallback>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
@@ -60,19 +60,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Admin
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" />

View File

@@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
)}
>
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-center">
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
<div className="flex items-start">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
</div>
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">

View File

@@ -2,31 +2,16 @@
import { HTMLAttributes, useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
date: string | number | Date;
format?: DateTimeFormatOptions;
};
/**
* Formats the date based on the user locale.
*
* Will use the estimated locale from the user headers on SSR, then will use
* the client browser locale once mounted.
*/
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
const { locale } = useLocale();
const [localeDate, setLocaleDate] = useState(() =>
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
);
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => {
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString());
useEffect(() => {
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
}, [date, format]);
setLocaleDate(new Date(date).toLocaleString());
}, [date]);
return (
<span className={className} {...props}>

View File

@@ -18,15 +18,13 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES = {
const ErrorMessages = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]:
'This account appears to be using a social login method, please sign in using that method',
};
const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: z.string().min(6).max(72),
@@ -39,9 +37,8 @@ export type SignInFormProps = {
};
export const SignInForm = ({ className }: SignInFormProps) => {
const searchParams = useSearchParams();
const { toast } = useToast();
const searchParams = useSearchParams();
const {
register,
@@ -64,7 +61,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
timeout = setTimeout(() => {
toast({
variant: 'destructive',
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
});
}, 0);
}
@@ -81,10 +78,12 @@ export const SignInForm = ({ className }: SignInFormProps) => {
await signIn('credentials', {
email,
password,
callbackUrl: LOGIN_REDIRECT_PATH,
callbackUrl: '/documents',
}).catch((err) => {
console.error(err);
});
// throw new Error('Not implemented');
} catch (err) {
toast({
title: 'An unknown error occurred',
@@ -96,7 +95,8 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const onSignInWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
await signIn('google', { callbackUrl: '/dashboard' });
// throw new Error('Not implemented');
} catch (err) {
toast({
title: 'An unknown error occurred',

View File

@@ -0,0 +1,25 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
export type AddSignersActionInput = TAddSignersFormSchema & {
documentId: number;
};
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
'use server';
const { id: userId } = await getRequiredServerComponentSession();
await setRecipientsForDocument({
userId,
documentId,
recipients: signers.map((signer) => ({
id: signer.nativeId,
email: signer.email,
name: signer.name,
})),
});
};

View File

@@ -2,7 +2,7 @@
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
"start": "cd apps && cd web && next start",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",

View File

@@ -1,37 +0,0 @@
'use client';
import { createContext, useContext } from 'react';
export type LocaleContextValue = {
locale: string;
};
export const LocaleContext = createContext<LocaleContextValue | null>(null);
export const useLocale = () => {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return context;
};
export function LocaleProvider({
children,
locale,
}: {
children: React.ReactNode;
locale: string;
}) {
return (
<LocaleContext.Provider
value={{
locale: locale,
}}
>
{children}
</LocaleContext.Provider>
);
}

View File

@@ -0,0 +1,6 @@
export const initials = (text: string) =>
text
?.split(' ')
.map((name: string) => name.slice(0, 1).toUpperCase())
.slice(0, 2)
.join('') ?? 'UK';

View File

@@ -1,5 +0,0 @@
import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View File

@@ -1,26 +0,0 @@
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export const getDocumentStats = async () => {
const counts = await prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat) => {
stats[stat.status] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};

View File

@@ -1,29 +0,0 @@
import { prisma } from '@documenso/prisma';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientsStats = async () => {
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
_count: true,
});
const stats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};

View File

@@ -1,18 +0,0 @@
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
where: {
Subscription: {
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});
};

View File

@@ -0,0 +1,38 @@
'use server';
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { DocumentContent } from '@documenso/prisma/client';
type CreateTemplateInput = {
name: string;
description: string;
document: DocumentContent;
userId: number;
};
export const createTemplate = async ({
name,
description,
document,
userId,
}: CreateTemplateInput) => {
const createTemplateDocument = await prisma.documentContent.create({
data: {
content: document.content,
name: document.name,
},
});
const createdTemplate = await prisma.template.create({
data: {
name,
slug: nanoid(10),
description,
ownerId: userId,
documentId: createTemplateDocument.id,
},
});
return createdTemplate;
};

View File

@@ -1,12 +0,0 @@
import { Recipient } from '@documenso/prisma/client';
export const recipientInitials = (text: string) =>
text
.split(' ')
.map((name: string) => name.slice(0, 1).toUpperCase())
.slice(0, 2)
.join('');
export const recipientAbbreviation = (recipient: Recipient) => {
return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
};

View File

@@ -1,5 +0,0 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];

View File

@@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
-- CreateTable
CREATE TABLE "Template" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "TemplateType" NOT NULL,
"ownerId" INTEGER NOT NULL,
"documentId" INTEGER NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentContent" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
-- CreateIndex
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the `DocumentContent` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Template` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_documentId_fkey";
-- DropForeignKey
ALTER TABLE "Template" DROP CONSTRAINT "Template_ownerId_fkey";
-- DropTable
DROP TABLE "DocumentContent";
-- DropTable
DROP TABLE "Template";
-- DropEnum
DROP TYPE "TemplateType";

View File

@@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "TemplateType" AS ENUM ('PRIVATE', 'PUBLIC');
-- CreateTable
CREATE TABLE "Template" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "TemplateType" NOT NULL,
"ownerId" INTEGER NOT NULL,
"documentId" INTEGER NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentContent" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "DocumentContent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Template_slug_key" ON "Template"("slug");
-- CreateIndex
CREATE INDEX "Template_ownerId_idx" ON "Template"("ownerId");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "DocumentContent"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ALTER COLUMN "kind" SET DEFAULT 'PRIVATE';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "description" TEXT;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `name` to the `DocumentContent` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "DocumentContent" ADD COLUMN "name" TEXT NOT NULL;

View File

@@ -13,11 +13,6 @@ enum IdentityProvider {
GOOGLE
}
enum Role {
ADMIN
USER
}
model User {
id Int @id @default(autoincrement())
name String?
@@ -26,12 +21,12 @@ model User {
password String?
source String?
signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription[]
Template Template[]
}
enum SubscriptionStatus {
@@ -172,3 +167,29 @@ model Signature {
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
}
enum TemplateType {
PRIVATE
PUBLIC
}
model Template {
id Int @id @default(autoincrement())
name String
slug String @unique
description String?
kind TemplateType @default(PRIVATE)
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
documentId Int
document DocumentContent @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@index([ownerId])
}
model DocumentContent {
id Int @id @default(autoincrement())
name String
content String
templates Template[]
}

View File

@@ -1,46 +1,19 @@
import { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from './button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
interface DataTablePaginationProps<TData> {
table: Table<TData>;
/**
* The type of information to show on the left hand side of the pagination.
*
* Defaults to 'VisibleCount'.
*/
additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
}
export function DataTablePagination<TData>({
table,
additionalInformation = 'VisibleCount',
}: DataTablePaginationProps<TData>) {
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
<div className="text-muted-foreground flex-1 text-sm">
{match(additionalInformation)
.with('SelectedCount', () => (
<span>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</span>
))
.with('VisibleCount', () => {
const visibleRows = table.getFilteredRowModel().rows.length;
return (
<span>
Showing {visibleRows} result{visibleRows > 1 && 's'}.
</span>
);
})
.with('None', () => null)
.exhaustive()}
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex items-center gap-x-2">

View File

@@ -102,7 +102,6 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
@@ -315,7 +314,7 @@ export const AddFieldsFormPartial = ({
))}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
@@ -325,7 +324,7 @@ export const AddFieldsFormPartial = ({
>
{selectedSigner?.email && (
<span className="flex-1 truncate text-left">
{selectedSigner?.name} ({selectedSigner?.email})
{selectedSigner?.email} ({selectedSigner?.email})
</span>
)}
@@ -349,10 +348,7 @@ export const AddFieldsFormPartial = ({
className={cn({
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
onSelect={() => setSelectedSigner(recipient)}
>
{recipient.sendStatus !== SendStatus.SENT ? (
<Check

View File

@@ -0,0 +1,103 @@
'use client';
import React from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Field } from '@documenso/prisma/client';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { TAddTemplateSchema, ZAddTemplateSchema } from './add-template-details.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { DocumentFlowStep } from './types';
export type AddTemplateFormProps = {
documentFlow: DocumentFlowStep;
fields: Field[];
numberOfSteps: number;
onSubmit: (_data: TAddTemplateSchema) => void;
};
export const AddTemplateFormPartial = ({
documentFlow,
numberOfSteps,
fields: _fields,
onSubmit,
}: AddTemplateFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTemplateSchema>({
resolver: zodResolver(ZAddTemplateSchema),
defaultValues: {
template: {
name: '',
description: '',
},
},
});
const onFormSubmit = handleSubmit(onSubmit);
return (
<>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('template.name')}
/>
<FormErrorMessage className="mt-2" error={errors.template?.name} />
</div>
<div>
<Label htmlFor="description">
Description <span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
id="description"
className="bg-background mt-2"
disabled={isSubmitting}
{...register('template.description')}
/>
<FormErrorMessage className="mt-2" error={errors.template?.description} />
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZAddTemplateSchema = z.object({
template: z.object({
name: z.string(),
description: z.string(),
}),
});
export type TAddTemplateSchema = z.infer<typeof ZAddTemplateSchema>;

View File

@@ -0,0 +1,418 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
display: 'swap',
variable: '--font-caveat',
});
const DEFAULT_HEIGHT_PERCENT = 5;
const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
numberOfSteps: number;
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
};
export const AddTemplateFieldsFormPartial = ({
documentFlow,
recipients,
fields,
numberOfSteps,
onSubmit,
}: AddTemplateFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useForm<TAddTemplateFieldsFormSchema>({
defaultValues: {
fields: fields.map((field) => ({
nativeId: field.id,
formId: `${field.id}-${field.documentId}`,
pageNumber: field.page,
type: field.type,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
})),
},
});
const onFormSubmit = handleSubmit(onSubmit);
const {
append,
remove,
update,
fields: localFields,
} = useFieldArray({
control,
name: 'fields',
});
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
const fieldBounds = useRef({
height: 0,
width: 0,
});
const onMouseMove = useCallback(
(event: MouseEvent) => {
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
);
setCoords({
x: event.clientX - fieldBounds.current.width / 2,
y: event.clientY - fieldBounds.current.height / 2,
});
},
[isWithinPageBounds],
);
const onMouseClick = useCallback(
(event: MouseEvent) => {
if (!selectedField || !selectedSigner) {
return;
}
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
) {
setSelectedField(null);
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
let pageX = ((event.pageX - left) / width) * 100;
let pageY = ((event.pageY - top) / height) * 100;
// Get the bounds as a percentage of the page width and height
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
// And center it based on the bounds
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
append({
formId: nanoid(12),
type: selectedField,
pageNumber,
pageX,
pageY,
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
});
setIsFieldWithinBounds(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
);
const onFieldResize = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
pageWidth,
pageHeight,
});
},
[getFieldPosition, localFields, update],
);
const onFieldMove = useCallback(
(node: HTMLElement, index: number) => {
const field = localFields[index];
const $page = window.document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { x: pageX, y: pageY } = getFieldPosition($page, node);
update(index, {
...field,
pageX,
pageY,
});
},
[getFieldPosition, localFields, update],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('click', onMouseClick);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('click', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
useEffect(() => {
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
if (!$page) {
return;
}
const { height, width } = $page.getBoundingClientRect();
fieldBounds.current = {
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
};
}, []);
useEffect(() => {
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
return (
<>
<DocumentFlowFormContainerContent>
<div className="flex flex-col">
{selectedField && (
<Card
className={cn(
'pointer-events-none fixed z-50 cursor-pointer bg-white transition-opacity',
{
'border-primary': isFieldWithinBounds,
'opacity-50': !isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
{FRIENDLY_FIELD_TYPE[selectedField]}
</CardContent>
</Card>
)}
{localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
<div className="-mx-2 flex-1 overflow-y-scroll px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
fontCaveat.className,
)}
>
{selectedSigner?.name || 'Signature'}
</p>
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Email'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Email</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Name'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Name</p>
</CardContent>
</Card>
</button>
<button
type="button"
className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
>
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
)}
>
{'Date'}
</p>
<p className="text-muted-foreground mt-2 text-xs">Date</p>
</CardContent>
</Card>
</button>
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={documentFlow.stepIndex}
maxStep={numberOfSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={documentFlow.onBackStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZAddTemplateFieldsFormSchema = z.object({
fields: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TAddTemplateFieldsFormSchema = z.infer<typeof ZAddTemplateFieldsFormSchema>;

10
templates Normal file
View File

@@ -0,0 +1,10 @@
Plan
Take the document
Take the details of the template
Take the fields
---??? Store it in the database with a templates schema
Issues?
Persist template details information.

View File

@@ -2,8 +2,13 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
"dependsOn": [
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"lint": {},
"dev": {
@@ -11,9 +16,10 @@
"persistent": true
}
},
"globalDependencies": ["**/.env.*local"],
"globalDependencies": [
"**/.env.*local"
],
"globalEnv": [
"APP_VERSION",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_APP_URL",