Compare commits
77 Commits
feat/docum
...
feat/promi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca40e983e3 | ||
|
|
ba054ae915 | ||
|
|
1d1c6e5a55 | ||
|
|
bf71d2a14e | ||
|
|
163911255e | ||
|
|
24e38a3bbc | ||
|
|
dfd714f16a | ||
|
|
722081f89e | ||
|
|
f0e1df22b8 | ||
|
|
615cb263fb | ||
|
|
650b69ae56 | ||
|
|
eb4be963e3 | ||
|
|
27c27743e3 | ||
|
|
92930a2f63 | ||
|
|
7ad3365b0e | ||
|
|
f8bf4fea36 | ||
|
|
10cd8144eb | ||
|
|
66973a3745 | ||
|
|
85677bb792 | ||
|
|
70a5105783 | ||
|
|
420372ac9e | ||
|
|
6b00282a87 | ||
|
|
dae1001cbb | ||
|
|
af81d99b2a | ||
|
|
2751adc463 | ||
|
|
396ce9f3f3 | ||
|
|
3f4f66d878 | ||
|
|
d6751d7a26 | ||
|
|
0e32baff0b | ||
|
|
f76bf4c2c7 | ||
|
|
0d8532ab6d | ||
|
|
490d3d51e1 | ||
|
|
2ab796910e | ||
|
|
07102588be | ||
|
|
04f9422f24 | ||
|
|
05a7f5e178 | ||
|
|
7bae814f96 | ||
|
|
ea45e38fa0 | ||
|
|
6d9a85112f | ||
|
|
346efd19db | ||
|
|
617143a47f | ||
|
|
66f067276e | ||
|
|
fc10d0449f | ||
|
|
083f3e7108 | ||
|
|
af307a2a49 | ||
|
|
b063758ee5 | ||
|
|
4964b252e3 | ||
|
|
e468f5bbc9 | ||
|
|
c5b7b8a18a | ||
|
|
a8a1fbb829 | ||
|
|
3c2a4892e7 | ||
|
|
6d9e84d327 | ||
|
|
73b4e30c97 | ||
|
|
bd01545a70 | ||
|
|
6d360e581d | ||
|
|
ba95818da4 | ||
|
|
d0720f4c70 | ||
|
|
f60cb22f11 | ||
|
|
e0cb4314fb | ||
|
|
0571137a60 | ||
|
|
30aabf50eb | ||
|
|
8441a5eb98 | ||
|
|
259ab49bc1 | ||
|
|
2f2d5dfc0b | ||
|
|
0f27f4261b | ||
|
|
9b92cad2db | ||
|
|
ad1ff6159c | ||
|
|
f633b17f17 | ||
|
|
8fa16001e6 | ||
|
|
e111234460 | ||
|
|
034072f50e | ||
|
|
a7664d79fd | ||
|
|
45d0d3f7e8 | ||
|
|
6e62eb8d81 | ||
|
|
3b9c57fe5c | ||
|
|
90e28cd3a4 | ||
|
|
e743e56787 |
@@ -12,6 +12,8 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# [[DATABASE]]
|
||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
@@ -52,7 +54,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
# OPTIONAL: Defines the host to use for PostHog.
|
||||
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
|
||||
# OPTIONAL: Leave blank to disable billing.
|
||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
|
||||
# This is only required for the marketing site
|
||||
# [[REDIS]]
|
||||
|
||||
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# Config files
|
||||
*.config.js
|
||||
*.config.cjs
|
||||
|
||||
# Statically hosted javascript files
|
||||
apps/*/public/*.js
|
||||
apps/*/public/*.cjs
|
||||
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "ci dependencies"
|
||||
- "ci"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/marketing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "feat/refresh"
|
||||
labels:
|
||||
- "npm dependencies"
|
||||
- "frontend"
|
||||
open-pull-requests-limit: 10
|
||||
4
.github/workflows/ci.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ "feat/refresh" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
|
||||
45
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ feat/refresh ]
|
||||
pull_request:
|
||||
branches: [ feat/refresh ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Documenso
|
||||
run: npm run build
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
21
.github/workflows/semantic-pull-requests.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: "Validate PR Name"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-pr:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
10
.gitignore
vendored
@@ -37,3 +37,13 @@ yarn-error.log*
|
||||
|
||||
# contentlayer
|
||||
.contentlayer
|
||||
|
||||
# intellij
|
||||
.idea
|
||||
|
||||
# vscode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
4
.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run commitlint -- $1
|
||||
0
.husky/pre-commit
Normal file → Executable file
@@ -89,6 +89,10 @@ Documenso is built using awesome open source tech including:
|
||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||
- [Zod for schema declaration and validation](https://zod.dev/)
|
||||
- [Lucide React for icons in React app](https://lucide.dev/)
|
||||
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
||||
- [Radix UI for component library](https://www.radix-ui.com/)
|
||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||
|
||||
|
||||
56
apps/marketing/content/blog/next.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Preview the next Documenso
|
||||
description: We're redesigning Documenso by making it more elegant and appropriately playful. Here's a sneak peek.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2023-08-21
|
||||
tags:
|
||||
- Design
|
||||
- Preview
|
||||
---
|
||||
|
||||
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
|
||||
|
||||
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
|
||||
|
||||
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||
|
||||
## Preview the next Documenso
|
||||
|
||||
We redesigned the whole signing flow to make it more appealing and more convenient.
|
||||
|
||||
We improved the overall look and feel by making it more elegant and appropriately playful. Focused on the task at hand, but explicitly enjoying doing it.
|
||||
|
||||
**We call it happy minimalism.**
|
||||
|
||||
We paid particular attention to the moment of signing, which should be celebrated.
|
||||
|
||||
The image below is the final bloom of the completion celebration we added:
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/blog-fig-preview-documenso.webp"
|
||||
width="2000"
|
||||
height="1268"
|
||||
alt="Figure 1"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">"You've signed a new document."</figcaption>
|
||||
</figure>
|
||||
|
||||
## Kicking off a new phase of collaboration
|
||||
|
||||
This preview also is the kickoff for a new phase of how we collaborate with the community.
|
||||
|
||||
We recently [switched to Discord](https://documenso.com/blog/switching-from-slack-to-discord) to set up a more developer-friendly, community-driven environment, and we just released the [public roadmap](https://documen.so/launches).
|
||||
|
||||
As always, if you have any questions or feedback, please reach out. We love to hear from you.
|
||||
|
||||
Best from Hamburg,
|
||||
|
||||
Timur
|
||||
|
||||
Make sure to [star the GitHub repository](https://documen.so/github), [follow us on X](http://documen.so/twitter) and [join the Discord server](https://documen.so/discord) to keep up to date with all things Documenso.
|
||||
|
||||
We're building a beautiful, open-source alternative to DocuSign.
|
||||
@@ -4,10 +4,11 @@
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "PORT=3001 next dev",
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.32.5",
|
||||
"typescript": "5.1.6",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
|
||||
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/marketing/public/logo_icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
@@ -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 } }) => {
|
||||
|
||||
@@ -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 } }) => {
|
||||
|
||||
@@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
|
||||
},
|
||||
{
|
||||
date: '2023-05',
|
||||
amount: 300_000,
|
||||
amount: 290_000,
|
||||
},
|
||||
{
|
||||
date: '2023-07',
|
||||
amount: 1_550_000,
|
||||
amount: 1_540_000,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
|
||||
import { GithubMetric } from './gh-metrics';
|
||||
import { TeamMembers } from './team-members';
|
||||
|
||||
export const revalidate = 86400;
|
||||
export const revalidate = 3600;
|
||||
|
||||
const ZGithubStatsResponse = z.object({
|
||||
stargazers_count: z.number(),
|
||||
@@ -43,7 +43,7 @@ export default async function OpenPage() {
|
||||
accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
})
|
||||
.then((res) => 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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
14
apps/marketing/src/app/robots.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/*',
|
||||
disallow: ['/_next/*'],
|
||||
},
|
||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
41
apps/marketing/src/app/sitemap.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = getBaseUrl();
|
||||
const lastModified = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified,
|
||||
},
|
||||
...allGenericPages.map((doc) => ({
|
||||
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||
lastModified,
|
||||
})),
|
||||
{
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified,
|
||||
},
|
||||
...allBlogPosts.map((doc) => ({
|
||||
url: `${baseUrl}/${doc._raw.flattenedPath}`,
|
||||
lastModified,
|
||||
})),
|
||||
{
|
||||
url: `${baseUrl}/open`,
|
||||
lastModified,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/oss-friends`,
|
||||
lastModified,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/pricing`,
|
||||
lastModified,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Info, Loader } from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
|
||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
const delay = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const [redirectUrl] = await Promise.all([
|
||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||
@@ -85,7 +87,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
@@ -97,50 +99,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className={cn('flex flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
{params?.get('cancelled') === 'true' && (
|
||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm leading-5 text-yellow-700">
|
||||
You have cancelled the payment process. If you didn't mean to do this, please
|
||||
try again.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
|
||||
{params?.get('cancelled') === 'true' && (
|
||||
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm leading-5 text-yellow-700">
|
||||
You have cancelled the payment process. If you didn't mean to do this, please
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Name</Label>
|
||||
|
||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Name</Label>
|
||||
<div>
|
||||
<Label className="text-slate-500">Email</Label>
|
||||
|
||||
<Input type="text" className="mt-2" {...register('name')} autoFocus />
|
||||
<Input type="email" className="mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.name} />
|
||||
</div>
|
||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Email</Label>
|
||||
|
||||
<Input type="email" className="mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||
? 'Monthly'
|
||||
: 'Yearly'}
|
||||
)
|
||||
</Button>
|
||||
<Button type="submit" size="lg" loading={isSubmitting}>
|
||||
Claim the Community Plan (
|
||||
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
|
||||
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||
? 'Monthly'
|
||||
: 'Yearly'}
|
||||
)
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Github, Slack, Twitter } from 'lucide-react';
|
||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
@@ -36,11 +36,11 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://documenso.slack.com"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
className="hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Slack className="h-6 w-6" />
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +61,14 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
Open
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://shop.documenso.com"
|
||||
target="_blank"
|
||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://status.documenso.com"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { HamburgerMenu } from './mobile-hamburger';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLElement>;
|
||||
|
||||
export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className={cn('flex items-center justify-between', className)} {...props}>
|
||||
<Link href="/">
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={0}></Image>
|
||||
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="hidden items-center gap-x-6 md:flex">
|
||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Pricing
|
||||
</Link>
|
||||
@@ -35,6 +42,15 @@ export const Header = ({ className, ...props }: HeaderProps) => {
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<HamburgerMenu
|
||||
onToggleMenuOpen={() => setIsHamburgerMenuOpen((v) => !v)}
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
/>
|
||||
<MobileNavigation
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export interface HamburgerMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onToggleMenuOpen?: () => void;
|
||||
}
|
||||
|
||||
export const HamburgerMenu = ({ isMenuOpen, onToggleMenuOpen }: HamburgerMenuProps) => {
|
||||
return (
|
||||
<div className="flex md:hidden">
|
||||
<Button variant="outline" className="z-20 w-10 p-0" onClick={onToggleMenuOpen}>
|
||||
{isMenuOpen ? <X /> : <Menu />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
apps/marketing/src/components/(marketing)/mobile-navigation.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Github, MessagesSquare, Twitter } from 'lucide-react';
|
||||
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
export type MobileNavigationProps = {
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
};
|
||||
|
||||
export const MENU_NAVIGATION_LINKS = [
|
||||
{
|
||||
href: '/blog',
|
||||
text: 'Blog',
|
||||
},
|
||||
{
|
||||
href: '/pricing',
|
||||
text: 'Pricing',
|
||||
},
|
||||
{
|
||||
href: 'https://status.documenso.com',
|
||||
text: 'Status',
|
||||
},
|
||||
{
|
||||
href: 'mailto:support@documenso.com',
|
||||
text: 'Support',
|
||||
},
|
||||
{
|
||||
href: '/privacy',
|
||||
text: 'Privacy',
|
||||
},
|
||||
{
|
||||
href: 'https://app.documenso.com/login',
|
||||
text: 'Sign in',
|
||||
},
|
||||
];
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="w-full max-w-[400px]">
|
||||
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
|
||||
<Image src="/logo.png" alt="Documenso Logo" width={170} height={25} />
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 flex w-full flex-col items-start gap-y-4"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={{
|
||||
staggerChildren: 0.2,
|
||||
}}
|
||||
>
|
||||
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
|
||||
<motion.div
|
||||
key={href}
|
||||
variants={{
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: shouldReduceMotion ? 0 : 100,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
className="text-2xl font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
href={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="mx-auto mt-8 flex w-full flex-wrap items-center gap-x-4 gap-y-4 ">
|
||||
<Link
|
||||
href="https://twitter.com/documenso"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Twitter className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<Github className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
className="text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
>
|
||||
<MessagesSquare className="h-6 w-6" />
|
||||
</Link>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
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';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
@@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||
setValue('signatureText', '');
|
||||
|
||||
trigger('signatureDataUrl');
|
||||
void trigger('signatureDataUrl');
|
||||
setShowSigningDialog(false);
|
||||
};
|
||||
|
||||
@@ -135,7 +135,9 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
signatureText,
|
||||
}: TWidgetFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
const delay = new Promise<void>((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;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
TouchEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
|
||||
const DPI = 2;
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
const perfectFreehandOptions = useMemo(() => {
|
||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||
|
||||
return {
|
||||
size,
|
||||
thinning: 0.25,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
end: {
|
||||
taper: size * 2,
|
||||
},
|
||||
} satisfies StrokeOptions;
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(true);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.save();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points];
|
||||
|
||||
if (addPoint) {
|
||||
newPoints.push(point);
|
||||
|
||||
setPoints(newPoints);
|
||||
}
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if ('buttons' in event && event.buttons === 1) {
|
||||
onMouseDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onMouseUp(event, false);
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * DPI;
|
||||
$el.current.height = $el.current.clientHeight * DPI;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('relative block', className)}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
|
||||
Clear Signature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
apps/marketing/src/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
@@ -9,16 +9,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"contentlayer/generated": [
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"types": [
|
||||
"@documenso/lib/types/next-auth.d.ts"
|
||||
],
|
||||
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||
"strictNullChecks": true,
|
||||
"incremental": false
|
||||
},
|
||||
@@ -29,7 +23,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "PORT=3000 next dev",
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@@ -27,13 +28,15 @@
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"posthog-js": "^1.75.3",
|
||||
"posthog-node": "^3.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-pdf": "^7.1.1",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.1.6",
|
||||
"zod": "^3.21.4"
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
@@ -10,8 +10,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||
}
|
||||
|
||||
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
@@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
|
||||
@@ -2,14 +2,28 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
@@ -19,6 +33,8 @@ export type EditDocumentFormProps = {
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
@@ -26,27 +42,103 @@ export const EditDocumentForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
}: EditDocumentFormProps) => {
|
||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
const onNextStep = () => {
|
||||
if (step === 'signers') {
|
||||
setStep('fields');
|
||||
}
|
||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||
signers: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
if (step === 'fields') {
|
||||
setStep('subject');
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreviousStep = () => {
|
||||
if (step === 'fields') {
|
||||
setStep('signers');
|
||||
}
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
if (step === 'subject') {
|
||||
setStep('fields');
|
||||
router.refresh();
|
||||
|
||||
setStep('subject');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while sending the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,35 +154,43 @@ export const EditDocumentForm = ({
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
/>
|
||||
)}
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'subject' && (
|
||||
<AddSubjectFormPartial
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
/>
|
||||
)}
|
||||
{step === 'fields' && (
|
||||
<AddFieldsFormPartial
|
||||
documentFlow={documentFlow.fields}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'subject' && (
|
||||
<AddSubjectFormPartial
|
||||
documentFlow={documentFlow.subject}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
export type LoadablePDFCard = PDFViewerProps & {
|
||||
className?: string;
|
||||
pdfClassName?: string;
|
||||
};
|
||||
|
||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
|
||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||
return (
|
||||
<Card className={className} gradient {...props}>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer className={pdfClassName} {...props} />
|
||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,13 @@ export default function Loading() {
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
Loading Document...
|
||||
</h1>
|
||||
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8">
|
||||
<div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
|
||||
@@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
@@ -69,18 +72,28 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
/>
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Plus } 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 { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import {
|
||||
PeriodSelectorValue,
|
||||
isPeriodSelectorValue,
|
||||
} from '~/components/(dashboard)/period-selector/types';
|
||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { UploadDocument } from '../dashboard/upload-document';
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
@@ -37,10 +31,12 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
});
|
||||
|
||||
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
// 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,
|
||||
@@ -52,8 +48,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
perPage,
|
||||
});
|
||||
|
||||
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
@@ -72,25 +66,13 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">All Documents</h1>
|
||||
<UploadDocument />
|
||||
|
||||
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||
<Tabs defaultValue={status}>
|
||||
<Tabs defaultValue={shouldDefaultToPending ? InternalDocumentStatus.PENDING : status}>
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
||||
<Link href={getTabHref('ALL')}>All</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.DRAFT, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
||||
@@ -110,26 +92,30 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.DRAFT, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
||||
<Link href={getTabHref('ALL')}>All</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
|
||||
<Button>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
|
||||
{isNoResults ? (
|
||||
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
|
||||
) : (
|
||||
<DocumentsDataTable results={results} />
|
||||
)}
|
||||
<DocumentsDataTable results={results} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
const isBillingEnabled = await getServerComponentFlag('app_billing');
|
||||
|
||||
// Redirect if subscriptions are not enabled.
|
||||
if (!IS_SUBSCRIPTIONS_ENABLED) {
|
||||
if (!isBillingEnabled) {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
@@ -51,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
return (
|
||||
<form
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
|
||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
@@ -65,7 +64,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
@@ -99,10 +98,10 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
@@ -110,8 +109,8 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
disabled={!isComplete || isSubmitting}
|
||||
>
|
||||
|
||||
@@ -149,7 +149,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
disabled={!localFullName}
|
||||
onClick={() => {
|
||||
setShowFullNameModal(false);
|
||||
onSign('local');
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@@ -9,9 +10,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { SigningForm } from './form';
|
||||
@@ -58,7 +57,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-8">
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
|
||||
@@ -12,10 +12,9 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
@@ -183,7 +182,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
disabled={!localSignature}
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
onSign('local');
|
||||
void onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
@@ -5,8 +7,11 @@ 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';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
@@ -37,7 +42,9 @@ export const metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
@@ -51,15 +58,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
<PostHogPageview />
|
||||
</Suspense>
|
||||
|
||||
<body>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 14 MiB |
@@ -46,7 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
||||
className={`
|
||||
${zIndexClass}
|
||||
${firstClass}
|
||||
h-10 w-10 border-2 border-solid border-white`}
|
||||
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -11,7 +11,17 @@ import {
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
recipients: Recipient[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const StackAvatarsWithTooltip = ({
|
||||
recipients,
|
||||
position,
|
||||
children,
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||
);
|
||||
@@ -32,9 +42,10 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex cursor-pointer">
|
||||
<StackAvatars recipients={recipients} />
|
||||
{children || <StackAvatars recipients={recipients} />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
<TooltipContent side={position}>
|
||||
<div className="flex flex-col gap-y-5 p-1">
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -2,30 +2,15 @@
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground': pathname?.startsWith('/dashboard'),
|
||||
},
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
{/* No Nav tabs while there is only one main page */}
|
||||
{/* <Link
|
||||
href="/documents"
|
||||
className={cn(
|
||||
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ',
|
||||
@@ -35,14 +20,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</Link>
|
||||
{/* <Link
|
||||
href="/settings/profile"
|
||||
className={cn('font-medium leading-5 text-[#A1A1AA] hover:opacity-80', {
|
||||
'text-primary-foreground': pathname?.startsWith('/settings'),
|
||||
})}
|
||||
>
|
||||
Settings
|
||||
</Link> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -28,11 +27,19 @@ import {
|
||||
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 isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const initials =
|
||||
user.name
|
||||
?.split(' ')
|
||||
@@ -40,8 +47,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
.slice(0, 2)
|
||||
.join('') ?? 'UK';
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -69,7 +74,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="cursor-pointer">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
@@ -113,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
signOut({
|
||||
void signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
export const LazyPDFViewer = dynamic(
|
||||
async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
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) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link href="/settings/profile">
|
||||
@@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
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) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
@@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{IS_SUBSCRIPTIONS_ENABLED && (
|
||||
{isBillingEnabled && (
|
||||
<Link href="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
|
||||
|
||||
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
const delay = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const [redirectUrl] = await Promise.all([
|
||||
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
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';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
@@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
setValue('signatureDataUrl', draftSignatureDataUrl);
|
||||
setValue('signatureText', '');
|
||||
|
||||
trigger('signatureDataUrl');
|
||||
void trigger('signatureDataUrl');
|
||||
setShowSigningDialog(false);
|
||||
};
|
||||
|
||||
@@ -135,7 +135,9 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
signatureText,
|
||||
}: TWidgetFormSchema) => {
|
||||
try {
|
||||
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000));
|
||||
const delay = new Promise<void>((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;
|
||||
|
||||
@@ -13,11 +13,6 @@ type FriendlyStatus = {
|
||||
};
|
||||
|
||||
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
icon: File,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
@@ -28,6 +23,11 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-500',
|
||||
},
|
||||
DRAFT: {
|
||||
label: 'Draft',
|
||||
icon: File,
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
};
|
||||
|
||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
|
||||
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
import { TAddSignersFormSchema } from './add-signers.types';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
|
||||
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@@ -18,8 +18,8 @@ import { FormErrorMessage } from '../form/form-error-message';
|
||||
|
||||
export const ZPasswordFormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6),
|
||||
repeatedPassword: z.string().min(6),
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
message: 'Passwords do not match',
|
||||
@@ -92,6 +92,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
@@ -107,6 +110,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
@@ -14,10 +14,10 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
@@ -44,7 +44,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: '/dashboard',
|
||||
callbackUrl: '/documents',
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
@@ -76,10 +76,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit(onFormSubmit)();
|
||||
}}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
@@ -99,6 +96,9 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,13 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
export const ZSignUpFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(1),
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||
@@ -106,6 +105,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { Point } from './point';
|
||||
|
||||
export class Canvas {
|
||||
private readonly $canvas: HTMLCanvasElement;
|
||||
private readonly $offscreenCanvas: HTMLCanvasElement;
|
||||
|
||||
private currentCanvasWidth = 0;
|
||||
private currentCanvasHeight = 0;
|
||||
|
||||
private points: Point[] = [];
|
||||
private onChangeHandlers: Array<(_canvas: Canvas, _cleared: boolean) => void> = [];
|
||||
|
||||
private isPressed = false;
|
||||
private lastVelocity = 0;
|
||||
|
||||
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
|
||||
private readonly DPI = 2;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.$canvas = canvas;
|
||||
this.$offscreenCanvas = document.createElement('canvas');
|
||||
|
||||
const { width, height } = this.$canvas.getBoundingClientRect();
|
||||
|
||||
this.currentCanvasWidth = width * this.DPI;
|
||||
this.currentCanvasHeight = height * this.DPI;
|
||||
|
||||
this.$canvas.width = this.currentCanvasWidth;
|
||||
this.$canvas.height = this.currentCanvasHeight;
|
||||
|
||||
Object.assign(this.$canvas.style, {
|
||||
touchAction: 'none',
|
||||
msTouchAction: 'none',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
window.addEventListener('resize', this.onResize.bind(this));
|
||||
|
||||
this.$canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
||||
this.$canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
||||
this.$canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
||||
this.$canvas.addEventListener('mouseenter', this.onMouseEnter.bind(this));
|
||||
this.$canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this));
|
||||
this.$canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
|
||||
this.$canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
|
||||
this.$canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the minimum stroke width as a percentage of the current canvas suitable for a signature.
|
||||
*/
|
||||
private minStrokeWidth(): number {
|
||||
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum stroke width as a percentage of the current canvas suitable for a signature.
|
||||
*/
|
||||
private maxStrokeWidth(): number {
|
||||
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.035;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the HTML canvas element.
|
||||
*/
|
||||
public getCanvas(): HTMLCanvasElement {
|
||||
return this.$canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the 2D rendering context of the canvas.
|
||||
* Throws an error if the context is not available.
|
||||
*/
|
||||
public getContext(): CanvasRenderingContext2D {
|
||||
const ctx = this.$canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas context is not available.');
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the resize event of the canvas.
|
||||
* Adjusts the canvas size and preserves the content using image data.
|
||||
*/
|
||||
private onResize(): void {
|
||||
const { width, height } = this.$canvas.getBoundingClientRect();
|
||||
|
||||
const oldWidth = this.currentCanvasWidth;
|
||||
const oldHeight = this.currentCanvasHeight;
|
||||
|
||||
const ctx = this.getContext();
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, oldWidth, oldHeight);
|
||||
|
||||
this.$canvas.width = width * this.DPI;
|
||||
this.$canvas.height = height * this.DPI;
|
||||
|
||||
this.currentCanvasWidth = width * this.DPI;
|
||||
this.currentCanvasHeight = height * this.DPI;
|
||||
|
||||
ctx.putImageData(imageData, 0, 0, 0, 0, width * this.DPI, height * this.DPI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mouse down event on the canvas.
|
||||
* Adds the starting point for the signature.
|
||||
*/
|
||||
private onMouseDown(event: MouseEvent | PointerEvent | TouchEvent): void {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.isPressed = true;
|
||||
|
||||
const point = Point.fromEvent(event, this.DPI);
|
||||
|
||||
this.addPoint(point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mouse move event on the canvas.
|
||||
* Adds a point to the signature if the mouse is pressed, based on the sample rate.
|
||||
*/
|
||||
private onMouseMove(event: MouseEvent | PointerEvent | TouchEvent): void {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, this.DPI);
|
||||
|
||||
if (point.distanceTo(this.points[this.points.length - 1]) > 10) {
|
||||
this.addPoint(point);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the mouse up event on the canvas.
|
||||
* Adds the final point for the signature and resets the points array.
|
||||
*/
|
||||
private onMouseUp(event: MouseEvent | PointerEvent | TouchEvent, addPoint = true): void {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.isPressed = false;
|
||||
|
||||
const point = Point.fromEvent(event, this.DPI);
|
||||
|
||||
if (addPoint) {
|
||||
this.addPoint(point);
|
||||
}
|
||||
|
||||
this.onChangeHandlers.forEach((handler) => handler(this, false));
|
||||
|
||||
this.points = [];
|
||||
}
|
||||
|
||||
private onMouseEnter(event: MouseEvent): void {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
event.buttons === 1 && this.onMouseDown(event);
|
||||
}
|
||||
|
||||
private onMouseLeave(event: MouseEvent): void {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.onMouseUp(event, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a point to the signature and performs smoothing and drawing.
|
||||
*/
|
||||
private addPoint(point: Point): void {
|
||||
const lastPoint = this.points[this.points.length - 1] ?? point;
|
||||
|
||||
this.points.push(point);
|
||||
|
||||
const smoothedPoints = this.smoothSignature(this.points);
|
||||
|
||||
let velocity = point.velocityFrom(lastPoint);
|
||||
velocity =
|
||||
this.VELOCITY_FILTER_WEIGHT * velocity +
|
||||
(1 - this.VELOCITY_FILTER_WEIGHT) * this.lastVelocity;
|
||||
|
||||
const newWidth =
|
||||
velocity > 0 && this.lastVelocity > 0 ? this.strokeWidth(velocity) : this.minStrokeWidth();
|
||||
|
||||
this.drawSmoothSignature(smoothedPoints, newWidth);
|
||||
|
||||
this.lastVelocity = velocity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a smoothing algorithm to the signature points.
|
||||
*/
|
||||
private smoothSignature(points: Point[]): Point[] {
|
||||
const smoothedPoints: Point[] = [];
|
||||
|
||||
const startPoint = points[0];
|
||||
const endPoint = points[points.length - 1];
|
||||
|
||||
smoothedPoints.push(startPoint);
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = i > 0 ? points[i - 1] : startPoint;
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = i < points.length - 2 ? points[i + 2] : endPoint;
|
||||
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
|
||||
smoothedPoints.push(new Point(cp1x, cp1y));
|
||||
smoothedPoints.push(new Point(cp2x, cp2y));
|
||||
smoothedPoints.push(p2);
|
||||
}
|
||||
|
||||
return smoothedPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the smoothed signature on the canvas.
|
||||
*/
|
||||
private drawSmoothSignature(points: Point[], width: number): void {
|
||||
const ctx = this.getContext();
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
const startPoint = points[0];
|
||||
|
||||
ctx.moveTo(startPoint.x, startPoint.y);
|
||||
|
||||
ctx.lineWidth = width;
|
||||
|
||||
for (let i = 1; i < points.length; i += 3) {
|
||||
const cp1 = points[i];
|
||||
const cp2 = points[i + 1];
|
||||
const endPoint = points[i + 2];
|
||||
|
||||
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, endPoint.x, endPoint.y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the stroke width based on the velocity.
|
||||
*/
|
||||
private strokeWidth(velocity: number): number {
|
||||
return Math.max(this.maxStrokeWidth() / (velocity + 1), this.minStrokeWidth());
|
||||
}
|
||||
|
||||
public registerOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
|
||||
this.onChangeHandlers.push(handler);
|
||||
}
|
||||
|
||||
public unregisterOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
|
||||
this.onChangeHandlers = this.onChangeHandlers.filter((l) => l !== handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the signature as a data URL.
|
||||
*/
|
||||
public toDataURL(type?: string, quality?: number): string {
|
||||
return this.$canvas.toDataURL(type, quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the signature from the canvas.
|
||||
*/
|
||||
public clear(): void {
|
||||
const ctx = this.getContext();
|
||||
|
||||
ctx.clearRect(0, 0, this.currentCanvasWidth, this.currentCanvasHeight);
|
||||
|
||||
this.onChangeHandlers.forEach((handler) => handler(this, true));
|
||||
|
||||
this.points = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the signature as an image blob.
|
||||
*/
|
||||
public toBlob(type?: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.$canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Could not convert canvas to blob.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
},
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export const average = (a: number, b: number) => (a + b) / 2;
|
||||
|
||||
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(
|
||||
2,
|
||||
)} ${average(b[0], c[0]).toFixed(2)},${average(b[1], c[1]).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += 'Z';
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './signature-pad';
|
||||
@@ -1,98 +0,0 @@
|
||||
import {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
|
||||
export type PointLike = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const isTouchEvent = (
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
): event is TouchEvent | ReactTouchEvent => {
|
||||
return 'touches' in event;
|
||||
};
|
||||
|
||||
export class Point implements PointLike {
|
||||
public x: number;
|
||||
public y: number;
|
||||
public timestamp: number;
|
||||
|
||||
constructor(x: number, y: number, timestamp?: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.timestamp = timestamp ?? Date.now();
|
||||
}
|
||||
|
||||
public distanceTo(point: PointLike): number {
|
||||
return Math.sqrt(Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2));
|
||||
}
|
||||
|
||||
public equals(point: PointLike): boolean {
|
||||
return this.x === point.x && this.y === point.y && this.timestamp === point.timestamp;
|
||||
}
|
||||
|
||||
public velocityFrom(start: PointLike): number {
|
||||
const timeDifference = this.timestamp - start.timestamp;
|
||||
|
||||
if (timeDifference !== 0) {
|
||||
return this.distanceTo(start) / timeDifference;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static fromPointLike({ x, y, timestamp }: PointLike): Point {
|
||||
return new Point(x, y, timestamp);
|
||||
}
|
||||
|
||||
public static fromEvent(
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
dpi = 1,
|
||||
el?: HTMLElement | null,
|
||||
): Point {
|
||||
const target = el ?? event.target;
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error('Event target is not an HTMLElement.');
|
||||
}
|
||||
|
||||
const { top, bottom, left, right } = target.getBoundingClientRect();
|
||||
|
||||
let clientX, clientY;
|
||||
|
||||
if (isTouchEvent(event)) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
}
|
||||
|
||||
// create a new point snapping to the edge of the current target element if it exceeds
|
||||
// the bounding box of the target element
|
||||
let x = Math.min(Math.max(left, clientX), right) - left;
|
||||
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
||||
|
||||
// adjust for DPI
|
||||
x *= dpi;
|
||||
y *= dpi;
|
||||
|
||||
return new Point(x, y);
|
||||
}
|
||||
}
|
||||
79
apps/web/src/helpers/get-feature-flag.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag';
|
||||
|
||||
/**
|
||||
* 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_SITE_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_SITE_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>;
|
||||
}
|
||||
16
apps/web/src/helpers/get-post-hog-server-client.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
26
apps/web/src/helpers/get-server-component-feature-flag.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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,10 +1,9 @@
|
||||
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';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
export default function middleware(req: NextRequest) {
|
||||
if (req.nextUrl.pathname === '/') {
|
||||
const redirectUrl = new URL('/dashboard', req.url);
|
||||
const redirectUrl = new URL('/documents', req.url);
|
||||
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
44
apps/web/src/pages/api/feature-flag/all.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
122
apps/web/src/pages/api/feature-flag/get.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: ({ req, res }) => createTrpcContext({ req, res }),
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
});
|
||||
|
||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
105
apps/web/src/providers/feature-flag.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
FEATURE_FLAG_POLL_INTERVAL,
|
||||
LOCAL_FEATURE_FLAGS,
|
||||
isFeatureFlagEnabled,
|
||||
} from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
import { getAllFlags } from '~/helpers/get-feature-flag';
|
||||
|
||||
export const ZFeatureFlagValueSchema = z.union([
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/providers/posthog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { getSession } from 'next-auth/react';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
|
||||
export function PostHogPageview() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
if (typeof window !== 'undefined' && postHogConfig) {
|
||||
posthog.init(postHogConfig.key, {
|
||||
api_host: postHogConfig.host,
|
||||
disable_session_recording: true,
|
||||
loaded: () => {
|
||||
getSession()
|
||||
.then((session) => {
|
||||
if (session) {
|
||||
posthog.identify(session.user.email ?? session.user.id.toString());
|
||||
} else {
|
||||
posthog.reset();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing.
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!postHogConfig || !pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = window.origin + pathname;
|
||||
if (searchParams && searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`;
|
||||
}
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: url,
|
||||
});
|
||||
}, [pathname, searchParams, postHogConfig]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,16 +9,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"contentlayer/generated": [
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"types": [
|
||||
"@documenso/lib/types/next-auth.d.ts"
|
||||
],
|
||||
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||
"strictNullChecks": true,
|
||||
"incremental": false
|
||||
},
|
||||
@@ -30,7 +24,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
3
commitlint.config.cjs
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
1996
package-lock.json
generated
@@ -6,9 +6,16 @@
|
||||
"start": "cd apps && cd web && next start",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
"prepare": "husky install"
|
||||
"prepare": "husky install",
|
||||
"commitlint": "commitlint --edit"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.6.0",
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.7.1",
|
||||
"@commitlint/config-conventional": "^17.7.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"eslint": "^7.32.0",
|
||||
|
||||
@@ -110,9 +110,10 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
res.json().then((data) => {
|
||||
return callback(new Error(`MailChannels error: ${data.message}`), null);
|
||||
});
|
||||
res
|
||||
.json()
|
||||
.then((data) => callback(new Error(`MailChannels error: ${data.message}`), null))
|
||||
.catch((err) => callback(err, null));
|
||||
})
|
||||
.catch((err) => {
|
||||
return callback(err, null);
|
||||
|
||||
@@ -19,6 +19,8 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['../../apps/*/tsconfig.json', '../../packages/*/tsconfig.json'],
|
||||
ecmaVersion: 2022,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
@@ -32,6 +34,18 @@ module.exports = {
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// Safety with promises so we aren't running with scissors
|
||||
'no-promise-executor-return': 'error',
|
||||
'prefer-promise-reject-errors': 'error',
|
||||
'require-atomic-updates': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
'@typescript-eslint/promise-function-async': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
|
||||
// We never want to use `as` but are required to on occasion to handle
|
||||
// shortcomings in third-party and generated types.
|
||||
//
|
||||
|
||||
9
packages/eslint-config/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@documenso/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
},
|
||||
"include": ["**/*.cjs", "**/*.js"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||