mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 21:12:48 +10:00
Compare commits
65 Commits
feat/pie-c
...
feat/refac
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab796910e | |||
| 07102588be | |||
| 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 | |||
| 8ed2393300 | |||
| 94cf150ffd | |||
| c571a3d0d9 | |||
| 1c7431b859 | |||
| 2f7d6548ef | |||
| a16525be5e | |||
| 21e377d3ff | |||
| 45d0d3f7e8 | |||
| 6e62eb8d81 | |||
| e098449af4 | |||
| 47d0030cf0 | |||
| e46607c1cb | |||
| 77c5db169a | |||
| 0157bf9576 | |||
| 454c2f45bd | |||
| badb897c06 | |||
| 9bb5768598 | |||
| 951de8baf5 | |||
| 48ceb1e5c7 | |||
| 0a30403719 | |||
| 0e1fcd86e6 | |||
| 44369ee7f6 | |||
| 7e46cb0d8e | |||
| 3b9c57fe5c | |||
| 90e28cd3a4 | |||
| e743e56787 |
@ -52,7 +52,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]]
|
||||
|
||||
39
.github/dependabot.yml
vendored
Normal file
39
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
version: 2
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "feat/refresh" ]
|
||||
pull_request:
|
||||
branches: [ "feat/refresh" ]
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: "Continuous Integration"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "feat/refresh" ]
|
||||
pull_request:
|
||||
branches: [ "feat/refresh" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
45
.github/workflows/codeql-analysis.yml
vendored
Normal file
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
|
||||
10
.gitignore
vendored
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
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
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
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.
|
||||
36
apps/marketing/content/blog/pre-seed.mdx
Normal file
36
apps/marketing/content/blog/pre-seed.mdx
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Announcing Pre-Seed and Open Metrics
|
||||
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2023-08-17
|
||||
tags:
|
||||
- Funding
|
||||
- Metrics
|
||||
- Open Startup
|
||||
---
|
||||
|
||||
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
|
||||
|
||||
## Two more for the road (to open signing)
|
||||
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
|
||||
|
||||
## Open Source, Open Metrics
|
||||
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
|
||||
|
||||
The two main takeaways are:
|
||||
|
||||
- "Any Startup that shares its metrics as open as technically and operationally possible is an Open Startup."
|
||||
- "Why should I care? Frankly speaking, Open Startups have a tough time screwing you over."
|
||||
|
||||
The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open.
|
||||
|
||||
For us, there are two sides to being an Open Startup:
|
||||
|
||||
- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools.
|
||||
- The product side: Sharing insights and data like usage, reach, and GitHub activity.
|
||||
|
||||
Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS.
|
||||
|
||||
Starting today, we're releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](https://documen.so/open)
|
||||
@ -7,7 +7,8 @@
|
||||
"dev": "PORT=3001 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
|
||||
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
BIN
apps/marketing/public/blog/blog-fig-preview-documenso.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/marketing/public/logo_icon.png
Normal file
BIN
apps/marketing/public/logo_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -70,9 +70,12 @@ export default async function OpenPage() {
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
||||
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
||||
All our metrics, finances, and learnings are public. We believe in transparency and want
|
||||
to share our journey with you.
|
||||
to share our journey with you. You can read more about why here:{' '}
|
||||
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
|
||||
Announcing Open Metrics
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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,20 +36,16 @@ 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>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Blog
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
|
||||
@ -57,6 +53,22 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Blog
|
||||
</Link>
|
||||
|
||||
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
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,26 +1,37 @@
|
||||
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>
|
||||
|
||||
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Blog
|
||||
</Link>
|
||||
|
||||
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Pricing
|
||||
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
|
||||
Open
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@ -31,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
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>
|
||||
);
|
||||
};
|
||||
@ -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({
|
||||
|
||||
@ -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
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"]
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"dev": "PORT=3000 next dev",
|
||||
"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",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.1.6",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
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
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,28 +2,23 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
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';
|
||||
|
||||
const PDFViewer = 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>
|
||||
),
|
||||
});
|
||||
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;
|
||||
@ -40,6 +35,9 @@ export const EditDocumentForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
@ -64,6 +62,76 @@ export const EditDocumentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
} 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();
|
||||
|
||||
onNextStep();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while sending the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -71,7 +139,7 @@ export const EditDocumentForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer document={documentUrl} />
|
||||
<LazyPDFViewer document={documentUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -83,6 +151,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -93,6 +162,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -103,6 +173,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
export type AuthenticatedDashboardLayoutProps = {
|
||||
@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
fileName?: string;
|
||||
document?: string;
|
||||
};
|
||||
|
||||
export const DownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
document,
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
/**
|
||||
* Convert the document from base64 to a blob and download it.
|
||||
*/
|
||||
const onDownloadClick = () => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
let decodedDocument = document;
|
||||
|
||||
try {
|
||||
decodedDocument = atob(document);
|
||||
} catch (err) {
|
||||
// We're just going to ignore this error and try to download the document
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={disabled || !document}
|
||||
onClick={onDownloadClick}
|
||||
{...props}
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
107
apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
Normal file
107
apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8, Share } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DownloadButton } from './download-button';
|
||||
import { SigningCard } from './signing-card';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function CompletedSigningPage({
|
||||
params: { token },
|
||||
}: CompletedSigningPageProps) {
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }),
|
||||
]);
|
||||
|
||||
const recipientName =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
recipient.email;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard name={recipientName} />
|
||||
|
||||
<div className="mt-6">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have signed "{document.title}"
|
||||
</h2>
|
||||
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
{/* TODO: Hook this up */}
|
||||
<Button variant="outline" className="flex-1">
|
||||
<Share className="mr-2 h-5 w-5" />
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<DownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want so send slick signing links like this one?{' '}
|
||||
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import signingCelebration from '~/assets/signing-celebration.png';
|
||||
|
||||
export type SigningCardProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const SigningCard = ({ name }: SigningCardProps) => {
|
||||
return (
|
||||
<div className="relative w-full max-w-xs md:max-w-sm">
|
||||
<Card
|
||||
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
|
||||
degrees={-145}
|
||||
gradient
|
||||
>
|
||||
<CardContent
|
||||
className="font-signature p-6 text-center"
|
||||
style={{
|
||||
container: 'main',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<motion.div
|
||||
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{
|
||||
delay: 0.5,
|
||||
duration: 0.5,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={signingCelebration}
|
||||
alt="background pattern"
|
||||
className="w-full"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
apps/web/src/app/(signing)/sign/[token]/date-field.tsx
Normal file
94
apps/web/src/app/(signing)/sign/[token]/date-field.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
};
|
||||
|
||||
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const onSign = async () => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: '',
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
126
apps/web/src/app/(signing)/sign/[token]/form.tsx
Normal file
126
apps/web/src/app/(signing)/sign/[token]/form.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
export type SigningFormProps = {
|
||||
document: Document;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const isComplete = fields.every((f) => f.inserted);
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
if (!isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token: recipient.token,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
router.push(`/sign/${recipient.token}/complete`);
|
||||
};
|
||||
|
||||
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',
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
console.log({
|
||||
signpadValue: value,
|
||||
});
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
size="lg"
|
||||
disabled={!isComplete || isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
24
apps/web/src/app/(signing)/sign/[token]/layout.tsx
Normal file
24
apps/web/src/app/(signing)/sign/[token]/layout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
export type SigningLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||
const user = await getServerComponentSession();
|
||||
|
||||
return (
|
||||
<NextAuthProvider>
|
||||
<div className="min-h-screen overflow-hidden">
|
||||
{user && <AuthenticatedHeader user={user} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/app/(signing)/sign/[token]/name-field.tsx
Normal file
163
apps/web/src/app/(signing)/sign/[token]/name-field.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type NameFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
};
|
||||
|
||||
export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
||||
const [localFullName, setLocalFullName] = useState('');
|
||||
|
||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||
try {
|
||||
if (!providedFullName && !localFullName) {
|
||||
setShowFullNameModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
|
||||
isBase64: false,
|
||||
});
|
||||
|
||||
if (source === 'local' && !providedFullName) {
|
||||
setProvidedFullName(localFullName);
|
||||
}
|
||||
|
||||
setLocalFullName('');
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
|
||||
)}
|
||||
|
||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||
|
||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Sign as {recipient.name}{' '}
|
||||
<span className="text-muted-foreground">({recipient.email})</span>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="py-4">
|
||||
<Label htmlFor="signature">Full Name</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
className="mt-2"
|
||||
value={localFullName}
|
||||
onChange={(e) => setLocalFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowFullNameModal(false);
|
||||
setLocalFullName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!localFullName}
|
||||
onClick={() => {
|
||||
setShowFullNameModal(false);
|
||||
onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
93
apps/web/src/app/(signing)/sign/[token]/page.tsx
Normal file
93
apps/web/src/app/(signing)/sign/[token]/page.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
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';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
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 '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { SigningForm } from './form';
|
||||
import { NameField } from './name-field';
|
||||
import { SigningProvider } from './provider';
|
||||
import { SignatureField } from './signature-field';
|
||||
|
||||
export type SigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function SigningPage({ params: { token } }: SigningPageProps) {
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [document, fields, recipient] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }),
|
||||
viewedDocument({ token }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
return (
|
||||
<SigningProvider email={recipient.email} fullName={recipient.name}>
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<p className="text-muted-foreground">
|
||||
{document.User.name} ({document.User.email}) has invited you to sign this document.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={documentUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm document={document} recipient={recipient} fields={fields} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
</SigningProvider>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/app/(signing)/sign/[token]/provider.tsx
Normal file
63
apps/web/src/app/(signing)/sign/[token]/provider.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
export type SigningContextValue = {
|
||||
fullName: string;
|
||||
setFullName: (_value: string) => void;
|
||||
email: string;
|
||||
setEmail: (_value: string) => void;
|
||||
signature: string | null;
|
||||
setSignature: (_value: string | null) => void;
|
||||
};
|
||||
|
||||
const SigningContext = createContext<SigningContextValue | null>(null);
|
||||
|
||||
export const useSigningContext = () => {
|
||||
return useContext(SigningContext);
|
||||
};
|
||||
|
||||
export const useRequiredSigningContext = () => {
|
||||
const context = useSigningContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Signing context is required');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface SigningProviderProps {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
signature?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SigningProvider = ({
|
||||
fullName: initialFullName,
|
||||
email: initialEmail,
|
||||
signature: initialSignature,
|
||||
children,
|
||||
}: SigningProviderProps) => {
|
||||
const [fullName, setFullName] = useState(initialFullName || '');
|
||||
const [email, setEmail] = useState(initialEmail || '');
|
||||
const [signature, setSignature] = useState(initialSignature || null);
|
||||
|
||||
return (
|
||||
<SigningContext.Provider
|
||||
value={{
|
||||
fullName,
|
||||
setFullName,
|
||||
email,
|
||||
setEmail,
|
||||
signature,
|
||||
setSignature,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SigningContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
SigningProvider.displayName = 'SigningProvider';
|
||||
196
apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
Normal file
196
apps/web/src/app/(signing)/sign/[token]/signature-field.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
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 { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
};
|
||||
|
||||
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
|
||||
const { Signature: signature } = field;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||
|
||||
const state = useMemo<SignatureFieldState>(() => {
|
||||
if (!field.inserted) {
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
if (signature?.signatureImageAsBase64) {
|
||||
return 'signed-image';
|
||||
}
|
||||
|
||||
return 'signed-text';
|
||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||
|
||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
||||
try {
|
||||
console.log({
|
||||
providedSignature,
|
||||
localSignature,
|
||||
});
|
||||
|
||||
if (!providedSignature && !localSignature) {
|
||||
setShowSignatureModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
|
||||
isBase64: true,
|
||||
});
|
||||
|
||||
if (source === 'local' && !providedSignature) {
|
||||
setProvidedSignature(localSignature);
|
||||
}
|
||||
|
||||
setLocalSignature(null);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
Signature
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === 'signed-image' && signature?.signatureImageAsBase64 && (
|
||||
<img
|
||||
src={signature.signatureImageAsBase64}
|
||||
alt={`Signature for ${recipient.name}`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'signed-text' && (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Sign as {recipient.name}{' '}
|
||||
<span className="text-muted-foreground">({recipient.email})</span>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="">
|
||||
<Label htmlFor="signature">Signature</Label>
|
||||
|
||||
<SignaturePad
|
||||
id="signature"
|
||||
className="border-border mt-2 h-44 w-full rounded-md border"
|
||||
onChange={(value) => setLocalSignature(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
setLocalSignature(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!localSignature}
|
||||
onClick={() => {
|
||||
setShowSignatureModal(false);
|
||||
onSign('local');
|
||||
}}
|
||||
>
|
||||
Sign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
onSign?: () => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const SigningFieldContainer = ({
|
||||
field,
|
||||
loading,
|
||||
onSign,
|
||||
onRemove,
|
||||
children,
|
||||
}: SignatureFieldProps) => {
|
||||
const coords = useFieldPageCoords(field);
|
||||
|
||||
const onSignFieldClick = async () => {
|
||||
if (field.inserted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSign?.();
|
||||
};
|
||||
|
||||
const onRemoveSignedFieldClick = async () => {
|
||||
if (!field.inserted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onRemove?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
height: `${coords.height}px`,
|
||||
width: `${coords.width}px`,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="bg-background relative h-full w-full"
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
|
||||
)}
|
||||
>
|
||||
{!field.inserted && !loading && (
|
||||
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
|
||||
)}
|
||||
|
||||
{field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,15 +1,22 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
|
||||
import { FeatureFlagProvider } from '~/providers/feature-flag';
|
||||
import { ThemeProvider } from '~/providers/next-theme';
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
@ -35,9 +42,15 @@ 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" className={fontInter.variable} suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
className={cn(fontInter.variable, fontCaveat.variable)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
@ -45,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>
|
||||
);
|
||||
|
||||
BIN
apps/web/src/assets/signing-celebration.png
Normal file
BIN
apps/web/src/assets/signing-celebration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 MiB |
@ -12,7 +12,7 @@ export type StackAvatarProps = {
|
||||
first?: boolean;
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: 'unsigned' | 'waiting' | 'completed';
|
||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
||||
@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
||||
case 'unsigned':
|
||||
classes = 'bg-dawn-200 text-dawn-900';
|
||||
break;
|
||||
case 'opened':
|
||||
classes = 'bg-yellow-200 text-yellow-700';
|
||||
break;
|
||||
case 'waiting':
|
||||
classes = 'bg-water text-water-700';
|
||||
break;
|
||||
|
||||
@ -13,15 +13,19 @@ import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED',
|
||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||
);
|
||||
|
||||
const openedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === 'opened',
|
||||
);
|
||||
|
||||
const completedRecipients = recipients.filter(
|
||||
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED',
|
||||
(recipient) => getRecipientType(recipient) === 'completed',
|
||||
);
|
||||
|
||||
const uncompletedRecipients = recipients.filter(
|
||||
(recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED',
|
||||
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||
);
|
||||
|
||||
return (
|
||||
@ -66,6 +70,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={initials(recipient.name)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export const RefreshOnFocus = () => {
|
||||
const { refresh } = useRouter();
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [onFocus]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -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"
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
import React, { createContext, useRef } from 'react';
|
||||
|
||||
import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
|
||||
type EditFormContextValue = {
|
||||
firePageClickEvent: OnPDFViewerPageClick;
|
||||
registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
|
||||
unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
|
||||
} | null;
|
||||
|
||||
const EditFormContext = createContext<EditFormContextValue>(null);
|
||||
|
||||
export type EditFormProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const useEditForm = () => {
|
||||
const context = React.useContext(EditFormContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useEditForm must be used within a EditFormProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const EditFormProvider = ({ children }: EditFormProviderProps) => {
|
||||
const handlers = useRef(new Set<OnPDFViewerPageClick>());
|
||||
|
||||
const firePageClickEvent: OnPDFViewerPageClick = (event) => {
|
||||
handlers.current.forEach((handler) => handler(event));
|
||||
};
|
||||
|
||||
const registerPageClickHandler = (handler: OnPDFViewerPageClick) => {
|
||||
handlers.current.add(handler);
|
||||
};
|
||||
|
||||
const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => {
|
||||
handlers.current.delete(handler);
|
||||
};
|
||||
|
||||
return (
|
||||
<EditFormContext.Provider
|
||||
value={{
|
||||
firePageClickEvent,
|
||||
registerPageClickHandler,
|
||||
unregisterPageClickHandler,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EditFormContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
@ -12,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),
|
||||
@ -30,6 +32,8 @@ export type ProfileFormProps = {
|
||||
};
|
||||
|
||||
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
@ -59,6 +63,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
description: 'Your profile has been updated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
|
||||
@ -44,7 +44,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
callbackUrl: '/dashboard',
|
||||
callbackUrl: '/documents',
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
@ -12,10 +12,9 @@ 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),
|
||||
|
||||
7
apps/web/src/components/motion.tsx
Normal file
7
apps/web/src/components/motion.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export * from 'framer-motion';
|
||||
|
||||
export const MotionDiv = motion.div;
|
||||
@ -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
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((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((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
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: (...args) => fetch(...args),
|
||||
});
|
||||
}
|
||||
26
apps/web/src/helpers/get-server-component-feature-flag.ts
Normal file
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()),
|
||||
});
|
||||
};
|
||||
78
apps/web/src/hooks/use-field-page-coords.ts
Normal file
78
apps/web/src/hooks/use-field-page-coords.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const fieldX = (Number(field.positionX) / 100) * width + left;
|
||||
const fieldY = (Number(field.positionY) / 100) * height + top;
|
||||
|
||||
const fieldHeight = (Number(field.height) / 100) * height;
|
||||
const fieldWidth = (Number(field.width) / 100) * width;
|
||||
|
||||
setCoords({
|
||||
x: fieldX,
|
||||
y: fieldY,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
});
|
||||
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
calculateCoords();
|
||||
});
|
||||
|
||||
observer.observe($page);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [calculateCoords, field.page]);
|
||||
|
||||
return coords;
|
||||
};
|
||||
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export default async 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);
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export default async function handler(
|
||||
// We had intended to do this with Zod but we can only validate it
|
||||
// as a persistent file which does not include the properties that we
|
||||
// need.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
resolve({ ...fields, ...files } as any);
|
||||
});
|
||||
},
|
||||
|
||||
44
apps/web/src/pages/api/feature-flag/all.ts
Normal file
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
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;
|
||||
};
|
||||
105
apps/web/src/providers/feature-flag.tsx
Normal file
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()) {
|
||||
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 = () => 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
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
3
commitlint.config.cjs
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
1596
package-lock.json
generated
1596
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -29,7 +29,7 @@ module.exports = {
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 'off',
|
||||
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// We never want to use `as` but are required to on occasion to handle
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED') {
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.readStatus === ReadStatus.OPENED &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
||||
) {
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
37
packages/lib/constants/feature-flags.ts
Normal file
37
packages/lib/constants/feature-flags.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* How frequent to poll for new feature flags in milliseconds.
|
||||
*/
|
||||
export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Feature flags that will be used when PostHog is disabled.
|
||||
*
|
||||
* Does not take any person or group properties into account.
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Extract the PostHog configuration from the environment.
|
||||
*/
|
||||
export function extractPostHogConfig(): { key: string; host: string } | null {
|
||||
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const postHogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
|
||||
if (!postHogKey || !postHogHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: postHogKey,
|
||||
host: postHogHost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether feature flags are enabled for the current instance.
|
||||
*/
|
||||
export function isFeatureFlagEnabled(): boolean {
|
||||
return extractPostHogConfig() !== null;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
/* eslint-disable turbo/no-undeclared-env-vars */
|
||||
export const IS_SUBSCRIPTIONS_ENABLED = process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';
|
||||
|
||||
export const isSubscriptionsEnabled = () =>
|
||||
process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';
|
||||
@ -1,6 +1,9 @@
|
||||
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -27,6 +30,18 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
||||
return user;
|
||||
};
|
||||
|
||||
export const getServerComponentToken = async () => {
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const req = new NextRequest('http://example.com', {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const token = await getToken({
|
||||
req,
|
||||
});
|
||||
};
|
||||
|
||||
export const getServerComponentSession = async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
|
||||
@ -10,23 +10,25 @@
|
||||
"universal/",
|
||||
"next-auth/"
|
||||
],
|
||||
"scripts": {
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.12",
|
||||
"next-auth": "4.22.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"stripe": "^12.7.0"
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0"
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { sealDocument } from './seal-document';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
|
||||
}
|
||||
|
||||
const [recipient] = document.Recipient;
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (fields.some((field) => !field.inserted)) {
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const documents = await prisma.document.updateMany({
|
||||
where: {
|
||||
id: document.id,
|
||||
Recipient: {
|
||||
every: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('documents', documents);
|
||||
|
||||
if (documents.count > 0) {
|
||||
console.log('sealing document');
|
||||
sealDocument({ documentId: document.id });
|
||||
}
|
||||
};
|
||||
30
packages/lib/server-only/document/get-document-by-token.ts
Normal file
30
packages/lib/server-only/document/get-document-by-token.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
}: GetDocumentAndSenderByTokenOptions) => {
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const { password: _password, ...User } = result.User;
|
||||
|
||||
return {
|
||||
...result,
|
||||
User,
|
||||
};
|
||||
};
|
||||
74
packages/lib/server-only/document/seal-document.ts
Normal file
74
packages/lib/server-only/document/seal-document.ts
Normal file
@ -0,0 +1,74 @@
|
||||
'use server';
|
||||
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (document.status !== DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has not been completed`);
|
||||
}
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
|
||||
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (fields.some((field) => !field.inserted)) {
|
||||
throw new Error(`Document ${document.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const { document: pdfData } = document;
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
for (const field of fields) {
|
||||
console.log('inserting field', {
|
||||
...field,
|
||||
Signature: null,
|
||||
});
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
await prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
data: {
|
||||
document: Buffer.from(pdfBytes).toString('base64'),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -48,12 +48,15 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
signDocumentLink: 'https://example.com',
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
});
|
||||
|
||||
mailer.sendMail({
|
||||
|
||||
29
packages/lib/server-only/document/viewed-document.ts
Normal file
29
packages/lib/server-only/document/viewed-document.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
readStatus: ReadStatus.NOT_OPENED,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
console.warn(`No recipient found for token ${token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
readStatus: ReadStatus.OPENED,
|
||||
},
|
||||
});
|
||||
};
|
||||
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal file
18
packages/lib/server-only/field/get-fields-for-token.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
Recipient: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Signature: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
token: string;
|
||||
fieldId: number;
|
||||
};
|
||||
|
||||
export const removeSignedFieldWithToken = async ({
|
||||
token,
|
||||
fieldId,
|
||||
}: RemovedSignedFieldWithTokenOptions) => {
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
Recipient: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Document: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
// Unreachable code based on the above query but we need to satisfy TypeScript
|
||||
if (field.recipientId === null) {
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
},
|
||||
}),
|
||||
prisma.signature.deleteMany({
|
||||
where: {
|
||||
fieldId: field.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal file
89
packages/lib/server-only/field/sign-field-with-token.ts
Normal file
@ -0,0 +1,89 @@
|
||||
'use server';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
token: string;
|
||||
fieldId: number;
|
||||
value: string;
|
||||
isBase64?: boolean;
|
||||
};
|
||||
|
||||
export const signFieldWithToken = async ({
|
||||
token,
|
||||
fieldId,
|
||||
value,
|
||||
isBase64,
|
||||
}: SignFieldWithTokenOptions) => {
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
Recipient: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Document: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { Document: document, Recipient: recipient } = field;
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
if (field.inserted) {
|
||||
throw new Error(`Field ${fieldId} has already been inserted`);
|
||||
}
|
||||
|
||||
// Unreachable code based on the above query but we need to satisfy TypeScript
|
||||
if (field.recipientId === null) {
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
const isSignatureField =
|
||||
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
||||
|
||||
let customText = !isSignatureField ? value : undefined;
|
||||
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
||||
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||
}
|
||||
|
||||
await prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
customText,
|
||||
inserted: true,
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
upsert: {
|
||||
create: {
|
||||
recipientId: field.recipientId,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
},
|
||||
update: {
|
||||
recipientId: field.recipientId,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal file
144
packages/lib/server-only/pdf/insert-field-in-pdf.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { readFileSync } from 'fs';
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
|
||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${field.page} does not exist`);
|
||||
}
|
||||
|
||||
const { width: pageWidth, height: pageHeight } = page.getSize();
|
||||
|
||||
const fieldWidth = pageWidth * (Number(field.width) / 100);
|
||||
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
||||
|
||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||
|
||||
console.log({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
fieldX,
|
||||
fieldY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
|
||||
|
||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||
await pdf.embedFont(fontCaveat);
|
||||
}
|
||||
|
||||
const isInsertingImage =
|
||||
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
|
||||
|
||||
if (isSignatureField && isInsertingImage) {
|
||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
|
||||
const initialDimensions = {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
};
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
|
||||
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
|
||||
console.log({
|
||||
initialDimensions,
|
||||
scalingFactor,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
});
|
||||
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
});
|
||||
} else {
|
||||
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const initialDimensions = {
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
};
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
|
||||
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
|
||||
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
|
||||
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
console.log({
|
||||
initialDimensions,
|
||||
scalingFactor,
|
||||
textWidth,
|
||||
textHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
page.drawText(field.customText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
});
|
||||
}
|
||||
|
||||
return pdf;
|
||||
};
|
||||
|
||||
export const insertFieldInPDFBytes = async (
|
||||
pdf: ArrayBuffer | Uint8Array | string,
|
||||
field: FieldWithSignature,
|
||||
) => {
|
||||
const pdfDoc = await PDFDocument.load(pdf);
|
||||
|
||||
await insertFieldInPDF(pdfDoc, field);
|
||||
|
||||
return await pdfDoc.save();
|
||||
};
|
||||
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal file
13
packages/lib/server-only/recipient/get-recipient-by-token.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetRecipientByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions) => {
|
||||
return await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
};
|
||||
9
packages/prisma/guards/is-signature-field.ts
Normal file
9
packages/prisma/guards/is-signature-field.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
const SignatureFieldTypes = [FieldType.SIGNATURE, FieldType.FREE_SIGNATURE] as const;
|
||||
|
||||
type SignatureFieldType = (typeof SignatureFieldTypes)[number];
|
||||
|
||||
export const isSignatureFieldType = (type: FieldType): type is SignatureFieldType => {
|
||||
return type === FieldType.SIGNATURE || type === FieldType.FREE_SIGNATURE;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user