mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 18:51:37 +10:00
chore: merged feat/refresh
This commit is contained in:
BIN
apps/web/example/cert.p12
Normal file
BIN
apps/web/example/cert.p12
Normal file
Binary file not shown.
@ -2,7 +2,7 @@
|
||||
const path = require('path');
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
@ -22,6 +22,7 @@ const config = {
|
||||
],
|
||||
env: {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs",
|
||||
"dev:test": "playwright test",
|
||||
"e2e:test": "start-server-and-test \"npm run start\" http://localhost:3000 \"playwright test\""
|
||||
"e2e:test": "start-server-and-test \"npm run start\" http://localhost:3000 \"playwright test\"",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/ee": "*",
|
||||
@ -23,10 +24,10 @@
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
|
||||
BIN
apps/web/public/fonts/caveat-regular.ttf
Normal file
BIN
apps/web/public/fonts/caveat-regular.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/inter-bold.ttf
Normal file
BIN
apps/web/public/fonts/inter-bold.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/inter-regular.ttf
Normal file
BIN
apps/web/public/fonts/inter-regular.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/inter-semibold.ttf
Normal file
BIN
apps/web/public/fonts/inter-semibold.ttf
Normal file
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 |
BIN
apps/web/public/static/og-share-frame.png
Normal file
BIN
apps/web/public/static/og-share-frame.png
Normal file
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,21 +55,18 @@ 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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -169,6 +166,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
@ -179,6 +177,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
key={fields.length}
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -88,7 +112,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="h-5 w-5 text-gray-500" />
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
@ -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>
|
||||
|
||||
@ -92,8 +92,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
@ -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 = {
|
||||
@ -62,41 +63,44 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument />
|
||||
|
||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<DocumentsDataTable results={results} />
|
||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -67,8 +67,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</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();
|
||||
@ -43,7 +43,7 @@ export default async function BillingSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Your subscription is{' '}
|
||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||
{subscription?.periodEnd && (
|
||||
@ -67,7 +67,7 @@ export default async function BillingSettingsPage() {
|
||||
)}
|
||||
|
||||
{!billingPortalUrl && (
|
||||
<p className="max-w-[60ch] text-base text-slate-500">
|
||||
<p className="text-muted-foreground max-w-[60ch] text-base">
|
||||
You do not currently have a customer record, this should not happen. Please contact
|
||||
support for assistance.
|
||||
</p>
|
||||
|
||||
@ -9,7 +9,7 @@ export default async function PasswordSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can update your password.</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export default async function ProfileSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can edit your personal details.</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
|
||||
153
apps/web/src/app/(share)/share/[slug]/opengraph-image.tsx
Normal file
153
apps/web/src/app/(share)/share/[slug]/opengraph-image.tsx
Normal file
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
11
apps/web/src/app/(share)/share/[slug]/page.tsx
Normal file
11
apps/web/src/app/(share)/share/[slug]/page.tsx
Normal file
@ -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 />;
|
||||
}
|
||||
11
apps/web/src/app/(share)/share/[slug]/redirect.tsx
Normal file
11
apps/web/src/app/(share)/share/[slug]/redirect.tsx
Normal file
@ -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,78 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
fileName?: string;
|
||||
documentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const DownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
documentData,
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={disabled || !documentData}
|
||||
onClick={onDownloadClick}
|
||||
loading={isLoading}
|
||||
{...props}
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,19 @@
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { SigningCard } from '@documenso/ui/components/signing-card';
|
||||
|
||||
import { DownloadButton } from './download-button';
|
||||
import { SigningCard } from './signing-card';
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
import { ShareButton } from './share-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
@ -53,7 +55,7 @@ export default async function CompletedSigningPage({
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard name={recipientName} />
|
||||
<SigningCard name={recipientName} signingCelebrationImage={signingCelebration} />
|
||||
|
||||
<div className="mt-6">
|
||||
{match(document.status)
|
||||
@ -88,13 +90,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} />
|
||||
|
||||
<DownloadButton
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
@ -103,7 +101,7 @@ export default async function CompletedSigningPage({
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want so send slick signing links like this one?{' '}
|
||||
Want to 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,146 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Copy, Share, Twitter } from 'lucide-react';
|
||||
|
||||
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: createOrGetShareLink,
|
||||
data: shareLink,
|
||||
isLoading,
|
||||
} = trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
void createOrGetShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsOpen(nextOpen);
|
||||
};
|
||||
|
||||
const onCopyClick = async () => {
|
||||
let { slug = '' } = shareLink || {};
|
||||
|
||||
if (!slug) {
|
||||
const result = await createOrGetShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
|
||||
slug = result.slug;
|
||||
}
|
||||
|
||||
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The sharing link has been copied to your clipboard.',
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onTweetClick = async () => {
|
||||
let { slug = '' } = shareLink || {};
|
||||
|
||||
if (!slug) {
|
||||
const result = await createOrGetShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
|
||||
slug = result.slug;
|
||||
}
|
||||
|
||||
window.open(
|
||||
generateTwitterIntent(
|
||||
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
|
||||
`${window.location.origin}/share/${slug}`,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!token || !documentId}
|
||||
className="flex-1"
|
||||
loading={isLoading}
|
||||
>
|
||||
{!isLoading && <Share className="mr-2 h-5 w-5" />}
|
||||
Share
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">Share your signing experience!</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="rounded-md border p-4">
|
||||
I just {token ? 'signed' : 'sent'} a document with{' '}
|
||||
<span className="font-medium text-blue-400">@documenso</span>
|
||||
. Check it out!
|
||||
<span className="mt-2 block" />
|
||||
<span className="font-medium text-blue-400">
|
||||
{window.location.origin}/share/{shareLink?.slug || '...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
|
||||
<Twitter className="mr-2 h-4 w-4" />
|
||||
Tweet
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-4 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={onCopyClick}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,67 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const isComplete = fields.every((f) => f.inserted);
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
if (!isComplete) {
|
||||
setValidateUninsertedFields(true);
|
||||
const isFieldsValid = validateFieldsInserted(fields);
|
||||
|
||||
if (!isFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
Click to insert field
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
|
||||
@ -101,23 +120,18 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={!isComplete || isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</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) {
|
||||
|
||||
@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
@ -130,7 +130,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
<img
|
||||
src={signature.signatureImageAsBase64}
|
||||
alt={`Signature for ${recipient.name}`}
|
||||
className="h-full w-full object-contain"
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -3,10 +3,7 @@
|
||||
import React from 'react';
|
||||
|
||||
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';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
@ -23,8 +20,6 @@ export const SigningFieldContainer = ({
|
||||
onRemove,
|
||||
children,
|
||||
}: SignatureFieldProps) => {
|
||||
const coords = useFieldPageCoords(field);
|
||||
|
||||
const onSignFieldClick = async () => {
|
||||
if (field.inserted) {
|
||||
return;
|
||||
@ -42,40 +37,25 @@ export const SigningFieldContainer = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
height: `${coords.height}px`,
|
||||
width: `${coords.width}px`,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="bg-background relative h-full w-full"
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
|
||||
)}
|
||||
<FieldRootContainer field={field}>
|
||||
{!field.inserted && !loading && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full"
|
||||
onClick={onSignFieldClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
{!field.inserted && !loading && (
|
||||
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
);
|
||||
};
|
||||
|
||||
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
A password reset email has been sent, if you have an account you should see it in your inbox
|
||||
shortly.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||
password.
|
||||
</p>
|
||||
|
||||
<ForgotPasswordForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Remembered your password?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
|
||||
type UnauthenticatedLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex w-full max-w-md items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
||||
|
||||
import { ResetPasswordForm } from '~/components/forms/reset-password';
|
||||
|
||||
type ResetPasswordPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function ResetPasswordPage({ params: { token } }: ResetPasswordPageProps) {
|
||||
const isValid = await getResetTokenValidity({ token });
|
||||
|
||||
if (!isValid) {
|
||||
redirect('/reset-password');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
|
||||
|
||||
<ResetPasswordForm token={token} className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
The token you have used to reset your password is either expired or it never existed. If you
|
||||
have still forgotten your password, please request a new reset link.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,43 +1,33 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/card-sharing-figure.png';
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
<SignInForm className="mt-4" />
|
||||
|
||||
<SignInForm className="mt-4" />
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<p className="mt-2.5 text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
>
|
||||
Forgotten your password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,44 +1,25 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/connections.png';
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account ✨</h1>
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Create your account and start using state-of-the-art document signing. Open and beautiful
|
||||
signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
</p>
|
||||
<SignUpForm className="mt-4" />
|
||||
|
||||
<SignUpForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
@ -76,6 +76,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</LocaleProvider>
|
||||
|
||||
26
apps/web/src/app/not-found.tsx
Normal file
26
apps/web/src/app/not-found.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/assets/background-pattern-og.png
Normal file
BIN
apps/web/src/assets/background-pattern-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
BIN
apps/web/src/assets/caveat-regular.ttf
Normal file
BIN
apps/web/src/assets/caveat-regular.ttf
Normal file
Binary file not shown.
BIN
apps/web/src/assets/inter-bold.ttf
Normal file
BIN
apps/web/src/assets/inter-bold.ttf
Normal file
Binary file not shown.
BIN
apps/web/src/assets/inter-regular.ttf
Normal file
BIN
apps/web/src/assets/inter-regular.ttf
Normal file
Binary file not shown.
BIN
apps/web/src/assets/inter-semibold.ttf
Normal file
BIN
apps/web/src/assets/inter-semibold.ttf
Normal file
Binary file not shown.
BIN
apps/web/src/assets/og-share-frame.png
Normal file
BIN
apps/web/src/assets/og-share-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 743 KiB |
@ -58,7 +58,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -75,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -92,7 +92,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -109,7 +109,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -10,12 +10,14 @@ 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';
|
||||
@ -26,19 +28,23 @@ 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');
|
||||
@ -98,28 +104,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" />
|
||||
|
||||
@ -44,7 +44,7 @@ export const PeriodSelector = () => {
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-slate-500">
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
|
||||
@ -7,11 +7,10 @@ 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,11 +7,10 @@ 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) => {
|
||||
|
||||
@ -17,17 +17,17 @@ const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
color: 'text-blue-600',
|
||||
color: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
COMPLETED: {
|
||||
label: 'Completed',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
icon: File,
|
||||
color: 'text-yellow-500',
|
||||
color: 'text-yellow-500 dark:text-yellow-200',
|
||||
},
|
||||
INBOX: {
|
||||
label: 'Inbox',
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
export type ForgotPasswordFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TForgotPasswordFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZForgotPasswordFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
|
||||
await forgotPassword({ email }).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Reset email sent',
|
||||
description:
|
||||
'A password reset email has been sent, if you have an account you should see it in your inbox shortly.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
reset();
|
||||
|
||||
router.push('/check-email');
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -36,6 +38,9 @@ export type PasswordFormProps = {
|
||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -88,37 +93,69 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-500">
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeated-password" className="text-slate-500">
|
||||
<Label htmlFor="repeated-password" className="text-muted-foreground">
|
||||
Repeat Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
|
||||
@ -89,7 +89,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="full-name" className="text-slate-500">
|
||||
<Label htmlFor="full-name" className="text-muted-foreground">
|
||||
Full Name
|
||||
</Label>
|
||||
|
||||
@ -99,7 +99,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
@ -107,7 +107,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="signature" className="text-slate-500">
|
||||
<Label htmlFor="signature" className="text-muted-foreground">
|
||||
Signature
|
||||
</Label>
|
||||
|
||||
|
||||
173
apps/web/src/components/forms/reset-password.tsx
Normal file
173
apps/web/src/components/forms/reset-password.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZResetPasswordFormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
path: ['repeatedPassword'],
|
||||
message: "Passwords don't match",
|
||||
});
|
||||
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
className?: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TResetPasswordFormSchema>({
|
||||
values: {
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
},
|
||||
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
|
||||
try {
|
||||
await resetPassword({
|
||||
password,
|
||||
token,
|
||||
});
|
||||
|
||||
reset();
|
||||
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
description: 'Your password has been updated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/signin');
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to reset your password. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
|
||||
<span>Repeat Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@ -14,6 +12,7 @@ import { z } from 'zod';
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@ -39,9 +38,8 @@ export type SignInFormProps = {
|
||||
};
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -55,36 +53,29 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const errorCode = searchParams?.get('error');
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
if (isErrorCode(errorCode)) {
|
||||
timeout = setTimeout(() => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [errorCode, toast]);
|
||||
|
||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error && isErrorCode(result.error)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[result.error],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result?.url) {
|
||||
throw new Error('An unknown error occurred');
|
||||
}
|
||||
|
||||
window.location.href = result.url;
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
@ -113,33 +104,47 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
<Label htmlFor="email" className="text-muted-forground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-500">
|
||||
Password
|
||||
<Label htmlFor="password" className="text-muted-forground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{errors.password && (
|
||||
<span className="mt-1 text-xs text-red-500">{errors.password.message}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@ -31,6 +33,7 @@ export type SignUpFormProps = {
|
||||
|
||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
@ -106,15 +109,31 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export * from 'framer-motion';
|
||||
|
||||
export const MotionDiv = motion.div;
|
||||
66
apps/web/src/components/partials/not-found.tsx
Normal file
66
apps/web/src/components/partials/not-found.tsx
Normal file
@ -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 dark:contrast-[70%] dark:invert dark:sepia 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>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/helpers/get-asset-buffer.ts
Normal file
14
apps/web/src/helpers/get-asset-buffer.ts
Normal file
@ -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());
|
||||
};
|
||||
@ -1,79 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
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.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getFlag = async (
|
||||
flag: string,
|
||||
options?: GetFlagOptions,
|
||||
): Promise<TFeatureFlagValue> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
|
||||
url.searchParams.set('flag', flag);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
next: {
|
||||
revalidate: 60,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||
.catch(() => false);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user if possible.
|
||||
*
|
||||
* @param options See `GetFlagOptions`.
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getAllFlags = async (
|
||||
options?: GetFlagOptions,
|
||||
): Promise<Record<string, TFeatureFlagValue>> => {
|
||||
const requestHeaders = options?.requestHeaders ?? {};
|
||||
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
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.
|
||||
*
|
||||
* The authenticated user will be derived from the headers if possible.
|
||||
*/
|
||||
requestHeaders: Record<string, string>;
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export default function PostHogServerClient() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
if (!postHogConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PostHog(postHogConfig.key, {
|
||||
host: postHogConfig.host,
|
||||
fetch: async (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getAllFlags, getFlag } from './get-feature-flag';
|
||||
|
||||
/**
|
||||
* Evaluate whether a flag is enabled for the current user in a server component.
|
||||
*
|
||||
* @param flag The flag to evaluate.
|
||||
* @returns Whether the flag is enabled, or the variant value of the flag.
|
||||
*/
|
||||
export const getServerComponentFlag = async (flag: string) => {
|
||||
return await getFlag(flag, {
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all feature flags for the current user from a server component.
|
||||
*
|
||||
* @returns A record of flags and their values for the user derived from the headers.
|
||||
*/
|
||||
export const getServerComponentAllFlags = async () => {
|
||||
return await getAllFlags({
|
||||
requestHeaders: Object.fromEntries(headers().entries()),
|
||||
});
|
||||
};
|
||||
@ -1,18 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const fieldX = (Number(field.positionX) / 100) * width + left;
|
||||
const fieldY = (Number(field.positionY) / 100) * height + top;
|
||||
|
||||
const fieldHeight = (Number(field.height) / 100) * height;
|
||||
const fieldWidth = (Number(field.width) / 100) * width;
|
||||
|
||||
setCoords({
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
});
|
||||
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
@ -1,44 +1,7 @@
|
||||
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';
|
||||
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
export default handlerFeatureFlagAll;
|
||||
|
||||
@ -1,122 +1,7 @@
|
||||
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';
|
||||
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
export default handlerFeatureFlagGet;
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { getAllFlags } from '~/helpers/get-feature-flag';
|
||||
|
||||
import { TFeatureFlagValue } from './feature-flag.types';
|
||||
|
||||
export type FeatureFlagContextValue = {
|
||||
getFlag: (_key: string) => TFeatureFlagValue;
|
||||
};
|
||||
|
||||
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
children,
|
||||
initialFlags,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialFlags: Record<string, TFeatureFlagValue>;
|
||||
}) {
|
||||
const [flags, setFlags] = useState(initialFlags);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flag: string) => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
return flags[flag] ?? false;
|
||||
},
|
||||
[flags],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
}
|
||||
}, FEATURE_FLAG_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh the flags when the window is focused.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFeatureFlagEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider
|
||||
value={{
|
||||
getFlag,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
Reference in New Issue
Block a user