diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..f80dc7f80 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +# Config files +*.config.js +*.config.cjs + +# Statically hosted javascript files +apps/*/public/*.js +apps/*/public/*.cjs diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx index f32765024..37d6d1b63 100644 --- a/apps/marketing/src/app/(marketing)/[content]/page.tsx +++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx @@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; -export const generateStaticParams = async () => +export const generateStaticParams = () => allDocuments.map((post) => ({ post: post._raw.flattenedPath })); export const generateMetadata = ({ params }: { params: { content: string } }) => { diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx new file mode 100644 index 000000000..f9987dd27 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx @@ -0,0 +1,76 @@ +import { ImageResponse } from 'next/server'; + +import { allBlogPosts } from 'contentlayer/generated'; + +export const runtime = 'edge'; + +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = 'image/png'; + +type BlogPostOpenGraphImageProps = { + params: { post: string }; +}; + +export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGraphImageProps) { + const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); + + if (!blogPost) { + return null; + } + + // The long urls are needed for a compiler optimisation on the Next.js side, lifting this up + // to a constant will break og image generation. + const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([ + fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then( + async (res) => res.arrayBuffer(), + ), + fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) => + res.arrayBuffer(), + ), + ]); + + return new ImageResponse( + ( +
+ {/* @ts-expect-error Lack of typing from ImageResponse */} + og-background + + {/* @ts-expect-error Lack of typing from ImageResponse */} + logo + +

+ {blogPost.title} +

+ +

Written by {blogPost.authorName}

+
+ ), + { + ...size, + fonts: [ + { + name: 'Inter', + data: interRegular, + style: 'normal', + weight: 400, + }, + { + name: 'Inter', + data: interBold, + style: 'normal', + weight: 700, + }, + ], + }, + ); +} diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 68f22e734..7edf29ec2 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react'; import type { MDXComponents } from 'mdx/types'; import { useMDXComponent } from 'next-contentlayer/hooks'; -export const generateStaticParams = async () => +export const generateStaticParams = () => allBlogPosts.map((post) => ({ post: post._raw.flattenedPath })); export const generateMetadata = ({ params }: { params: { post: string } }) => { @@ -17,7 +17,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { notFound(); } - return { title: `Documenso - ${blogPost.title}` }; + return { + title: `Documenso - ${blogPost.title}`, + }; }; const mdxComponents: MDXComponents = { diff --git a/apps/marketing/src/app/(marketing)/claimed/page.tsx b/apps/marketing/src/app/(marketing)/claimed/page.tsx index ce748006e..f56ae2b26 100644 --- a/apps/marketing/src/app/(marketing)/claimed/page.tsx +++ b/apps/marketing/src/app/(marketing)/claimed/page.tsx @@ -27,7 +27,11 @@ export type ClaimedPlanPageProps = { export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlanPageProps) { const { sessionId } = searchParams; - const session = await stripe.checkout.sessions.retrieve(sessionId as string); + if (typeof sessionId !== 'string') { + redirect('/'); + } + + const session = await stripe.checkout.sessions.retrieve(sessionId); const user = await prisma.user.findFirst({ where: { @@ -157,7 +161,6 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan

res.json()) + .then(async (res) => res.json()) .then((res) => ZGithubStatsResponse.parse(res)); const { total_count: mergedPullRequests } = await fetch( @@ -54,7 +54,7 @@ export default async function OpenPage() { }, }, ) - .then((res) => res.json()) + .then(async (res) => res.json()) .then((res) => ZMergedPullRequestsResponse.parse(res)); const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', { @@ -62,7 +62,7 @@ export default async function OpenPage() { accept: 'application/json', }, }) - .then((res) => res.json()) + .then(async (res) => res.json()) .then((res) => ZStargazersLiveResponse.parse(res)); return ( diff --git a/apps/marketing/src/app/(marketing)/page.tsx b/apps/marketing/src/app/(marketing)/page.tsx index 09e9e3dec..377384701 100644 --- a/apps/marketing/src/app/(marketing)/page.tsx +++ b/apps/marketing/src/app/(marketing)/page.tsx @@ -24,7 +24,7 @@ export default async function IndexPage() { accept: 'application/vnd.github.v3+json', }, }) - .then((res) => res.json()) + .then(async (res) => res.json()) .then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined)) .catch(() => undefined); diff --git a/apps/marketing/src/assets/background-blog-og.png b/apps/marketing/src/assets/background-blog-og.png new file mode 100644 index 000000000..d5d48a21a Binary files /dev/null and b/apps/marketing/src/assets/background-blog-og.png differ diff --git a/apps/marketing/src/assets/inter-bold.ttf b/apps/marketing/src/assets/inter-bold.ttf new file mode 100644 index 000000000..8e82c70d1 Binary files /dev/null and b/apps/marketing/src/assets/inter-bold.ttf differ diff --git a/apps/marketing/src/assets/inter-regular.ttf b/apps/marketing/src/assets/inter-regular.ttf new file mode 100644 index 000000000..8d4eebf20 Binary files /dev/null and b/apps/marketing/src/assets/inter-regular.ttf differ diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx index f350a7e01..7de30bba3 100644 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx @@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { try { - const delay = new Promise((resolve) => setTimeout(resolve, 1000)); + const delay = new Promise((resolve) => { + setTimeout(resolve, 1000); + }); const [redirectUrl] = await Promise.all([ claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 6ae66a0a4..ab0dd6e24 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -9,6 +9,22 @@ import { cn } from '@documenso/ui/lib/utils'; export type FooterProps = HTMLAttributes; +const SOCIAL_LINKS = [ + { href: 'https://twitter.com/documenso', icon: }, + { href: 'https://github.com/documenso/documenso', icon: }, + { href: 'https://documen.so/discord', icon: }, +]; + +const FOOTER_LINKS = [ + { href: '/pricing', text: 'Pricing' }, + { href: '/blog', text: 'Blog' }, + { href: '/open', text: 'Open' }, + { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, + { href: 'https://status.documenso.com', text: 'Status', target: '_blank' }, + { href: 'mailto:support@documenso.com', text: 'Support' }, + { href: '/privacy', text: 'Privacy' }, +]; + export const Footer = ({ className, ...props }: FooterProps) => { return (
@@ -19,77 +35,25 @@ export const Footer = ({ className, ...props }: FooterProps) => {
- - - - - - - - - - - + {SOCIAL_LINKS.map((link, index) => ( + + {link.icon} + + ))}
- - Pricing - - - - Blog - - - - Open - - - - Shop - - - - Status - - - - Support - - - - Privacy - + {FOOTER_LINKS.map((link, index) => ( + + {link.text} + + ))}
diff --git a/apps/marketing/src/components/(marketing)/password-reveal.tsx b/apps/marketing/src/components/(marketing)/password-reveal.tsx index 7e1cb72a3..b31765943 100644 --- a/apps/marketing/src/components/(marketing)/password-reveal.tsx +++ b/apps/marketing/src/components/(marketing)/password-reveal.tsx @@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => { const [, copy] = useCopyToClipboard(); const onCopyClick = () => { - copy(password).then(() => { + void copy(password).then(() => { toast({ title: 'Copied to clipboard', description: 'Your password has been copied to your clipboard.', diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 4d229ae98..d3e76bd84 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -22,7 +22,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { const event = usePlausible(); const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - // eslint-disable-next-line turbo/no-undeclared-env-vars params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ? 'YEARLY' : 'MONTHLY', @@ -30,11 +29,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { const planId = useMemo(() => { if (period === 'MONTHLY') { - // eslint-disable-next-line turbo/no-undeclared-env-vars return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; } - // eslint-disable-next-line turbo/no-undeclared-env-vars return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID; }, [period]); diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 1a15069e9..def90e0cd 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { setValue('signatureDataUrl', draftSignatureDataUrl); setValue('signatureText', ''); - trigger('signatureDataUrl'); + void trigger('signatureDataUrl'); setShowSigningDialog(false); }; @@ -135,9 +135,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { signatureText, }: TWidgetFormSchema) => { try { - const delay = new Promise((resolve) => setTimeout(resolve, 1000)); + const delay = new Promise((resolve) => { + setTimeout(resolve, 1000); + }); - // eslint-disable-next-line turbo/no-undeclared-env-vars const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; const claimPlanInput = signatureDataUrl @@ -145,7 +146,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { name, email, planId, - signatureDataUrl: signatureDataUrl!, + signatureDataUrl: signatureDataUrl, signatureText: null, } : { @@ -153,7 +154,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { email, planId, signatureDataUrl: null, - signatureText: signatureText!, + signatureText: signatureText ?? '', }; const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); diff --git a/apps/marketing/src/pages/api/claim-plan/index.ts b/apps/marketing/src/pages/api/claim-plan/index.ts index a2e4108d2..abad354a8 100644 --- a/apps/marketing/src/pages/api/claim-plan/index.ts +++ b/apps/marketing/src/pages/api/claim-plan/index.ts @@ -43,7 +43,6 @@ export default async function handler( if (user && user.Subscription.length > 0) { return res.status(200).json({ - // eslint-disable-next-line turbo/no-undeclared-env-vars redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`, }); } @@ -104,7 +103,6 @@ export default async function handler( mode: 'subscription', metadata, allow_promotion_codes: true, - // eslint-disable-next-line turbo/no-undeclared-env-vars success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent( email, diff --git a/apps/marketing/src/pages/api/stripe/webhook/index.ts b/apps/marketing/src/pages/api/stripe/webhook/index.ts index a0a4ccebb..3f3810fd4 100644 --- a/apps/marketing/src/pages/api/stripe/webhook/index.ts +++ b/apps/marketing/src/pages/api/stripe/webhook/index.ts @@ -17,14 +17,13 @@ import { SigningStatus, } from '@documenso/prisma/client'; -const log = (...args: any[]) => console.log('[stripe]', ...args); +const log = (...args: unknown[]) => console.log('[stripe]', ...args); export const config = { api: { bodyParser: false }, }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // eslint-disable-next-line turbo/no-undeclared-env-vars // if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) { // return res.status(500).json({ // success: false, @@ -55,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) log('event-type:', event.type); if (event.type === 'checkout.session.completed') { + // This typecast is required since we don't want to create a guard for every event type + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const session = event.data.object as Stripe.Checkout.Session; if (session.metadata?.source === 'landing') { diff --git a/apps/web/package.json b/apps/web/package.json index c0a3035ea..8e7dd2be7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" }, "dependencies": { + "@documenso/ee": "*", "@documenso/lib": "*", "@documenso/prisma": "*", "@documenso/tailwind-config": "*", @@ -21,6 +22,7 @@ "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "nanoid": "^4.0.2", "next": "13.4.12", @@ -43,6 +45,7 @@ }, "devDependencies": { "@types/formidable": "^2.0.6", + "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", "@types/react-dom": "18.2.7" diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx index a9d650eb6..77b18b98c 100644 --- a/apps/web/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -5,6 +5,7 @@ import { Clock, File, FileCheck } from 'lucide-react'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; +import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { Table, TableBody, @@ -21,15 +22,33 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { UploadDocument } from './upload-document'; +const CARD_DATA = [ + { + icon: FileCheck, + title: 'Completed', + status: InternalDocumentStatus.COMPLETED, + }, + { + icon: File, + title: 'Drafts', + status: InternalDocumentStatus.DRAFT, + }, + { + icon: Clock, + title: 'Pending', + status: InternalDocumentStatus.PENDING, + }, +]; + export default async function DashboardPage() { - const session = await getRequiredServerComponentSession(); + const user = await getRequiredServerComponentSession(); const [stats, results] = await Promise.all([ getStats({ - userId: session.id, + user, }), findDocuments({ - userId: session.id, + userId: user.id, perPage: 10, }), ]); @@ -39,15 +58,11 @@ export default async function DashboardPage() {

Dashboard

- - - - - - - - - + {CARD_DATA.map((card) => ( + + + + ))}
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index e1c9a79e1..7ed28feca 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -130,7 +130,13 @@ export const EditDocumentForm = ({ }, }); - router.refresh(); + toast({ + title: 'Document sent', + description: 'Your document has been sent successfully.', + duration: 5000, + }); + + router.push('/dashboard'); } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx new file mode 100644 index 000000000..7c1d42d2b --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -0,0 +1,65 @@ +'use client'; + +import Link from 'next/link'; + +import { Edit, Pencil, Share } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DataTableActionButtonProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + const isRecipient = !!recipient; + const isDraft = row.status === DocumentStatus.DRAFT; + const isPending = row.status === DocumentStatus.PENDING; + const isComplete = row.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + + return match({ + isOwner, + isRecipient, + isDraft, + isPending, + isComplete, + isSigned, + }) + .with({ isOwner: true, isDraft: true }, () => ( + + )) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .otherwise(() => ( + + )); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx new file mode 100644 index 000000000..b1d5832f8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -0,0 +1,133 @@ +'use client'; + +import Link from 'next/link'; + +import { + Copy, + Download, + Edit, + History, + MoreHorizontal, + Pencil, + Share, + Trash2, + XCircle, +} from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +export type DataTableActionDropdownProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + // const isRecipient = !!recipient; + // const isDraft = row.status === DocumentStatus.DRAFT; + // const isPending = row.status === DocumentStatus.PENDING; + const isComplete = row.status === DocumentStatus.COMPLETED; + // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + + const onDownloadClick = () => { + let decodedDocument = row.document; + + try { + decodedDocument = atob(decodedDocument); + } catch (err) { + // We're just going to ignore this error and try to download the document + console.error(err); + } + + const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0))); + + const blob = new Blob([documentBytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = row.title || 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + }; + + return ( + + + + + + + Action + + + + + Sign + + + + + + + Edit + + + + + + Download + + + + + Duplicate + + + + + Void + + + + + Delete + + + Share + + + + Resend + + + + + Share + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index 35fdfb4b1..1d6c08e73 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -8,7 +8,7 @@ import { Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; -import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; +import { Document, Recipient, User } from '@documenso/prisma/client'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; +import { DataTableActionButton } from './data-table-action-button'; +import { DataTableActionDropdown } from './data-table-action-dropdown'; + export type DocumentsDataTableProps = { - results: FindResultSet; + results: FindResultSet< + Document & { + Recipient: Recipient[]; + User: Pick; + } + >; }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { @@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Title', cell: ({ row }) => ( - + {row.original.title} ), @@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'created', cell: ({ row }) => , }, + { + header: 'Actions', + cell: ({ row }) => ( +
+ + +
+ ), + }, ]} data={results.data} perPage={results.perPage} diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 76675f573..4ea55936b 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -3,8 +3,8 @@ import Link from 'next/link'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { isDocumentStatus } from '@documenso/lib/types/is-document-status'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; @@ -16,7 +16,7 @@ import { DocumentsDataTable } from './data-table'; export type DocumentsPageProps = { searchParams?: { - status?: InternalDocumentStatus | 'ALL'; + status?: ExtendedDocumentStatus; period?: PeriodSelectorValue; page?: string; perPage?: string; @@ -24,22 +24,20 @@ export type DocumentsPageProps = { }; export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { - const session = await getRequiredServerComponentSession(); + const user = await getRequiredServerComponentSession(); const stats = await getStats({ - userId: session.id, + user, }); - const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; + const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; // const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; - const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0; - const results = await findDocuments({ - userId: session.id, - status: status === 'ALL' ? undefined : status, + userId: user.id, + status, orderBy: { column: 'created', direction: 'desc', @@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage params.delete('page'); } - if (value === 'ALL') { - params.delete('status'); - } - return `/documents?${params.toString()}`; }; @@ -70,47 +64,28 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage

Documents

-
- +
+ - - - + {[ + ExtendedDocumentStatus.INBOX, + ExtendedDocumentStatus.PENDING, + ExtendedDocumentStatus.COMPLETED, + ExtendedDocumentStatus.DRAFT, + ExtendedDocumentStatus.ALL, + ].map((value) => ( + + + - - {Math.min(stats.PENDING, 99)} - - - - - - - - - - {Math.min(stats.COMPLETED, 99)} - - - - - - - - - - {Math.min(stats.DRAFT, 99)} - - - - - - - All - - + {value !== ExtendedDocumentStatus.ALL && ( + + {Math.min(stats[value], 99)} + + )} + + + ))} diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 256f682fb..e9966b9ac 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,8 +1,14 @@ +import Link from 'next/link'; 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 { 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 { PasswordForm } from '~/components/forms/password'; +import { LocaleDate } from '~/components/formatter/locale-date'; import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag'; export default async function BillingSettingsPage() { @@ -15,17 +21,55 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } + let subscription = await getSubscriptionByUserId({ userId: user.id }); + + // If we don't have a customer record, create one as well as an empty subscription. + if (!subscription?.customerId) { + subscription = await createCustomer({ user }); + } + + let billingPortalUrl = ''; + + if (subscription?.customerId) { + billingPortalUrl = await getPortalSession({ + customerId: subscription.customerId, + returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, + }); + } + return (

Billing

- Here you can update and manage your subscription. + Your subscription is{' '} + {subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}. + {subscription?.periodEnd && ( + <> + {' '} + Your next payment is due on{' '} + + + + . + + )}


- + {billingPortalUrl && ( + + )} + + {!billingPortalUrl && ( +

+ You do not currently have a customer record, this should not happen. Please contact + support for assistance. +

+ )}
); } diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 3666941dc..e18571e33 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -87,9 +87,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = className="h-44 w-full" defaultValue={signature ?? undefined} onChange={(value) => { - console.log({ - signpadValue: value, - }); setSignature(value); }} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index f200d94cd..9688619fa 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -149,7 +149,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { disabled={!localFullName} onClick={() => { setShowFullNameModal(false); - onSign('local'); + void onSign('local'); }} > Sign diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index bbc58b5e8..cb70ea4db 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -63,11 +63,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const onSign = async (source: 'local' | 'provider' = 'provider') => { try { - console.log({ - providedSignature, - localSignature, - }); - if (!providedSignature && !localSignature) { setShowSignatureModal(true); return; @@ -141,6 +136,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { {state === 'signed-text' && (

+ {/* This optional chaining is intentional, we don't want to move the check into the condition above */} {signature?.typedSignature}

)} @@ -182,7 +178,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { disabled={!localSignature} onClick={() => { setShowSignatureModal(false); - onSign('local'); + void onSign('local'); }} > Sign diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 9b64baf58..2c6165a05 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -7,13 +7,15 @@ import { cn } from '@documenso/ui/lib/utils'; export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + // const pathname = usePathname(); + return ( diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index f7d14c39d..02af86d70 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -118,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - signOut({ + void signOut({ callbackUrl: '/', }) } diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts index 4ebfe47f1..2b50f5d6c 100644 --- a/apps/web/src/components/(dashboard)/period-selector/types.ts +++ b/apps/web/src/components/(dashboard)/period-selector/types.ts @@ -1,5 +1,6 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return ['', '7d', '14d', '30d'].includes(value as string); }; diff --git a/apps/web/src/components/(marketing)/callout.tsx b/apps/web/src/components/(marketing)/callout.tsx deleted file mode 100644 index d83983141..000000000 --- a/apps/web/src/components/(marketing)/callout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import Link from 'next/link'; - -import { Github } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; - -import { Button } from '@documenso/ui/primitives/button'; - -export type CalloutProps = { - starCount?: number; - [key: string]: unknown; -}; - -export const Callout = ({ starCount }: CalloutProps) => { - const event = usePlausible(); - - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - setTimeout(() => { - el.focus(); - }, 500); - } - }; - - return ( -
- - - event('view-github')} - > - - -
- ); -}; diff --git a/apps/web/src/components/(marketing)/claim-plan-dialog.tsx b/apps/web/src/components/(marketing)/claim-plan-dialog.tsx deleted file mode 100644 index 06bfd5ced..000000000 --- a/apps/web/src/components/(marketing)/claim-plan-dialog.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; - -import { useSearchParams } from 'next/navigation'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info, Loader } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -export const ZClaimPlanDialogFormSchema = z.object({ - name: z.string().min(3), - email: z.string().email(), -}); - -export type TClaimPlanDialogFormSchema = z.infer; - -export type ClaimPlanDialogProps = { - className?: string; - planId: string; - children: React.ReactNode; -}; - -export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { - const params = useSearchParams(); - const { toast } = useToast(); - const event = usePlausible(); - - const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - mode: 'onBlur', - defaultValues: { - name: params?.get('name') ?? '', - email: params?.get('email') ?? '', - }, - resolver: zodResolver(ZClaimPlanDialogFormSchema), - }); - - const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { - try { - const delay = new Promise((resolve) => setTimeout(resolve, 1000)); - - const [redirectUrl] = await Promise.all([ - claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), - delay, - ]); - - event('claim-plan-pricing'); - - window.location.href = redirectUrl; - } catch (error) { - event('claim-plan-failed'); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - return ( - - {children} - - - - Claim your plan - - - We're almost there! Please enter your email address and name to claim your plan. - - - -
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

- You have cancelled the payment process. If you didn't mean to do this, please - try again. -

-
-
-
- )} - -
- - - - - -
- -
- - - - - -
- - -
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx b/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx deleted file mode 100644 index 2cbaaef53..000000000 --- a/apps/web/src/components/(marketing)/faster-smarter-beautiful-bento.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardBeautifulFigure from '~/assets/card-beautiful-figure.png'; -import cardFastFigure from '~/assets/card-fast-figure.png'; -import cardSmartFigure from '~/assets/card-smart-figure.png'; - -export type FasterSmarterBeautifulBentoProps = HTMLAttributes; - -export const FasterSmarterBeautifulBento = ({ - className, - ...props -}: FasterSmarterBeautifulBentoProps) => { - return ( -
-
- background pattern -
-

- A 10x better signing experience. - Faster, smarter and more beautiful. -

- -
- - -

- Fast. - When it comes to sending or receiving a contract, you can count on lightning-fast - speeds. -

- -
- its fast -
-
-
- - - -

- Beautiful. - Because signing should be celebrated. That’s why we care about the smallest detail in - our product. -

- -
- its fast -
-
-
- - - -

- Smart. - Our custom templates come with smart rules that can help you save time and energy. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/footer.tsx b/apps/web/src/components/(marketing)/footer.tsx deleted file mode 100644 index 823ece92e..000000000 --- a/apps/web/src/components/(marketing)/footer.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { Github, Slack, Twitter } from 'lucide-react'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type FooterProps = HTMLAttributes; - -export const Footer = ({ className, ...props }: FooterProps) => { - return ( -
-
-
- - Documenso Logo - - -
- - - - - - - - - - - -
-
- -
- - Pricing - - - - Status - - - - Support - - - {/* - Privacy - */} -
-
-
-

- © {new Date().getFullYear()} Documenso, Inc. All rights reserved. -

-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/header.tsx b/apps/web/src/components/(marketing)/header.tsx deleted file mode 100644 index 5a1fa3b89..000000000 --- a/apps/web/src/components/(marketing)/header.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { cn } from '@documenso/ui/lib/utils'; - -export type HeaderProps = HTMLAttributes; - -export const Header = ({ className, ...props }: HeaderProps) => { - return ( -
- - Documenso Logo - - -
- - Pricing - - - - Sign in - -
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/hero.tsx b/apps/web/src/components/(marketing)/hero.tsx deleted file mode 100644 index 7896a010e..000000000 --- a/apps/web/src/components/(marketing)/hero.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; - -import { Variants, motion } from 'framer-motion'; -import { Github } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -import backgroundPattern from '~/assets/background-pattern.png'; - -import { Widget } from './widget'; - -export type HeroProps = { - className?: string; - starCount?: number; - [key: string]: unknown; -}; - -const BackgroundPatternVariants: Variants = { - initial: { - opacity: 0, - }, - - animate: { - opacity: 1, - - transition: { - delay: 1, - duration: 1.2, - }, - }, -}; - -const HeroTitleVariants: Variants = { - initial: { - opacity: 0, - y: 60, - }, - animate: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - }, - }, -}; - -export const Hero = ({ className, starCount, ...props }: HeroProps) => { - const event = usePlausible(); - - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - requestAnimationFrame(() => { - el.focus(); - }); - } - }; - - return ( - -
- - background pattern - -
- -
- - Document signing, - finally open source. - - - - - - event('view-github')}> - - - - -
- - - Documenso - The open source DocuSign alternative | Product Hunt - - -
- - - - Documenso Supporter Pledge -

- Our mission is to create an open signing infrastructure that empowers the world, - enabling businesses to embrace openness, cooperation, and transparency. We believe - that signing, as a fundamental act, should embody these values. By offering an - open-source signing solution, we aim to make document signing accessible, transparent, - and trustworthy. -

- -

- Through our platform, called Documenso, we strive to earn your trust by allowing - self-hosting and providing complete visibility into its inner workings. We value - inclusivity and foster an environment where diverse perspectives and contributions are - welcomed, even though we may not implement them all. -

- -

- At Documenso, we envision a web-enabled future for business and contracts, and we are - committed to being the leading provider of open signing infrastructure. By combining - exceptional product design with open-source principles, we aim to deliver a robust and - well-designed application that exceeds your expectations. -

- -

- We understand that exceptional products are born from exceptional communities, and we - invite you to join our open-source community. Your contributions, whether technical or - non-technical, will help shape the future of signing. Together, we can create a better - future for everyone. -

- -

- Today we invite you to join us on this journey: By signing this mission statement you - signal your support of Documenso's mission{' '} - - (in a non-legally binding, but heartfelt way) - {' '} - and lock in the early supporter plan for forever, including everything we build this - year. -

- -
-

Timur & Lucas

-
- -
- Timur Ercan & Lucas Smith -

Co-Founders, Documenso

-
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/open-build-template-bento.tsx b/apps/web/src/components/(marketing)/open-build-template-bento.tsx deleted file mode 100644 index e7920500b..000000000 --- a/apps/web/src/components/(marketing)/open-build-template-bento.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardBuildFigure from '~/assets/card-build-figure.png'; -import cardOpenFigure from '~/assets/card-open-figure.png'; -import cardTemplateFigure from '~/assets/card-template-figure.png'; - -export type OpenBuildTemplateBentoProps = HTMLAttributes; - -export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => { - return ( -
-
- background pattern -
-

- Truly your own. - Customise and expand. -

- -
- - -

- Open Source or Hosted. - It’s up to you. Either clone our repository or rely on our easy to use hosting - solution. -

- -
- its fast -
-
-
- - - -

- Build on top. - Make it your own through advanced customization and adjustability. -

- -
- its fast -
-
-
- - - -

- Template Store (Soon). - Choose a template from the community app store. Or submit your own template for others - to use. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/password-reveal.tsx b/apps/web/src/components/(marketing)/password-reveal.tsx deleted file mode 100644 index 7e1cb72a3..000000000 --- a/apps/web/src/components/(marketing)/password-reveal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; - -export type PasswordRevealProps = { - password: string; -}; - -export const PasswordReveal = ({ password }: PasswordRevealProps) => { - const { toast } = useToast(); - const [, copy] = useCopyToClipboard(); - - const onCopyClick = () => { - copy(password).then(() => { - toast({ - title: 'Copied to clipboard', - description: 'Your password has been copied to your clipboard.', - }); - }); - }; - - return ( - - ); -}; diff --git a/apps/web/src/components/(marketing)/pricing-table.tsx b/apps/web/src/components/(marketing)/pricing-table.tsx deleted file mode 100644 index 73003abdc..000000000 --- a/apps/web/src/components/(marketing)/pricing-table.tsx +++ /dev/null @@ -1,180 +0,0 @@ -'use client'; - -import { HTMLAttributes, useMemo, useState } from 'react'; - -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; - -import { AnimatePresence, motion } from 'framer-motion'; -import { usePlausible } from 'next-plausible'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -import { ClaimPlanDialog } from './claim-plan-dialog'; - -export type PricingTableProps = HTMLAttributes; - -const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; - -export const PricingTable = ({ className, ...props }: PricingTableProps) => { - const params = useSearchParams(); - const event = usePlausible(); - - const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - // eslint-disable-next-line turbo/no-undeclared-env-vars - params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - ? 'YEARLY' - : 'MONTHLY', - ); - - const planId = useMemo(() => { - if (period === 'MONTHLY') { - // eslint-disable-next-line turbo/no-undeclared-env-vars - return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; - } - - // eslint-disable-next-line turbo/no-undeclared-env-vars - return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID; - }, [period]); - - return ( -
-
- - setPeriod('MONTHLY')} - > - Monthly - {period === 'MONTHLY' && ( - - )} - - - setPeriod('YEARLY')} - > - Yearly -
- Save $60 -
- {period === 'YEARLY' && ( - - )} -
-
-
- -
-
-

Self Hosted

-

Free

- -

- For small teams and individuals who need a simple solution -

- - - -
-

Host your own instance

-

Full Control

-

Customizability

-

Docker Ready

-

Community Support

-

Free, Forever

-
-
- -
-

Community

-
- - {period === 'MONTHLY' && $30} - {period === 'YEARLY' && $300} - -
- -

- For fast-growing companies that aim to scale across multiple teams. -

- - - - - -
-

Documenso Early Adopter Deal:

-

Join the movement

-

Simple signing solution

-

Email and Slack assistance

-

- Includes all upcoming features -

-

Fixed, straightforward pricing

-
-
- -
-

Enterprise

-

Pricing on request

- -

- For large organizations that need extra flexibility and control. -

- - event('enterprise-contact')} - > - - - -
-

Everything in Community, plus:

-

Custom Subdomain

-

Compliance Check

-

Guaranteed Uptime

-

Reporting & Analysis

-

24/7 Support

-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx b/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx deleted file mode 100644 index 05b6a3232..000000000 --- a/apps/web/src/components/(marketing)/share-connect-paid-widget-bento.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { HTMLAttributes } from 'react'; - -import Image from 'next/image'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import backgroundPattern from '~/assets/background-pattern.png'; -import cardConnectionsFigure from '~/assets/card-connections-figure.png'; -import cardPaidFigure from '~/assets/card-paid-figure.png'; -import cardSharingFigure from '~/assets/card-sharing-figure.png'; -import cardWidgetFigure from '~/assets/card-widget-figure.png'; - -export type ShareConnectPaidWidgetBentoProps = HTMLAttributes; - -export const ShareConnectPaidWidgetBento = ({ - className, - ...props -}: ShareConnectPaidWidgetBentoProps) => { - return ( -
-
- background pattern -
-

- Integrates with all your favourite tools. - Send, connect, receive and embed everywhere. -

- -
- - -

- Easy Sharing (Soon). - Receive your personal link to share with everyone you care about. -

- -
- its fast -
-
-
- - - -

- Connections (Soon). - Create connections and automations with Zapier and more to integrate with your - favorite tools. -

- -
- its fast -
-
-
- - - -

- Get paid (Soon). - Integrated payments with stripe so you don’t have to worry about getting paid. -

- -
- its fast -
-
-
- - - -

- React Widget (Soon). - Easily embed Documenso into your product. Simply copy and paste our react widget into - your application. -

- -
- its fast -
-
-
-
-
- ); -}; diff --git a/apps/web/src/components/(marketing)/widget.tsx b/apps/web/src/components/(marketing)/widget.tsx deleted file mode 100644 index 1a15069e9..000000000 --- a/apps/web/src/components/(marketing)/widget.tsx +++ /dev/null @@ -1,400 +0,0 @@ -'use client'; - -import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Loader } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { Controller, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -const ZWidgetFormSchema = z - .object({ - email: z.string().email({ message: 'Please enter a valid email address.' }), - name: z.string().min(3, { message: 'Please enter a valid name.' }), - }) - .and( - z.union([ - z.object({ - signatureDataUrl: z.string().min(1), - signatureText: z.null().or(z.string().max(0)), - }), - z.object({ - signatureDataUrl: z.null().or(z.string().max(0)), - signatureText: z.string().min(1), - }), - ]), - ); - -export type TWidgetFormSchema = z.infer; - -export type WidgetProps = HTMLAttributes; - -export const Widget = ({ className, children, ...props }: WidgetProps) => { - const { toast } = useToast(); - const event = usePlausible(); - - const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); - const [showSigningDialog, setShowSigningDialog] = useState(false); - const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); - - const { - control, - register, - handleSubmit, - setValue, - trigger, - watch, - formState: { errors, isSubmitting, isValid }, - } = useForm({ - mode: 'onChange', - defaultValues: { - email: '', - name: '', - signatureDataUrl: null, - signatureText: '', - }, - resolver: zodResolver(ZWidgetFormSchema), - }); - - const signatureDataUrl = watch('signatureDataUrl'); - const signatureText = watch('signatureText'); - - const stepsRemaining = useMemo(() => { - if (step === 'NAME') { - return 2; - } - - if (step === 'SIGN') { - return 1; - } - - return 3; - }, [step]); - - const onNextStepClick = () => { - if (step === 'EMAIL') { - setStep('NAME'); - - setTimeout(() => { - document.querySelector('#name')?.focus(); - }, 0); - } - - if (step === 'NAME') { - setStep('SIGN'); - - setTimeout(() => { - document.querySelector('#signatureText')?.focus(); - }, 0); - } - }; - - const onEnterPress = (callback: () => void) => { - return (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - - callback(); - } - }; - }; - - const onSignatureConfirmClick = () => { - setValue('signatureDataUrl', draftSignatureDataUrl); - setValue('signatureText', ''); - - trigger('signatureDataUrl'); - setShowSigningDialog(false); - }; - - const onFormSubmit = async ({ - email, - name, - signatureDataUrl, - signatureText, - }: TWidgetFormSchema) => { - try { - const delay = new Promise((resolve) => setTimeout(resolve, 1000)); - - // eslint-disable-next-line turbo/no-undeclared-env-vars - const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; - - const claimPlanInput = signatureDataUrl - ? { - name, - email, - planId, - signatureDataUrl: signatureDataUrl!, - signatureText: null, - } - : { - name, - email, - planId, - signatureDataUrl: null, - signatureText: signatureText!, - }; - - const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); - - event('claim-plan-widget'); - - window.location.href = result; - } catch (error) { - event('claim-plan-failed'); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - return ( - <> - -
-
- {children} -
- -
-

Sign up for the community plan

-

- with Timur Ercan & Lucas Smith from Documenso -

- -
- - - - - - ( -
- - field.value !== '' && - !errors.email?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- - {(step === 'NAME' || step === 'SIGN') && ( - - - - ( -
- - field.value !== '' && - !errors.name?.message && - onEnterPress(onNextStepClick)(e) - } - {...field} - /> - -
- -
-
- )} - /> - - -
- )} -
- -
- -
-

{stepsRemaining} step(s) until signed

-

Minimise contract

-
- -
-
-
- - - setShowSigningDialog(true)} - > -
- {!signatureText && signatureDataUrl && ( - user signature - )} - - {signatureText && ( -

- {signatureText} -

- )} -
- -
e.stopPropagation()} - > - { - if (e.target.value !== '') { - setValue('signatureDataUrl', null); - } - }, - })} - /> - - -
-
-
- -
- - - - - - Add your signature - - - - By signing you signal your support of Documenso's mission in a

- non-legally binding, but heartfelt way.

-

You also unlock the option to purchase the early supporter plan including - everything we build this year for fixed price. -
- - - - - - - - -
-
- - ); -}; diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx index 4e1ccf742..126a52f4f 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/web/src/components/formatter/document-status.tsx @@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react'; import { CheckCircle2, Clock, File } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; import { cn } from '@documenso/ui/lib/utils'; type FriendlyStatus = { label: string; - icon: LucideIcon; + icon?: LucideIcon; color: string; }; -const FRIENDLY_STATUS_MAP: Record = { +const FRIENDLY_STATUS_MAP: Record = { PENDING: { label: 'Pending', icon: Clock, @@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record = { icon: File, color: 'text-yellow-500', }, + INBOX: { + label: 'Inbox', + icon: SignatureIcon, + color: 'text-muted-foreground', + }, + ALL: { + label: 'All', + color: 'text-muted-foreground', + }, }; export type DocumentStatusProps = HTMLAttributes & { - status: InternalDocumentStatus; + status: ExtendedDocumentStatus; inheritColor?: boolean; }; @@ -45,11 +55,13 @@ export const DocumentStatus = ({ return ( - + {Icon && ( + + )} {label} ); diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 7c595421e..508579b78 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -39,6 +39,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { const { register, handleSubmit, + reset, formState: { errors, isSubmitting }, } = useForm({ values: { @@ -56,6 +57,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { password, }); + reset(); + toast({ title: 'Password updated', description: 'Your password has been updated successfully.', @@ -73,7 +76,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { title: 'An unknown error occurred', variant: 'destructive', description: - 'We encountered an unknown error while attempting to sign you In. Please try again later.', + 'We encountered an unknown error while attempting to update your password. Please try again later.', }); } } diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index d65a0ce27..5b4045abb 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -21,7 +21,7 @@ import { FormErrorMessage } from '../form/form-error-message'; export const ZProfileFormSchema = z.object({ name: z.string().min(1), - signature: z.string().min(1), + signature: z.string().min(1, 'Signature Pad cannot be empty'), }); export type TProfileFormSchema = z.infer; @@ -122,6 +122,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { /> )} /> +
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 9e9a01976..2ffb2798e 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -76,10 +76,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { return (
{ - e.preventDefault(); - handleSubmit(onFormSubmit)(); - }} + onSubmit={handleSubmit(onFormSubmit)} >