Compare commits

..

1 Commits

Author SHA1 Message Date
David Nguyen
e7b18e820e chore: add code of conduct
Update contributing guidelines
2023-10-04 12:41:57 +11:00
156 changed files with 4417 additions and 9414 deletions

View File

@@ -9,5 +9,10 @@ npm install
# Copy the env file
cp .env.example .env
# Source the env file, export the variables
set -a
source .env
set +a
# Run the migrations
npm run prisma:migrate-dev
npm run -w @documenso/prisma prisma:migrate-dev

View File

@@ -15,11 +15,6 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
@@ -73,7 +68,6 @@ NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.

View File

@@ -1,50 +0,0 @@
---
name: Bug Report
about: Create a bug report to help us improve
---
<!--- Please provide a general summary of the issue in the Title above -->
## Issue Description
<!--- Please provide a clear and concise description of the problem. -->
## Steps to Reproduce
<!--- Please provide step-by-step instructions to reproduce the issue. -->
<!--- Include code snippets, error messages, and any other relevant information. -->
1. Step 1
2. Step 2
3. ...
## Expected Behavior
<!--- Describe what you expected to happen. -->
## Current Behavior
<!--- Describe what is currently happening. -->
## Screenshots (optional)
<!--- If applicable, add screenshots to help explain the issue. -->
## Environment
<!--- Please provide information about your environment, such as operating system, browser, version, etc. -->
- OS: [e.g., Windows 10]
- Browser: [e.g., Chrome, Firefox]
- Version: [e.g., 2.0.1]
## Checklist
<!--- Please check the boxes that apply to this issue report. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing issues to make sure this is not a duplicate.
- [ ] I have provided steps to reproduce the issue.
- [ ] I have included relevant environment information.
- [ ] I have included any relevant screenshots.
- [ ] I understand that this is a voluntary contribution and that there is no guarantee of resolution.

View File

@@ -1,41 +0,0 @@
---
name: Feature Request
about: Suggest a new idea or enhancement for this project
---
<!--- Please provide a clear and concise title for your feature request -->
## Feature Description
<!--- Describe the feature you are requesting in detail. -->
<!--- Explain what problem it solves or what value it adds to the project. -->
## Use Case
<!--- Provide a scenario or use case where this feature would be beneficial. -->
<!--- Explain how users would interact with this feature and why it's important. -->
## Proposed Solution
<!--- If you have an idea of how this feature could be implemented, describe it here. -->
<!--- Include any technical details, UI/UX considerations, or design suggestions. -->
## Alternatives (optional)
<!--- Are there any alternative ways to achieve the same goal? -->
<!--- Describe other approaches that could be considered if this feature is not implemented. -->
## Additional Context
<!--- Add any additional context or information that might be relevant to the feature request. -->
## Checklist
<!--- Please check the boxes that apply to this feature request. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing feature requests to make sure this is not a duplicate.
- [ ] I have provided a detailed description of the requested feature.
- [ ] I have explained the use case or scenario for this feature.
- [ ] I have included any relevant technical details or design suggestions.
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@@ -1,41 +0,0 @@
---
name: General Improvement
about: Suggest a minor enhancement or improvement for this project
---
<!--- Please provide a clear and concise title for your improvement suggestion -->
## Improvement Description
<!--- Describe the improvement you are suggesting in detail. -->
<!--- Explain what specific aspect of the project it addresses or enhances. -->
## Rationale
<!--- Explain why this improvement would be beneficial. -->
<!--- Share any context, pain points, or reasons for suggesting this change. -->
## Proposed Solution
<!--- If you have a suggestion for how this improvement could be implemented, describe it here. -->
<!--- Include any technical details, design suggestions, or other relevant information. -->
## Alternatives (optional)
<!--- Are there any alternative approaches to achieve the same improvement? -->
<!--- Describe other ways to address the issue or enhance the project. -->
## Additional Context
<!--- Add any additional context or information that might be relevant to the improvement suggestion. -->
## Checklist
<!--- Please check the boxes that apply to this improvement suggestion. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing issues and improvement suggestions to avoid duplication.
- [ ] I have provided a clear description of the improvement being suggested.
- [ ] I have explained the rationale behind this improvement.
- [ ] I have included any relevant technical details or design suggestions.
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@@ -1,49 +0,0 @@
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---
## Description
<!--- Describe the changes introduced by this pull request. -->
<!--- Explain what problem it solves or what feature/fix it adds. -->
## Related Issue
<!--- If this pull request is related to a specific issue, reference it here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->
## Changes Made
<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes. -->
- Change 1
- Change 2
- ...
## Testing Performed
<!--- Describe the testing that you have performed to validate these changes. -->
<!--- Include information about test cases, testing environments, and results. -->
- Tested feature X in scenario Y.
- Ran unit tests for component Z.
- Tested on browsers A, B, and C.
- ...
## Checklist
<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->
- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these changes.
- [ ] I have updated the documentation to reflect these changes, if applicable.
- [ ] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous submission, if applicable.
## Additional Notes
<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include details about design decisions, potential concerns, or anything else relevant. -->

View File

@@ -1,40 +0,0 @@
---
name: Test Addition
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
---
## Description
<!--- Provide a clear and concise description of the new test you are adding. -->
<!--- Explain the purpose of the test and what it aims to validate. -->
## Related Issue
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->
## Test Details
<!--- Describe the details of the test you're adding. -->
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
- Test Name: Name of the test
- Type: [Unit / E2E]
- Description: Brief description of what the test checks
- Inputs: What inputs the test uses (if applicable)
- Expected Output: What output or behavior the test expects
## Checklist
<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->
- [ ] I have written the new test and ensured it works as intended.
- [ ] I have added necessary documentation to explain the purpose of the test.
- [ ] I have followed the project's testing guidelines and coding style.
- [ ] I have addressed any review feedback from previous submissions, if applicable.
## Additional Notes
<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include explanations about the testing approach or any potential concerns. -->

View File

@@ -9,7 +9,7 @@ updates:
labels:
- "ci dependencies"
- "ci"
open-pull-requests-limit: 0
open-pull-requests-limit: 2
- package-ecosystem: "npm"
directory: "/apps/marketing"
@@ -19,7 +19,7 @@ updates:
labels:
- "npm dependencies"
- "frontend"
open-pull-requests-limit: 0
open-pull-requests-limit: 2
- package-ecosystem: "npm"
directory: "/apps/web"
@@ -29,4 +29,4 @@ updates:
labels:
- "npm dependencies"
- "frontend"
open-pull-requests-limit: 0
open-pull-requests-limit: 2

View File

@@ -1,51 +0,0 @@
name: Playwright Tests
on:
push:
branches: [feat/refresh]
pull_request:
branches: [feat/refresh]
jobs:
e2e_tests:
timeout-minutes: 60
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Copy env
run: cp .env.example .env
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Generate Prisma Client
run: npm run prisma:generate -w @documenso/prisma
- name: Create the database
run: npm run prisma:migrate-dev
- name: Run Playwright tests
run: npm run ci
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
env:
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}

1
.gitignore vendored
View File

@@ -31,7 +31,6 @@ yarn-error.log*
# turbo
.turbo
.turbo-cookie
# vercel
.vercel

View File

@@ -1,55 +0,0 @@
tasks:
- init: |
npm i &&
npm run dx:up &&
cp .env.example .env &&
set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:
- port: 3000
visibility: public
onOpen: open-preview
- port: 3001
visibility: public
onOpen: open-preview
- port: 9000
visibility: public
onOpen: ignore
- port: 1100
visibility: private
onOpen: ignore
- port: 2500
visibility: private
onOpen: ignore
- port: 54320
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- aaron-bond.better-comments
- bradlc.vscode-tailwindcss
- dbaeumer.vscode-eslint
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github
- Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@@ -5,8 +5,8 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
## Before getting started
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
- Consider the results from the discussion on the issue
- Select and issue from [here](https://github.com/documenso/documenso/issues) or create a new one
- Consider the results from the discussion in the issue
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
## Taking issues
@@ -27,14 +27,14 @@ Always feel free to ask questions or seek clarification on the issue.
## Developing
The development branch is <code>main</code>. All pull requests should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
The development branch is <code>main</code>. All pull request should be made against this branch. If you need help getting started, [join us on Discord](https://documen.so/discord).
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
own GitHub account and then
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch:
- Create a new branch (include the issue id and something readable):
- Create a new branch (include the issue id and somthing readable):
```sh
git checkout -b doc-999-my-feature-or-fix
@@ -45,7 +45,7 @@ The development branch is <code>main</code>. All pull requests should be made ag
## Building
> **Note**
> Please ensure you can make a full production build before pushing code or creating PRs.
> Please be sure that you can make a full production build before pushing code or creating PRs.
You can build the project with:

View File

@@ -179,7 +179,7 @@ git clone https://github.com/documenso/documenso
- NEXT_PRIVATE_SMTP_FROM_NAME
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
5. Create the database schema by running `npm run prisma:migrate-dev`
5. Create the database schema by running `npm run prisma:migrate-dev -w @documenso/prisma`
6. Run `npm run dev` root directory to start
@@ -254,22 +254,6 @@ containers:
- '::'
```
### I can't see environment variables in my package scripts
Wrap your package script with the `with:env` script like such:
```
npm run with:env -- npm run myscript
```
The same can be done when using `npx` for one of bin scripts:
```
npm run with:env -- npx myscript
```
This will load environment variables from your `.env` and `.env.local` files.
## Repo Activity
![Repository Activity](https://repobeats.axiom.co/api/embed/622a2e9aa709696f7226304b5b7178a5741b3868.svg)

View File

@@ -2,12 +2,8 @@
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
path: path.join(__dirname, '../../.env.local'),
});
/** @type {import('next').NextConfig} */
@@ -26,14 +22,6 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async headers() {
return [
{

View File

@@ -21,7 +21,7 @@
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
"micro": "^10.0.1",
"next": "13.5.4",
"next": "13.4.19",
"next-auth": "4.22.3",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
@@ -31,10 +31,10 @@
"react-confetti": "^6.1.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"react-icons": "^4.8.0",
"recharts": "^2.7.2",
"sharp": "0.32.5",
"typescript": "5.2.2",
"typescript": "5.1.6",
"zod": "^3.21.4"
},
"devDependencies": {

View File

@@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@@ -2,8 +2,6 @@
import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
@@ -15,7 +13,6 @@ export type MarketingLayoutProps = {
export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
useEffect(() => {
const onScroll = () => {
@@ -28,11 +25,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
}, []);
return (
<div
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28">
<div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,

View File

@@ -31,22 +31,6 @@ export const TEAM_MEMBERS = [
engagement: 'Full-Time',
joinDate: 'July 26th, 2023',
},
{
name: 'Catalin-Marinel Pit',
role: 'Software Engineer - II',
salary: 80_000,
location: 'Romania',
engagement: 'Full-Time',
joinDate: 'September 4th, 2023',
},
{
name: 'Gowdhama Rajan B',
role: 'Designer - III',
salary: 100_000,
location: 'India',
engagement: 'Full-Time',
joinDate: 'October 9th, 2023',
},
];
export const FUNDING_RAISED = [

View File

@@ -27,10 +27,7 @@ import { createSinglePlayerDocument } from '~/components/(marketing)/single-play
type SinglePlayerModeStep = 'fields' | 'sign';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export const SinglePlayerClient = () => {
export default function SinglePlayerModePage() {
const analytics = useAnalytics();
const router = useRouter();
@@ -133,7 +130,7 @@ export const SinglePlayerClient = () => {
signer: data.email,
});
router.push(`/singleplayer/${documentToken}/success`);
router.push(`/single-player-mode/${documentToken}/success`);
} catch {
toast({
title: 'Something went wrong',
@@ -244,4 +241,4 @@ export const SinglePlayerClient = () => {
</div>
</div>
);
};
}

View File

@@ -1,10 +0,0 @@
import { SinglePlayerClient } from './client';
export const revalidate = 0;
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export default function SingleplayerPage() {
return <SinglePlayerClient />;
}

View File

@@ -4,7 +4,6 @@ import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
@@ -64,9 +63,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body>
<FeatureFlagProvider initialFlags={flags}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<PlausibleProvider>
<TrpcProvider>{children}</TrpcProvider>
</PlausibleProvider>
<PlausibleProvider>{children}</PlausibleProvider>
</ThemeProvider>
</FeatureFlagProvider>

View File

@@ -2,8 +2,8 @@
import Link from 'next/link';
import { Github } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { Button } from '@documenso/ui/primitives/button';
@@ -52,7 +52,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
onClick={() => event('view-github')}
>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
<Github className="mr-2 h-5 w-5" />
Star on Github
{starCount && starCount > 0 && (
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useSearchParams } from 'next/navigation';
@@ -30,7 +30,7 @@ import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
name: z.string().min(3),
email: z.string().email(),
});
@@ -55,8 +55,8 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TClaimPlanDialogFormSchema>({
mode: 'onBlur',
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
@@ -91,12 +91,6 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
}
};
useEffect(() => {
if (!isSubmitting && !open) {
reset();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>

View File

@@ -5,30 +5,27 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { Github, MessagesSquare, Moon, Sun, Twitter } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import { cn } from '@documenso/ui/lib/utils';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
const SOCIAL_LINKS = [
{ href: 'https://twitter.com/documenso', icon: <FaXTwitter className="h-6 w-6" /> },
{ href: 'https://github.com/documenso/documenso', icon: <LuGithub className="h-6 w-6" /> },
{ href: 'https://documen.so/discord', icon: <LiaDiscord className="h-7 w-7" /> },
{ href: 'https://twitter.com/documenso', icon: <Twitter className="h-6 w-6" /> },
{ href: 'https://github.com/documenso/documenso', icon: <Github className="h-6 w-6" /> },
{ href: 'https://documen.so/discord', icon: <MessagesSquare className="h-6 w-6" /> },
];
const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' },
{ href: '/singleplayer', text: 'Singleplayer' },
{ href: '/single-player-mode', text: 'Single Player Mode' },
{ href: '/blog', text: 'Blog' },
{ href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support' },
{ href: '/privacy', text: 'Privacy' },
];

View File

@@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
{isSinglePlayerModeMarketingEnabled && (
<Link
href="/singleplayer"
href="/single-player-mode"
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
>
Try now!

View File

@@ -4,8 +4,8 @@ import Image from 'next/image';
import Link from 'next/link';
import { Variants, motion } from 'framer-motion';
import { Github } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
@@ -122,7 +122,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" />
<Github className="mr-2 h-5 w-5" />
Star on Github
</Button>
</Link>
@@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants}
initial="initial"
animate="animate"
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300"
>
<Link href="/singleplayer" className="block px-4 py-2 text-center">
<Link href="/single-player-mode" className="block px-4 py-2 text-center">
<h2 className="text-muted-foreground text-xs font-semibold">
Introducing Single Player Mode
</h2>

View File

@@ -4,9 +4,7 @@ import Image from 'next/image';
import Link from 'next/link';
import { motion, useReducedMotion } from 'framer-motion';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import { Github, MessagesSquare, Twitter } from 'lucide-react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
@@ -17,8 +15,8 @@ export type MobileNavigationProps = {
export const MENU_NAVIGATION_LINKS = [
{
href: '/singleplayer',
text: 'Singleplayer',
href: '/single-player-mode',
text: 'Single Player Mode',
},
{
href: '/blog',
@@ -39,7 +37,6 @@ export const MENU_NAVIGATION_LINKS = [
{
href: 'mailto:support@documenso.com',
text: 'Support',
target: '_blank',
},
{
href: '/privacy',
@@ -79,7 +76,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
staggerChildren: 0.03,
}}
>
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
<motion.div
key={href}
variants={{
@@ -101,7 +98,6 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
href={href}
onClick={() => handleMenuItemClick()}
target={target}
>
{text}
</Link>
@@ -115,7 +111,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<FaXTwitter className="h-6 w-6" />
<Twitter className="h-6 w-6" />
</Link>
<Link
@@ -123,7 +119,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LuGithub className="h-6 w-6" />
<Github className="h-6 w-6" />
</Link>
<Link
@@ -131,7 +127,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
target="_blank"
className="text-foreground hover:text-foreground/80"
>
<LiaDiscord className="h-7 w-7" />
<MessagesSquare className="h-6 w-6" />
</Link>
</div>
</SheetContent>

View File

@@ -1,8 +1,9 @@
'use client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type PasswordRevealProps = {
password: string;
};

View File

@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Share } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { base64 } from '@documenso/lib/universal/base64';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -86,11 +87,11 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
<div className="relative mt-8 w-full">
<div className={cn('flex flex-col items-center', className)}>
<div className="grid w-full max-w-sm grid-cols-2 gap-4">
<DocumentShareButton
documentId={document.id}
token={document.Recipient.token}
className="flex-1 bg-transparent backdrop-blur-sm"
/>
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled>
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DocumentDownloadButton
className="flex-1 bg-transparent backdrop-blur-sm"
@@ -102,7 +103,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
<Button
onClick={async () => onShowDocumentClick()}
loading={isFetchingDocumentFile}
className="z-10 col-span-2"
className="col-span-2"
>
Show document
</Button>

View File

@@ -31,7 +31,7 @@ import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z
.object({
email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
name: z.string().min(3, { message: 'Please enter a valid name.' }),
})
.and(
z.union([
@@ -41,7 +41,7 @@ const ZWidgetFormSchema = z
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().trim().min(1),
signatureText: z.string().min(1),
}),
]),
);
@@ -377,7 +377,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</Card>
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
<DialogContent position="center">
<DialogContent>
<DialogHeader>
<DialogTitle>Add your signature</DialogTitle>
</DialogHeader>
@@ -391,7 +391,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<SignaturePad
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}
/>

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => {
if (!navigator?.clipboard) {
console.warn('Clipboard not supported');
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy failed', error);
setCopiedText(null);
return false;
}
};
return [copiedText, copy];
}

View File

@@ -41,7 +41,7 @@ export default async function handler(
},
});
if (user && user.Subscription) {
if (user && user.Subscription.length > 0) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
});

View File

@@ -1,8 +0,0 @@
import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
});

View File

@@ -2,12 +2,8 @@
const path = require('path');
const { version } = require('./package.json');
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
path: path.join(__dirname, '../../.env.local'),
});
/** @type {import('next').NextConfig} */
@@ -33,14 +29,6 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async rewrites() {
return [
{
@@ -49,32 +37,6 @@ const config = {
},
];
},
async redirects() {
return [
{
permanent: true,
source: '/documents/:id/sign',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
{
permanent: true,
source: '/documents/:id/signed',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
];
},
};
module.exports = config;

View File

@@ -25,7 +25,7 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.5.4",
"next": "13.4.19",
"next-auth": "4.22.3",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
@@ -36,11 +36,11 @@
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"react-icons": "^4.8.0",
"react-rnd": "^10.4.1",
"sharp": "0.32.5",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"typescript": "5.1.6",
"zod": "^3.21.4"
},
"devDependencies": {

View File

@@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@@ -1,106 +0,0 @@
'use client';
import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
export type DocumentsDataTableProps = {
results: FindResultSet<
Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
},
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
return (
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-gray-400">
<span className="text-sm">
{recipientInitials(row.original.User.name ?? '')}
</span>
</AvatarFallback>
</Avatar>
</Link>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@@ -1,29 +0,0 @@
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = {
searchParams?: {
page?: string;
perPage?: string;
};
};
export default async function Documents({ searchParams = {} }: DocumentsPageProps) {
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
page,
perPage,
});
return (
<div>
<h2 className="text-4xl font-semibold">Manage documents</h2>
<div className="mt-8">
<DocumentsDataTable results={results} />
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
import { BarChart3, User2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -37,40 +37,10 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
asChild
disabled
>
<Link href="/admin/users">
<User2 className="mr-2 h-5 w-5" />
Users
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/documents') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
Documents
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
Subscriptions
</Link>
Users (Coming Soon)
</Button>
</div>
);

View File

@@ -1,65 +0,0 @@
import Link from 'next/link';
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
export default async function Subscriptions() {
const subscriptions = await findSubscriptions();
return (
<div>
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>User ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((subscription, index) => (
<TableRow key={index}>
<TableCell>{subscription.id}</TableCell>
<TableCell>{subscription.status}</TableCell>
<TableCell>
{subscription.createdAt
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
{subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
<Link href={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -1,141 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { toast } = useToast();
const router = useRouter();
const { data: user } = trpc.profile.getUser.useQuery(
{
id: Number(params.id),
},
{
enabled: !!params.id,
},
);
const roles = user?.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const form = useForm<TUserFormSchema>({
resolver: zodResolver(ZUserFormSchema),
values: {
name: user?.name ?? '',
email: user?.email ?? '',
roles: user?.roles ?? [],
},
});
const onSubmit = async ({ name, email, roles }: TUserFormSchema) => {
try {
await updateUserMutation({
id: Number(user?.id),
name,
email,
roles,
});
router.refresh();
toast({
title: 'Profile updated',
description: 'Your profile has been updated.',
duration: 5000,
});
} catch (e) {
toast({
title: 'Error',
description: 'An error occurred while updating your profile.',
variant: 'destructive',
});
}
};
return (
<div>
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Name</FormLabel>
<FormControl>
<Input type="text" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Email</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roles"
render={({ field: { onChange } }) => (
<FormItem>
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<Combobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>
</FormControl>
<FormMessage />
</fieldset>
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update user
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@@ -1,143 +0,0 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import Link from 'next/link';
import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
type UserData = {
id: number;
name: string | null;
email: string;
roles: Role[];
Subscription?: SubscriptionLite | null;
Document: DocumentLite[];
};
type SubscriptionLite = Pick<
Subscription,
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
>;
type DocumentLite = Pick<Document, 'id'>;
type UsersDataTableProps = {
users: UserData[];
totalPages: number;
perPage: number;
page: number;
};
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
const debouncedSearchString = useDebouncedValue(searchString, 1000);
useEffect(() => {
startTransition(() => {
updateSearchParams({
search: debouncedSearchString,
page: 1,
perPage,
});
});
}, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchString(e.target.value);
};
return (
<div className="relative">
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder="Search by name or email"
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: 'Email',
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: 'Roles',
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
},
{
header: 'Documents',
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
]}
data={users}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@@ -1,9 +0,0 @@
'use server';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
export async function search(search: string, page: number, perPage: number) {
const results = await findUsers({ username: search, email: search, page, perPage });
return results;
}

View File

@@ -1,25 +0,0 @@
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
type AdminManageUsersProps = {
searchParams?: {
search?: string;
page?: number;
perPage?: number;
};
};
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
const { users, totalPages } = await search(searchString, page, perPage);
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
</div>
);
}

View File

@@ -6,15 +6,13 @@ import { Edit, Pencil, Share } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionButtonProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
@@ -24,18 +22,16 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
const [, copyToClipboard] = useCopyToClipboard();
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
@@ -45,6 +41,20 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
return match({
isOwner,
isRecipient,
@@ -70,17 +80,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button>
))
.otherwise(() => (
<Button
className="w-24"
loading={isCopyingShareLink}
onClick={async () =>
createAndCopyShareLink({
token: recipient?.token,
documentId: row.id,
})
}
>
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button>
));

View File

@@ -1,7 +1,5 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import {
@@ -18,15 +16,11 @@ import {
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,7 +30,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionDropdownProps = {
row: Document & {
@@ -47,29 +41,38 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpcReact.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
// const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@@ -144,7 +147,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Void
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<DropdownMenuItem disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@@ -156,16 +159,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Resend
</DropdownMenuItem>
<DropdownMenuItem
disabled={isDraft}
onClick={async () =>
createAndCopyShareLink({
token: recipient?.token,
documentId: row.id,
})
}
>
{isCopyingShareLink ? (
<DropdownMenuItem onClick={onShareClick}>
{isCreatingShareLink ? (
<Loader className="mr-2 h-4 w-4" />
) : (
<Share className="mr-2 h-4 w-4" />
@@ -173,14 +168,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Share
</DropdownMenuItem>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDraftDocumentDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
)}
</DropdownMenu>
);
};

View File

@@ -1,89 +0,0 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteDraftDocumentDialog = ({
id,
open,
onOpenChange,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteDocument, isLoading } =
trpcReact.document.deleteDraftDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDraftDelete = async () => {
try {
await deleteDocument({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -66,7 +66,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[

View File

@@ -2,15 +2,12 @@
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
@@ -25,8 +22,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
@@ -57,19 +52,11 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
} catch (error) {
console.error(error);
if (error instanceof TRPCClientError) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'Error',
description: 'An error occurred while uploading your document.',
variant: 'destructive',
});
}
} finally {
setIsLoading(false);
}
@@ -77,46 +64,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
onDrop={onFileDrop}
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
{remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">
You have reached your document limit.
</h2>
<p className="text-muted-foreground/60 mt-2 text-sm">
You can upload up to {quota.documents} documents per month on your current plan.
</p>
<Link
className="text-primary hover:text-primary/80 mt-6 block font-medium"
href="/settings/billing"
>
Upgrade your account to upload more documents.
</Link>
</div>
</div>
)}
</div>
);
};

View File

@@ -4,7 +4,6 @@ import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
@@ -29,13 +28,11 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</LimitsProvider>
</NextAuthProvider>
);
}

View File

@@ -1,133 +0,0 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createCheckout } from './create-checkout.action';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
};
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { toast } = useToast();
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
const onSubscribeClick = async (priceId: string) => {
try {
setIsFetchingCheckoutSession(true);
const url = await createCheckout({ priceId });
if (!url) {
throw new Error('Unable to create session');
}
window.open(url);
} catch (_err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to create a checkout session.',
variant: 'destructive',
});
} finally {
setIsFetchingCheckoutSession(false);
}
};
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{FRIENDLY_INTERVALS[interval]}
</TabsTrigger>
),
)}
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<Button
className="mt-4"
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
Subscribe
</Button>
</CardContent>
</MotionCard>
))}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -1,55 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export const BillingPortalButton = () => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
const handleFetchPortalUrl = async () => {
if (isFetchingPortalUrl) {
return;
}
setIsFetchingPortalUrl(true);
try {
const sessionUrl = await createBillingPortal();
if (!sessionUrl) {
throw new Error('NO_SESSION');
}
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
}
toast({
title: 'Something went wrong',
description,
variant: 'destructive',
duration: 10000,
});
}
setIsFetchingPortalUrl(false);
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
Manage Subscription
</Button>
);
};

View File

@@ -1,48 +0,0 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@@ -1,59 +0,0 @@
'use server';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@@ -1,19 +1,16 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession();
@@ -24,73 +21,57 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
const [subscription, prices] = await Promise.all([
getSubscriptionByUserId({ userId: user.id }),
getPricesByInterval(),
]);
let subscriptionProduct: Stripe.Product | null = null;
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
if (sub) {
return sub;
}
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
// If we don't have a customer record, create one as well as an empty subscription.
return createCustomer({ user });
});
let billingPortalUrl = '';
if (subscription.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
return (
<div>
<h3 className="text-lg font-medium">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
You are currently on the <span className="font-semibold">Free Plan</span>.
</p>
)}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
<p className="text-muted-foreground mt-2 text-sm">
Your subscription is{' '}
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
{subscription?.periodEnd && (
<>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
) : (
<span>
automatically renew on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
)}
Your next payment is due on{' '}
<span className="font-semibold">
<LocaleDate date={subscription.periodEnd} />
</span>
.
</>
)}
</p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
))
.otherwise(() => null)}
</div>
<hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
{billingPortalUrl && (
<Button asChild>
<Link href={billingPortalUrl}>Manage Subscription</Link>
</Button>
)}
{!billingPortalUrl && (
<p className="text-muted-foreground max-w-[60ch] text-base">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>
)}
</div>
);
}

View File

@@ -1,43 +0,0 @@
import fs from 'fs/promises';
import { loadFileIntoPinecone } from '@documenso/lib/server-only/pinecone';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentDataType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Chat } from './chat';
type ChatPDFProps = {
id: string;
type: DocumentDataType;
data: string;
initialData: string;
};
export async function ChatPDF({ documentData }: { documentData: ChatPDFProps }) {
const docData = await getFile(documentData);
const fileName = `${documentData.id}}.pdf`;
try {
await fs.access(fileName, fs.constants.F_OK);
} catch (err) {
await fs.writeFile(fileName, docData);
}
await loadFileIntoPinecone(fileName);
return (
<Card className="my-8" gradient={true} degrees={200}>
<CardContent className="mt-8 flex flex-col">
<h2 className="text-foreground text-2xl font-semibold">Chat with the PDF</h2>
<p className="text-muted-foreground mt-2 text-sm">Ask any questions regarding the PDF</p>
<hr className="border-border mb-4 mt-4" />
<Chat />
<hr className="border-border mb-4 mt-4" />
<p className="text-muted-foreground text-sm italic">
Disclaimer: Never trust AI 100%. Always double check the documents yourself. Documenso is
not liable for any issue arising from you relying 100% on the AI.
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import { useChat } from 'ai/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
type Props = {};
export function Chat({}: Props) {
const { input, handleInputChange, handleSubmit, messages } = useChat({
api: '/api/chat',
});
// continue https://youtu.be/bZFedu-0emE?si=2JGSJfSQ38aXSlp2&t=10941
return (
<div>
<div className="flex flex-col gap-8">
<ul>
{messages.map((message, index) => (
<li
className={cn(
'flex',
message.role === 'user'
? 'mb-6 ml-10 mt-6 flex justify-end'
: 'mr-10 justify-start',
)}
key={index}
>
<span
className={
message.role === 'user'
? 'bg-background text-foreground group relative rounded-lg border-2 p-4 backdrop-blur-[2px]'
: 'bg-primary text-primary-foreground rounded-lg p-4 backdrop-blur-[2px]'
}
>
{message.content}
</span>
</li>
))}
</ul>
</div>
<form className="mb-2 mt-8 flex" onSubmit={handleSubmit}>
<Input
value={input}
className="mr-6 w-1/2"
onChange={handleInputChange}
placeholder="Ask away..."
/>
<Button type="submit">Send</Button>
</form>
</div>
);
}

View File

@@ -9,11 +9,12 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import signingCelebration from '~/assets/signing-celebration.png';
import { ShareButton } from './share-button';
export type CompletedSigningPageProps = {
params: {
token?: string;
@@ -88,7 +89,7 @@ export default async function CompletedSigningPage({
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
<ShareButton documentId={document.id} token={recipient.token} />
<DocumentDownloadButton
className="flex-1"

View File

@@ -2,17 +2,10 @@
import { HTMLAttributes, useState } from 'react';
import { Copy, Share } from 'lucide-react';
import { FaXTwitter } from 'react-icons/fa6';
import { Copy, Share, Twitter } from 'lucide-react';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
} from '@documenso/lib/constants/toast';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -24,18 +17,16 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
token: string;
documentId: number;
};
export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
const { toast } = useToast();
const { copyShareLink, createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
});
const [, copyToClipboard] = useCopyToClipboard();
const [isOpen, setIsOpen] = useState(false);
@@ -57,15 +48,24 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
};
const onCopyClick = async () => {
if (shareLink) {
await copyShareLink(`${window.location.origin}/share/${shareLink.slug}`);
} else {
await createAndCopyShareLink({
let { slug = '' } = shareLink || {};
if (!slug) {
const result = await createOrGetShareLink({
token,
documentId,
});
slug = result.slug;
}
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
setIsOpen(false);
};
@@ -84,7 +84,7 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
window.open(
generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`,
`${window.location.origin}/share/${slug}`,
),
'_blank',
);
@@ -98,10 +98,10 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
<Button
variant="outline"
disabled={!token || !documentId}
className={cn('flex-1', className)}
loading={isLoading || isCopyingShareLink}
className="flex-1"
loading={isLoading}
>
{!isLoading && !isCopyingShareLink && <Share className="mr-2 h-5 w-5" />}
{!isLoading && <Share className="mr-2 h-5 w-5" />}
Share
</Button>
</DialogTrigger>
@@ -119,17 +119,13 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
<span className="font-medium text-blue-400">@documenso</span>
. Check it out!
<span className="mt-2 block" />
<span
className={cn('break-all font-medium text-blue-400', {
'animate-pulse': !shareLink?.slug,
})}
>
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
<span className="break-all font-medium text-blue-400">
{window.location.origin}/share/{shareLink?.slug || '...'}
</span>
</div>
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
<FaXTwitter className="mr-2 h-4 w-4" />
<Twitter className="mr-2 h-4 w-4" />
Tweet
</Button>

View File

@@ -87,7 +87,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4 h-8 w-full" />
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
@@ -99,7 +99,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
onChange={(e) => setFullName(e.target.value)}
/>
</div>

View File

@@ -125,7 +125,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
type="text"
className="mt-2"
value={localFullName}
onChange={(e) => setLocalFullName(e.target.value.trimStart())}
onChange={(e) => setLocalFullName(e.target.value)}
/>
</div>

View File

@@ -14,7 +14,6 @@ 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 { ChatPDF } from './chat-pdf';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
@@ -107,7 +106,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
.otherwise(() => null),
)}
</ElementVisible>
<ChatPDF documentData={documentData} />
</div>
</SigningProvider>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
@@ -48,7 +48,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) {
@@ -62,16 +61,9 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]);
useEffect(() => {
if (!showSignatureModal && !isLocalSignatureSet) {
setLocalSignature(null);
}
}, [showSignatureModal, isLocalSignatureSet]);
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
if (!providedSignature && !localSignature) {
setIsLocalSignatureSet(false);
setShowSignatureModal(true);
return;
}
@@ -186,7 +178,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
disabled={!localSignature}
onClick={() => {
setShowSignatureModal(false);
setIsLocalSignatureSet(true);
void onSign('local');
}}
>

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import {
CreditCard,
Github,
Key,
LogOut,
User as LucideUser,
@@ -15,7 +16,6 @@ import {
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
@@ -130,7 +130,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
<LuGithub className="mr-2 h-4 w-4" />
<Github className="mr-2 h-4 w-4" />
Star on Github
</Link>
</DropdownMenuItem>

View File

@@ -20,7 +20,6 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z
.object({
currentPassword: z.string().min(6).max(72),
password: z.string().min(6).max(72),
repeatedPassword: z.string().min(6).max(72),
})
@@ -41,7 +40,6 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const {
register,
@@ -50,7 +48,6 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
formState: { errors, isSubmitting },
} = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
},
@@ -59,10 +56,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
const onFormSubmit = async ({ password }: TPasswordFormSchema) => {
try {
await updatePassword({
currentPassword,
password,
});
@@ -96,39 +92,6 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="current-password" className="text-muted-foreground">
Current Password
</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2 pr-10"
{...register('currentPassword')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowCurrentPassword((show) => !show)}
>
{showCurrentPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
</div>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Password

View File

@@ -20,7 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
name: z.string().min(1),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
});
@@ -117,8 +117,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
name="signature"
render={({ field: { onChange } }) => (
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>

View File

@@ -19,7 +19,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
name: z.string().min(1),
email: z.string().email().min(1),
password: z.string().min(6).max(72),
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
@@ -147,8 +147,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
name="signature"
render={({ field: { onChange } }) => (
<SignaturePad
className="h-36 w-full"
containerClassName="mt-2 rounded-lg border bg-background"
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]"
onChange={(v) => onChange(v ?? '')}
/>
)}

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => {
if (!navigator?.clipboard) {
console.warn('Clipboard not supported');
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy failed', error);
setCopiedText(null);
return false;
}
};
return [copiedText, copy];
}

View File

@@ -1,54 +0,0 @@
import { Message, OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
import { getContext } from '@documenso/lib/server-only/context';
export const runtime = 'edge';
const config = new Configuration({
apiKey: process.env.OPENAI_API_KEY!,
});
const openai = new OpenAIApi(config);
export default async function handler(request: Request) {
// console.log(request.method);
// request.json().then((data) => console.log(data));
// return Response.json({ message: 'world' });
try {
const data = await request.json();
const lastMessage = data.messages[data.messages.length - 1];
const context = await getContext(lastMessage.content);
console.log('context', context);
const prompt = {
role: 'system',
content: `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
START CONTEXT BLOCK
${context}
END OF CONTEXT BLOCK
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question".
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
AI assistant will not invent anything that is not drawn directly from the context.
`,
};
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [prompt, ...data.messages.filter((message: Message) => message.role === 'user')],
stream: true,
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
} catch (error) {
console.error('There was an error getting embeddings: ', error);
throw new Error('There was an error getting embeddings');
}
}

View File

@@ -41,7 +41,7 @@ export default async function handler(
},
});
if (user && user.Subscription) {
if (user && user.Subscription.length > 0) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
});

View File

@@ -1,3 +0,0 @@
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
export default limitsHandler;

View File

@@ -1,7 +1,197 @@
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
export const config = {
api: { bodyParser: false },
};
export default stripeWebhookHandler;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
// return res.status(500).json({
// success: false,
// message: 'Subscriptions are not enabled',
// });
// }
const sig =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!sig) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
log('constructing body...');
const body = await buffer(req);
log('constructed body');
const event = stripe.webhooks.constructEvent(
body,
sig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
);
log('event-type:', event.type);
if (event.type === 'checkout.session.completed') {
// This is required since we don't want to create a guard for every event type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'landing') {
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
documentDataId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
email: user.email,
token: randomBytes(16).toString('hex'),
signedAt: now,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
documentId: document.id,
},
});
const field = await prisma.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 0,
positionX: 77,
positionY: 638,
inserted: false,
customText: '',
},
});
if (signatureDataUrl) {
documentData.data = await insertImageInPDF(
documentData.data,
signatureDataUrl,
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
} else {
documentData.data = await insertTextInPDF(
documentData.data,
signatureText ?? '',
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
}
await Promise.all([
prisma.signature.create({
data: {
fieldId: field.id,
recipientId: recipient.id,
signatureImageAsBase64: signatureDataUrl || undefined,
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
where: {
id: document.id,
},
data: {
documentData: {
update: {
data: documentData.data,
},
},
},
}),
]);
}
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
log('Unhandled webhook event', event.type);
return res.status(400).json({
success: false,
message: 'Unhandled webhook event',
});
}

View File

@@ -6,3 +6,7 @@ export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
});
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
// res.json({ hello: 'world' });
// }

9459
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
"private": true,
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
"start": "cd apps && cd web && next start",
"lint": "turbo run lint",
@@ -11,16 +10,9 @@
"commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev -w @documenso/prisma",
"dx:up": "docker compose -f docker/compose-services.yml up -d",
"dx:down": "docker compose -f docker/compose-services.yml down",
"ci": "turbo run build test:e2e",
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
"with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
"dx:down": "docker compose -f docker/compose-services.yml down"
},
"engines": {
"npm": ">=8.6.0",
@@ -29,8 +21,8 @@
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"dotenv": "^16.0.3",
"dotenv-cli": "^7.2.1",
"eslint": "^8.40.0",
"eslint-config-custom": "*",
"husky": "^8.0.0",
@@ -46,13 +38,6 @@
"packages/*"
],
"dependencies": {
"@pinecone-database/pinecone": "^1.1.1",
"@types/md5": "^2.3.4",
"ai": "^2.2.16",
"langchain": "^0.0.169",
"md5": "^2.3.0",
"openai-edge": "^1.2.2",
"pdf-parse": "^1.1.1",
"recharts": "^2.7.2"
}
}

View File

@@ -1,4 +0,0 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@@ -1,55 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
test.use({ storageState: { cookies: [], origins: [] } });
/*
Using them sequentially so the 2nd test
uses the details from the 1st (registration) test
*/
test.describe.configure({ mode: 'serial' });
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');
await page.getByLabel('Name').fill(username);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test('user can login with user and password', async ({ page }: { page: Page }) => {
await page.goto('/signin');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test.afterAll('Teardown', async () => {
try {
await deleteUser({ email });
} catch (e) {
throw new Error(`Error deleting user: ${e}`);
}
});

View File

@@ -1,21 +0,0 @@
{
"name": "@documenso/app-tests",
"version": "1.0.0",
"license": "to-update",
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@types/node": "^20.8.2",
"@documenso/web": "*"
},
"dependencies": {
"start-server-and-test": "^2.0.1"
}
}

View File

@@ -1,77 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View File

@@ -14,13 +14,6 @@
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.5.4",
"next-auth": "4.22.3",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.21.4"
"@documenso/prisma": "*"
}
}

View File

@@ -1,31 +0,0 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
};
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZLimitsResponseSchema.parse(res))
.catch(() => {
return {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
} satisfies TLimitsResponseSchema;
});
};

View File

@@ -1,11 +0,0 @@
import { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
};

View File

@@ -1,6 +0,0 @@
export const ERROR_CODES: Record<string, string> = {
UNAUTHORIZED: 'You must be logged in to access this resource',
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
UNKNOWN: 'An unknown error occurred',
};

View File

@@ -1,54 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
req: NextApiRequest,
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
) => {
try {
const token = await getToken({ req });
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
});
}
if (!token?.email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const limits = await getServerLimits({ email: token.email });
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
} catch (err) {
console.error('error', err);
if (err instanceof Error) {
const status = match(err.message)
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
.otherwise(() => 500);
return res.status(status).json({
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
});
}
res.status(500).json({
error: ERROR_CODES.UNKNOWN,
});
}
};

View File

@@ -1,53 +0,0 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
const LimitsContext = createContext<LimitsContextValue | null>(null);
export const useLimits = () => {
const limits = useContext(LimitsContext);
if (!limits) {
throw new Error('useLimits must be used within a LimitsProvider');
}
return limits;
};
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
children?: React.ReactNode;
};
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
const defaultValue: TLimitsResponseSchema = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
};
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
useEffect(() => {
void getLimits().then((limits) => setLimits(limits));
}, []);
useEffect(() => {
const onFocus = () => {
void getLimits().then((limits) => setLimits(limits));
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
};

View File

@@ -1,18 +0,0 @@
'use server';
import { headers } from 'next/headers';
import { getLimits } from '../client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
};
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders });
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
};

View File

@@ -1,28 +0,0 @@
import { z } from 'zod';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
documents: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
recipients: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
});
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
export const ZLimitsErrorResponseSchema = z.object({
error: z.string(),
});
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;

View File

@@ -1,78 +0,0 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string;
};
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const user = await prisma.user.findFirst({
where: {
email,
},
include: {
Subscription: true,
},
});
if (!user) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
if (user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({
where: {
userId: user.id,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
},
});
remaining.documents = Math.max(remaining.documents - documents, 0);
return {
quota,
remaining,
};
};

View File

@@ -1,32 +0,0 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
}: GetCheckoutSessionOptions) => {
'use server';
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
});
return session.url;
};

View File

@@ -1,19 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data[0] ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
return !stripeCustomer.deleted ? stripeCustomer : null;
} catch {
return null;
}
};

View File

@@ -1,49 +0,0 @@
import Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
prices = prices.filter((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// Filter out prices for products that are not active.
return product.active;
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

@@ -1,17 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@@ -1,224 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
try {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return res.status(500).json({
success: false,
message: 'Billing is disabled',
});
}
const signature =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!signature) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
await match(event.type)
.with('checkout.session.completed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const userId = Number(session.client_reference_id);
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await onSubscriptionDeleted({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.otherwise(() => {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
});
} catch (err) {
console.error(err);
res.status(500).json({
success: false,
message: 'Unknown error',
});
}
};

View File

@@ -1,21 +0,0 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};

View File

@@ -1,44 +0,0 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
},
update: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
};

View File

@@ -1 +1 @@
export { render } from '@react-email/components';
export { render, renderAsync } from '@react-email/components';

View File

@@ -32,7 +32,7 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
customBody,
}: DocumentInviteEmailTemplateProps) => {
const previewText = `${inviterName} has invited you to sign ${documentName}`;
const previewText = `Completed Document`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();

View File

@@ -7,8 +7,8 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "6.8.0",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.40.0",
"eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0",
@@ -16,6 +16,6 @@
"eslint-plugin-package-json": "^0.1.4",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"typescript": "5.2.2"
"typescript": "^5.1.6"
}
}

View File

@@ -1,53 +0,0 @@
import { trpc } from '@documenso/trpc/react';
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
import { useCopyToClipboard } from './use-copy-to-clipboard';
export type UseCopyShareLinkOptions = {
onSuccess?: () => void;
onError?: () => void;
};
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
const [, copyToClipboard] = useCopyToClipboard();
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
/**
* Copy a newly created, or pre-existing share link to the user's clipboard.
*
* @param payload The payload to create or get a share link.
*/
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
const valueToCopy = createOrGetShareLink(payload).then(
(result) => `${window.location.origin}/share/${result.slug}`,
);
await copyShareLink(valueToCopy);
};
/**
* Copy a share link to the user's clipboard.
*
* @param shareLink Either the share link itself or a promise that returns a shared link.
*/
const copyShareLink = async (shareLink: Promise<string> | string) => {
try {
const isCopySuccess = await copyToClipboard(shareLink);
if (!isCopySuccess) {
throw new Error('Copy to clipboard failed');
}
onSuccess?.();
} catch (e) {
onError?.();
}
};
return {
createAndCopyShareLink,
copyShareLink,
isCopyingShareLink: isCreatingShareLink,
};
}

Some files were not shown because too many files have changed in this diff Show More