diff --git a/.env.example b/.env.example index 968dd05e5..d188894de 100644 --- a/.env.example +++ b/.env.example @@ -77,16 +77,14 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= 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. NEXT_PUBLIC_POSTHOG_KEY="" -# OPTIONAL: Defines the host to use for PostHog. -NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com" # OPTIONAL: Leave blank to disable billing. NEXT_PUBLIC_FEATURE_BILLING_ENABLED= +# OPTIONAL: Leave blank to allow users to signup through /signup page. +NEXT_PUBLIC_DISABLE_SIGNUP= # This is only required for the marketing site # [[REDIS]] diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml index 1ce7a02be..dbd321509 100644 --- a/.github/workflows/issue-assignee-check.yml +++ b/.github/workflows/issue-assignee-check.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User' runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml index cc272fbfe..78f927e61 100644 --- a/.github/workflows/pr-review-reminder.yml +++ b/.github/workflows/pr-review-reminder.yml @@ -12,6 +12,10 @@ jobs: if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 7685562b5..37b764652 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -16,6 +16,24 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: + - name: Check PR creator's previous activity + id: check_activity + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + if [ "$ACTIVITY" -eq 0 ]; then + echo "::set-output name=is_new::true" + else + echo "::set-output name=is_new::false" + fi + + - name: Count PRs created by user + id: count_prs + run: | + CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') + PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') + echo "::set-output name=pr_count::$PR_COUNT" + - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title env: @@ -36,7 +54,7 @@ jobs: ${{ steps.lint_pr_title.outputs.error_message }} ``` - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + - if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}} uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-title-lint-error diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index efd681a71..3e829d24b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,11 +15,10 @@ jobs: - uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-pr-stale: 30 - days-before-issue-stale: 30 - stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected' + days-before-pr-stale: 90 + days-before-issue-stale: 90 + days-before-issue-close: 180 stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.' - close-issue-message: 'This issue has been closed because of inactivity.' close-pr-message: 'This PR has been closed because of inactivity.' exempt-pr-labels: 'WIP,on-hold,needs review' - exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned' + exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage' diff --git a/README.md b/README.md index 24d932858..39cbb4332 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ · Issues · - Roadmap + Upcoming Releases · - Upcoming Launches + Roadmap

@@ -115,10 +115,12 @@ To run Documenso locally, you will need Want to get up and running quickly? Follow these steps: -1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. +1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. + +After forking the repository, clone it to your local device by using the following command: ```sh -git clone https://github.com/documenso/documenso +git clone https://github.com//documenso ``` 2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults. @@ -152,10 +154,12 @@ npm run d Follow these steps to setup Documenso on your local machine: -1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. +1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. + +After forking the repository, clone it to your local device by using the following command: ```sh -git clone https://github.com/documenso/documenso +git clone https://github.com//documenso ``` 2. Run `npm i` in the root directory @@ -280,12 +284,16 @@ WantedBy=multi-user.target ### Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX) +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) ### Render [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) +### Koyeb + +[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile) + ## Troubleshooting ### I'm not receiving any emails when using the developer quickstart. diff --git a/apps/marketing/content/blog/pre-seed.mdx b/apps/marketing/content/blog/pre-seed.mdx index fae0a6c4a..215700355 100644 --- a/apps/marketing/content/blog/pre-seed.mdx +++ b/apps/marketing/content/blog/pre-seed.mdx @@ -1,6 +1,6 @@ --- title: Announcing Pre-Seed and Open Metrics -description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. +description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' diff --git a/apps/marketing/content/blog/shop.mdx b/apps/marketing/content/blog/shop.mdx index fafd98a40..cb5b65554 100644 --- a/apps/marketing/content/blog/shop.mdx +++ b/apps/marketing/content/blog/shop.mdx @@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) ## Documenso Merch Shop -The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso. +The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
-

Monthly New Users

+

New Users

diff --git a/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx index 3c3f4476a..e31bb9def 100644 --- a/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx +++ b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx @@ -3,7 +3,7 @@ import { DateTime } from 'luxon'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; +import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { cn } from '@documenso/ui/lib/utils'; export type MonthlyTotalUsersChartProps = { @@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha return (
-

Monthly Total Users

+

Total Users

diff --git a/apps/marketing/src/app/(marketing)/open/tooltip.tsx b/apps/marketing/src/app/(marketing)/open/tooltip.tsx index 0ae92d535..d077e7d35 100644 --- a/apps/marketing/src/app/(marketing)/open/tooltip.tsx +++ b/apps/marketing/src/app/(marketing)/open/tooltip.tsx @@ -29,10 +29,7 @@ export function OpenPageTooltip() { -

- August and earlier: Active subscribers. September and beyond: Numbers of active - subscriptions. -

+

Active Subscriptions.

diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index b7654c7cf..389528bf8 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -86,6 +86,7 @@ export const SinglePlayerClient = () => { data.fields.map((field, i) => ({ id: i, documentId: -1, + templateId: null, recipientId: -1, type: field.type, page: field.pageNumber, @@ -148,6 +149,7 @@ export const SinglePlayerClient = () => { const placeholderRecipient: Recipient = { id: -1, documentId: -1, + templateId: null, email: '', name: '', token: '', diff --git a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx b/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx deleted file mode 100644 index 8f826b4de..000000000 --- a/apps/marketing/src/components/(marketing)/claim-plan-dialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; - -import { useSearchParams } from 'next/navigation'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Info } from 'lucide-react'; -import { usePlausible } from 'next-plausible'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { claimPlan } from '~/api/claim-plan/fetcher'; - -import { FormErrorMessage } from '../form/form-error-message'; - -export const ZClaimPlanDialogFormSchema = z.object({ - name: z.string().trim().min(3, { message: 'Please enter a valid name.' }), - email: z.string().email(), -}); - -export type TClaimPlanDialogFormSchema = z.infer; - -export type ClaimPlanDialogProps = { - className?: string; - planId: string; - children: React.ReactNode; -}; - -export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => { - const params = useSearchParams(); - const analytics = useAnalytics(); - const event = usePlausible(); - - const { toast } = useToast(); - - const [open, setOpen] = useState(() => params?.get('cancelled') === 'true'); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - reset, - } = useForm({ - defaultValues: { - name: params?.get('name') ?? '', - email: params?.get('email') ?? '', - }, - resolver: zodResolver(ZClaimPlanDialogFormSchema), - }); - - const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { - try { - const delay = new Promise((resolve) => { - setTimeout(resolve, 1000); - }); - - const [redirectUrl] = await Promise.all([ - claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), - delay, - ]); - - event('claim-plan-pricing'); - analytics.capture('Marketing: Claim plan', { planId, email }); - - window.location.href = redirectUrl; - } catch (error) { - event('claim-plan-failed'); - analytics.capture('Marketing: Claim plan failure', { planId, email }); - - toast({ - title: 'Something went wrong', - description: error instanceof Error ? error.message : 'Please try again later.', - variant: 'destructive', - }); - } - }; - - useEffect(() => { - if (!isSubmitting && !open) { - reset(); - } - }, [open]); - - return ( - !isSubmitting && setOpen(value)}> - {children} - - - - Claim your plan - - - We're almost there! Please enter your email address and name to claim your plan. - - - -
-
- {params?.get('cancelled') === 'true' && ( -
-
-
- -
-
-

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

-
-
-
- )} - -
- - - - - -
- -
- - - - - -
- - -
-
-
-
- ); -}; diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 1399297c7..a687af0d3 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => { return (
-
+
{
-
+
{FOOTER_LINKS.map((link, index) => ( {link.text} diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 712435e68..b65411064 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -1,9 +1,9 @@ 'use client'; -import { HTMLAttributes, useState } from 'react'; +import type { HTMLAttributes } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { usePlausible } from 'next-plausible'; @@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; export const PricingTable = ({ className, ...props }: PricingTableProps) => { - const params = useSearchParams(); const event = usePlausible(); - const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => - params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID - ? 'YEARLY' - : 'MONTHLY', - ); + const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); return (
diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx index aa423e522..1af71c775 100644 --- a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -6,8 +6,9 @@ import Link from 'next/link'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { DocumentStatus, Signature } from '@documenso/prisma/client'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { Signature } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { 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'; diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index e0e8170fa..3a8b63025 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -27,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { claimPlan } from '~/api/claim-plan/fetcher'; +import { STEP } from '../constants'; import { FormErrorMessage } from '../form/form-error-message'; const ZWidgetFormSchema = z @@ -49,13 +50,16 @@ const ZWidgetFormSchema = z export type TWidgetFormSchema = z.infer; +type StepKeys = keyof typeof STEP; +type StepValues = (typeof STEP)[StepKeys]; + export type WidgetProps = HTMLAttributes; export const Widget = ({ className, children, ...props }: WidgetProps) => { const { toast } = useToast(); const event = usePlausible(); - const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); + const [step, setStep] = useState(STEP.EMAIL); const [showSigningDialog, setShowSigningDialog] = useState(false); const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState(null); @@ -82,28 +86,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { const signatureText = watch('signatureText'); const stepsRemaining = useMemo(() => { - if (step === 'NAME') { + if (step === STEP.NAME) { return 2; } - if (step === 'SIGN') { - return 1; + if (step === STEP.EMAIL) { + return 3; } - return 3; + return 1; }, [step]); const onNextStepClick = () => { - if (step === 'EMAIL') { - setStep('NAME'); + if (step === STEP.EMAIL) { + setStep(STEP.NAME); setTimeout(() => { document.querySelector('#name')?.focus(); }, 0); } - if (step === 'NAME') { - setStep('SIGN'); + if (step === STEP.NAME) { + setStep(STEP.SIGN); setTimeout(() => { document.querySelector('#signatureText')?.focus(); @@ -144,19 +148,19 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { const claimPlanInput = signatureDataUrl ? { - name, - email, - planId, - signatureDataUrl: signatureDataUrl, - signatureText: null, - } + name, + email, + planId, + signatureDataUrl: signatureDataUrl, + signatureText: null, + } : { - name, - email, - planId, - signatureDataUrl: null, - signatureText: signatureText ?? '', - }; + name, + email, + planId, + signatureDataUrl: null, + signatureText: signatureText ?? '', + }; const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); @@ -227,7 +231,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { type="button" className="bg-primary h-full w-14 rounded" disabled={!field.value || !!errors.email?.message} - onClick={() => step === 'EMAIL' && onNextStepClick()} + onClick={() => step === STEP.EMAIL && onNextStepClick()} > Next @@ -239,7 +243,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { - {(step === 'NAME' || step === 'SIGN') && ( + {(step === STEP.NAME || step === STEP.SIGN) && ( { - By signing you signal your support of Documenso's mission in a

- non-legally binding, but heartfelt way.

-

You also unlock the option to purchase the early supporter plan including - everything we build this year for fixed price. + By signing you signal your support of Documenso's mission in a
+ non-legally binding, but heartfelt way.
+
+ You also unlock the option to purchase the early supporter plan including everything we + build this year for fixed price.
Roles - onChange(values)} /> diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index f0c91615b..a8e02ca9f 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -8,7 +8,7 @@ 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 type { 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'; @@ -19,7 +19,7 @@ type UserData = { name: string | null; email: string; roles: Role[]; - Subscription?: SubscriptionLite | null; + Subscription?: SubscriptionLite[] | null; Document: DocumentLite[]; }; @@ -35,9 +35,16 @@ type UsersDataTableProps = { totalPages: number; perPage: number; page: number; + individualPriceIds: string[]; }; -export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { +export const UsersDataTable = ({ + users, + totalPages, + perPage, + page, + individualPriceIds, +}: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); const [searchString, setSearchString] = useState(''); @@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa { header: 'Subscription', accessorKey: 'subscription', - cell: ({ row }) => row.original.Subscription?.status ?? 'NONE', + cell: ({ row }) => { + const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) => + individualPriceIds.includes(sub.priceId), + ); + + return foundIndividualSubscription?.status ?? 'NONE'; + }, }, { header: 'Documents', diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 686ce7669..069378274 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,3 +1,5 @@ +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; + import { UsersDataTable } from './data-table-users'; import { search } from './fetch-users.actions'; @@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const perPage = Number(searchParams.perPage) || 10; const searchString = searchParams.search || ''; - const { users, totalPages } = await search(searchString, page, perPage); + const [{ users, totalPages }, individualPrices] = await Promise.all([ + search(searchString, page, perPage), + getPricesByType('individual'), + ]); + + const individualPriceIds = individualPrices.map((price) => price.id); return (

Manage users

- +
); } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index ffce3bd6c..a5dc9e23e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,8 +4,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -145,14 +145,16 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message } = data.email; + const { subject, message, timezone, dateFormat } = data.meta; try { await sendDocument({ documentId: document.id, - email: { + meta: { subject, message, + timezone, + dateFormat, }, }); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 9c3532f88..e1282d29f 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + return ( @@ -164,6 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 5b4a84286..38c01ed82 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = { open: boolean; onOpenChange: (_open: boolean) => void; status: DocumentStatus; + documentTitle: string; }; export const DeleteDocumentDialog = ({ @@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({ open, onOpenChange, status, + documentTitle, }: DeleteDraftDocumentDialogProps) => { const router = useRouter(); @@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({ toast({ title: 'Document deleted', - description: 'Your document has been successfully deleted.', + description: `"${documentTitle}" has been successfully deleted`, duration: 5000, }); @@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({ }, }); + useEffect(() => { + if (open) { + setInputValue(''); + setIsDeleteEnabled(status === DocumentStatus.DRAFT); + } + }, [open, status]); + const onDelete = async () => { try { await deleteDocument({ id, status }); @@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({ !isLoading && onOpenChange(value)}> - Do you want to delete this document? + Are you sure you want to delete "{documentTitle}"? Please note that this action is irreversible. Once confirmed, your document will be @@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({ {status !== DocumentStatus.DRAFT && ( -
+
{ router.push(`/documents/${newId}`); + toast({ title: 'Document Duplicated', description: 'Your document has been successfully duplicated.', diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 5e93495e3..65b95f9ec 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -25,6 +25,7 @@ export type UploadDocumentProps = { export const UploadDocument = ({ className }: UploadDocumentProps) => { const router = useRouter(); const analytics = useAnalytics(); + const { data: session } = useSession(); const { toast } = useToast(); @@ -35,6 +36,16 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); + const disabledMessage = useMemo(() => { + if (remaining.documents === 0) { + return 'You have reached your document limit.'; + } + + if (!session?.user.emailVerified) { + return 'Verify your email to upload documents.'; + } + }, [remaining.documents, session?.user.emailVerified]); + const onFileDrop = async (file: File) => { try { setIsLoading(true); @@ -90,6 +101,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx index ba4c0f818..a931f489b 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; -import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import type { 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'; diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index ee5dbf175..885414515 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -1,46 +1,13 @@ 'use server'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { 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, - }, - }); - } + const { stripeCustomer } = await getStripeCustomerByUser(user); return getPortalSession({ customerId: stripeCustomer.id, diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts index 465d662a1..f8f20030c 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -1,55 +1,36 @@ 'use server'; -import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; -import { - getStripeCustomerByEmail, - getStripeCustomerById, -} from '@documenso/ee/server-only/stripe/get-customer'; +import { getStripeCustomerByUser } 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-component-session'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; export type CreateCheckoutOptions = { priceId: string; }; export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => { - const { user } = await getRequiredServerComponentSession(); + const session = await getRequiredServerComponentSession(); - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); + const { user, stripeCustomer } = await getStripeCustomerByUser(session.user); - let stripeCustomer: Stripe.Customer | null = null; + const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id }); - // Find the Stripe customer for the current user subscription. - if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) { - stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); - - if (!stripeCustomer) { - throw new Error('Missing Stripe customer for subscription'); - } + const foundSubscription = existingSubscriptions.find( + (subscription) => + subscription.priceId === priceId && + subscription.periodEnd && + subscription.periodEnd >= new Date(), + ); + if (foundSubscription) { 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) { - await createCustomer({ - user, - }); - - stripeCustomer = await getStripeCustomerByEmail(user.email); - } - return getCheckoutSession({ customerId: stripeCustomer.id, priceId, diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 61dff3216..74e4bd685 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -2,12 +2,15 @@ import { redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; -import type { Stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { type Stripe } from '@documenso/lib/server-only/stripe'; +import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; +import { SubscriptionStatus } from '@documenso/prisma/client'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans'; import { BillingPortalButton } from './billing-portal-button'; export default async function BillingSettingsPage() { - const { user } = await getRequiredServerComponentSession(); + let { user } = await getRequiredServerComponentSession(); const isBillingEnabled = await getServerComponentFlag('app_billing'); @@ -24,20 +27,36 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const [subscription, prices] = await Promise.all([ - getSubscriptionByUserId({ userId: user.id }), - getPricesByInterval(), + if (!user.customerId) { + user = await getStripeCustomerByUser(user).then((result) => result.user); + } + + const [subscriptions, prices, individualPrices] = await Promise.all([ + getSubscriptionsByUserId({ userId: user.id }), + getPricesByInterval({ type: 'individual' }), + getPricesByType('individual'), ]); + const individualPriceIds = individualPrices.map(({ id }) => id); + let subscriptionProduct: Stripe.Product | null = null; + const individualUserSubscriptions = subscriptions.filter(({ priceId }) => + individualPriceIds.includes(priceId), + ); + + const subscription = + individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + individualUserSubscriptions[0]; + if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( () => null, ); } - const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; + const isMissingOrInactiveOrFreePlan = + !subscription || subscription.status === SubscriptionStatus.INACTIVE; return (
diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx new file mode 100644 index 000000000..bdc769e79 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; +import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; +import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EditTemplateFormProps = { + className?: string; + user: User; + template: Template; + recipients: Recipient[]; + fields: Field[]; + documentData: DocumentData; +}; + +type EditTemplateStep = 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; + +export const EditTemplateForm = ({ + className, + template, + recipients, + fields, + user: _user, + documentData, +}: EditTemplateFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const [step, setStep] = useState('signers'); + + const documentFlow: Record = { + signers: { + title: 'Add Placeholders', + description: 'Add all relevant placeholders for each recipient.', + stepIndex: 1, + }, + fields: { + title: 'Add Fields', + description: 'Add all relevant fields for each recipient.', + stepIndex: 2, + }, + }; + + const currentDocumentFlow = documentFlow[step]; + + const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation(); + const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation(); + + const onAddTemplatePlaceholderFormSubmit = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + await addTemplateSigners({ + templateId: template.id, + signers: data.signers, + }); + + router.refresh(); + + setStep('fields'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { + try { + await addTemplateFields({ + templateId: template.id, + fields: data.fields, + }); + + toast({ + title: 'Template saved', + description: 'Your templates has been saved successfully.', + duration: 5000, + }); + + router.push('/templates'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + + + + +
+ e.preventDefault()} + > + + + setStep(EditTemplateSteps[step - 1])} + > + + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx new file mode 100644 index 000000000..6d234eff2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; + +import { TemplateType } from '~/components/formatter/template-type'; + +import { EditTemplateForm } from './edit-template'; + +export type TemplatePageProps = { + params: { + id: string; + }; +}; + +export default async function TemplatePage({ params }: TemplatePageProps) { + const { id } = params; + + const templateId = Number(id); + + if (!templateId || Number.isNaN(templateId)) { + redirect('/documents'); + } + + const { user } = await getRequiredServerComponentSession(); + + const template = await getTemplateById({ + id: templateId, + userId: user.id, + }).catch(() => null); + + if (!template || !template.templateDocumentData) { + redirect('/documents'); + } + + const { templateDocumentData } = template; + + const [templateRecipients, templateFields] = await Promise.all([ + getRecipientsForTemplate({ + templateId, + userId: user.id, + }), + getFieldsForTemplate({ + templateId, + userId: user.id, + }), + ]); + + return ( +
+ + + Templates + + +

+ {template.title} +

+ +
+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx new file mode 100644 index 000000000..9f26d632c --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import type { Template } from '@documenso/prisma/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +import { DeleteTemplateDialog } from './delete-template-dialog'; +import { DuplicateTemplateDialog } from './duplicate-template-dialog'; + +export type DataTableActionDropdownProps = { + row: Template; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const isOwner = row.userId === session.user.id; + + return ( + + + + + + + Action + + + + + Edit + + + + {/* onDuplicateButtonClick(row.id)}> */} + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)}> + + Delete + + + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx new file mode 100644 index 000000000..63d6888b1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader, Plus } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { Template } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; +import { TemplateType } from '~/components/formatter/template-type'; + +import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; + +type TemplatesDataTableProps = { + templates: Template[]; + perPage: number; + page: number; + totalPages: number; +}; + +export const TemplatesDataTable = ({ + templates, + perPage, + page, + totalPages, +}: TemplatesDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const router = useRouter(); + + const { toast } = useToast(); + const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({}); + + const { mutateAsync: createDocumentFromTemplate } = + trpc.template.createDocumentFromTemplate.useMutation(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const onUseButtonClick = async (templateId: number) => { + try { + const { id } = await createDocumentFromTemplate({ + templateId, + }); + + toast({ + title: 'Document created', + description: 'Your document has been created from the template successfully.', + duration: 5000, + }); + + router.push(`/documents/${id}`); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while creating document from template.', + variant: 'destructive', + }); + } + }; + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) => , + }, + { + header: 'Actions', + accessorKey: 'actions', + cell: ({ row }) => { + const isRowLoading = loadingStates[row.original.id]; + + return ( +
+ + +
+ ); + }, + }, + ]} + data={templates} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx new file mode 100644 index 000000000..31e1011be --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Template; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + return ( + + {row.title} + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx new file mode 100644 index 000000000..9075f4677 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -0,0 +1,84 @@ +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 DeleteTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template deleted', + description: 'Your template has been successfully deleted.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDeleteTemplate = async () => { + try { + await deleteTemplate({ id }); + } catch { + toast({ + title: 'Something went wrong', + description: 'This template could not be deleted at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to delete this template? + + + Please note that this action is irreversible. Once confirmed, your template will be + permanently deleted. + + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx new file mode 100644 index 000000000..be743ff48 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -0,0 +1,87 @@ +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 DuplicateTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DuplicateTemplateDialog = ({ + id, + open, + onOpenChange, +}: DuplicateTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: duplicateTemplate, isLoading } = + trpcReact.template.duplicateTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template duplicated', + description: 'Your template has been duplicated successfully.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDuplicate = async () => { + try { + await duplicateTemplate({ + templateId: id, + }); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while duplicating template.', + variant: 'destructive', + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to duplicate this template? + + Your template will be duplicated. + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/empty-state.tsx b/apps/web/src/app/(dashboard)/templates/empty-state.tsx new file mode 100644 index 000000000..b928d8a83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/empty-state.tsx @@ -0,0 +1,17 @@ +import { Bird } from 'lucide-react'; + +export const EmptyTemplateState = () => { + return ( +
+ + +
+

We're all empty

+ +

+ You have not yet created any templates. To create a template please upload one. +

+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx new file mode 100644 index 000000000..a4aa9bce2 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -0,0 +1,228 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { FilePlus, X } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { base64 } from '@documenso/lib/universal/base64'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZCreateTemplateFormSchema = z.object({ + name: z.string(), +}); + +type TCreateTemplateFormSchema = z.infer; + +export const NewTemplateDialog = () => { + const router = useRouter(); + const { data: session } = useSession(); + const { toast } = useToast(); + + const form = useForm({ + defaultValues: { + name: '', + }, + resolver: zodResolver(ZCreateTemplateFormSchema), + }); + + const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = + trpc.template.createTemplate.useMutation(); + + const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); + const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + + const onFileDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); + + setUploadedFile({ + file, + fileBase64: `data:application/pdf;base64,${base64String}`, + }); + + if (!form.getValues('name')) { + form.setValue('name', file.name); + } + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const onSubmit = async (values: TCreateTemplateFormSchema) => { + if (!uploadedFile) { + return; + } + + const file: File = uploadedFile.file; + + try { + const { type, data } = await putFile(file); + + const { id: templateDocumentDataId } = await createDocumentData({ + type, + data, + }); + + const { id } = await createTemplate({ + title: values.name ? values.name : file.name, + templateDocumentDataId, + }); + + toast({ + title: 'Template document uploaded', + description: + 'Your document has been uploaded successfully. You will be redirected to the template page.', + duration: 5000, + }); + + setShowNewTemplateDialog(false); + + void router.push(`/templates/${id}`); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const resetForm = () => { + if (form.getValues('name') === uploadedFile?.file.name) { + form.reset(); + } + + setUploadedFile(null); + }; + + useEffect(() => { + if (!showNewTemplateDialog) { + form.reset(); + } + }, [form, showNewTemplateDialog]); + + return ( + + + + + + + + New Template + + +
+
+ + ( + + Name your template + + + + + + Leave this empty if you would like to use your document's name for the + template + + + + + )} + /> + +
+ + +
+ {uploadedFile ? ( + + + + +
+
+
+
+
+ +

+ Uploaded Document +

+ + + {uploadedFile.file.name} + + + + ) : ( + + )} +
+
+ +
+ +
+ + +
+ +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx new file mode 100644 index 000000000..f4167e42a --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; + +import { TemplatesDataTable } from './data-table-templates'; +import { EmptyTemplateState } from './empty-state'; +import { NewTemplateDialog } from './new-template-dialog'; + +type TemplatesPageProps = { + searchParams?: { + page?: number; + perPage?: number; + }; +}; + +export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + const { user } = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const { templates, totalPages } = await getTemplates({ + userId: user.id, + page: page, + perPage: perPage, + }); + + return ( +
+
+

Templates

+ +
+ +
+
+ +
+ {templates.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 54757667a..4b1aed265 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -15,6 +15,8 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; +import { truncateTitle } from '~/helpers/truncate-title'; + export type CompletedSigningPageProps = { params: { token?: string; @@ -36,6 +38,8 @@ export default async function CompletedSigningPage({ return notFound(); } + const truncatedTitle = truncateTitle(document.title); + const { documentData } = document; const [fields, recipient] = await Promise.all([ @@ -89,7 +93,7 @@ export default async function CompletedSigningPage({

You have signed - "{document.title}" + "{truncatedTitle}"

{match({ status: document.status, deletedAt: document.deletedAt }) diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 9cff29c64..ce34a55fd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; -import { Recipient } from '@documenso/prisma/client'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container'; export type DateFieldProps = { field: FieldWithSignature; recipient: Recipient; + dateFormat?: string | null; + timezone?: string | null; }; -export const DateField = ({ field, recipient }: DateFieldProps) => { +export const DateField = ({ + field, + recipient, + dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, + timezone = DEFAULT_DOCUMENT_TIME_ZONE, +}: DateFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); + + const isDifferentTime = field.inserted && localDateString !== field.customText; + + const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`; + const onSign = async () => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, - value: '', + value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, }); startTransition(() => router.refresh()); @@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { }; return ( - + {isLoading && (
@@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { )} {field.inserted && ( -

{field.customText}

+

{localDateString}

)} ); diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index c8e283ded..4d52ca50a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { }; return ( - + {isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 4d7715f05..350bec9e5 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -49,6 +49,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = const { data: session } = useSession(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const { mutateAsync: completeDocumentWithToken } = @@ -76,6 +77,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = return sortFieldsByPosition(fields.filter((field) => !field.inserted)); }, [fields]); + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(fields); + }; + const onFormSubmit = async () => { setValidateUninsertedFields(true); @@ -120,7 +126,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = disabled={isSubmitting} className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} > -
+

Sign Document

@@ -232,6 +242,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = onSignatureComplete={handleSubmit(onFormSubmit)} document={document} fields={fields} + fieldsValidated={fieldsValidated} />

diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index bbe18fb8a..6e661e77a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; -import { Recipient } from '@documenso/prisma/client'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; @@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { }; return ( - + {isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx index 8c7051caa..39bfba935 100644 --- a/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import Link from 'next/link'; diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 97babb82f..efd0b266c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; @@ -14,6 +17,8 @@ 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 { truncateTitle } from '~/helpers/truncate-title'; + import { DateField } from './date-field'; import { EmailField } from './email-field'; import { SigningForm } from './form'; @@ -42,10 +47,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp viewedDocument({ token }).catch(() => null), ]); + const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); + if (!document || !document.documentData || !recipient) { return notFound(); } + const truncatedTitle = truncateTitle(document.title); + const { documentData } = document; const { user } = await getServerComponentSession(); @@ -77,7 +86,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp >

- {document.title} + {truncatedTitle}

@@ -111,7 +120,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp )) .with(FieldType.DATE, () => ( - + )) .with(FieldType.EMAIL, () => ( diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 8dd9fc6e8..e4d4571fc 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -9,10 +9,13 @@ import { DialogTrigger, } from '@documenso/ui/primitives/dialog'; +import { truncateTitle } from '~/helpers/truncate-title'; + export type SignDialogProps = { isSubmitting: boolean; document: Document; fields: Field[]; + fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; }; @@ -20,30 +23,31 @@ export const SignDialog = ({ isSubmitting, document, fields, + fieldsValidated, onSignatureComplete, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); - + const truncatedTitle = truncateTitle(document.title); const isComplete = fields.every((field) => field.inserted); return ( - +
Sign Document
- You are about to finish signing "{document.title}". Are you sure? + You are about to finish signing "{truncatedTitle}". Are you sure?
diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 6c2c8d6bd..08d077f76 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { }; return ( - + {isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index 485384a3e..b4805fa6b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; export type SignatureFieldProps = { field: FieldWithSignature; @@ -11,6 +12,8 @@ export type SignatureFieldProps = { children: React.ReactNode; onSign?: () => Promise | void; onRemove?: () => Promise | void; + type?: 'Date' | 'Email' | 'Name' | 'Signature'; + tooltipText?: string | null; }; export const SigningFieldContainer = ({ @@ -19,6 +22,8 @@ export const SigningFieldContainer = ({ onSign, onRemove, children, + type, + tooltipText, }: SignatureFieldProps) => { const onSignFieldClick = async () => { if (field.inserted) { @@ -46,7 +51,22 @@ export const SigningFieldContainer = ({ /> )} - {field.inserted && !loading && ( + {type === 'Date' && field.inserted && !loading && ( + + + + + + {tooltipText && {tooltipText}} + + )} + + {type !== 'Date' && field.inserted && !loading && (
- - {/* We have no other subpaths rn */} - {/* - Documents - */}
); }; diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 25f260575..bdae6c511 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return (
5 && 'border-b-border', className, )} @@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { -
+
{/* - + + + ); }; diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 47cba1e88..0eb491537 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -1,23 +1,25 @@ 'use client'; -import { useState } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff, Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { FormErrorMessage } from '../form/form-error-message'; - export const ZPasswordFormSchema = z .object({ currentPassword: z @@ -48,16 +50,7 @@ export type PasswordFormProps = { export const PasswordForm = ({ className }: PasswordFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [showCurrentPassword, setShowCurrentPassword] = useState(false); - - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { currentPassword: '', password: '', @@ -66,6 +59,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { resolver: zodResolver(ZPasswordFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation(); const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { @@ -75,7 +70,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { password, }); - reset(); + form.reset(); toast({ title: 'Password updated', @@ -101,117 +96,61 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { }; return ( -
-
- - -
- + +
+ ( + + Current Password + + + + + + )} /> - -
- - -
-
- - -
- - -
- - -
- -
- - -
- + -
- - -
- -
- -
-
+ + ); }; diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 47adf2615..0ce5c7f3d 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -3,8 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { z } from 'zod'; import type { User } from '@documenso/prisma/client'; @@ -12,13 +11,19 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { FormErrorMessage } from '../form/form-error-message'; - export const ZProfileFormSchema = z.object({ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), signature: z.string().min(1, 'Signature Pad cannot be empty'), @@ -36,12 +41,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const { toast } = useToast(); - const { - register, - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { name: user.name ?? '', signature: user.signature || '', @@ -49,6 +49,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { @@ -84,56 +86,57 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { }; return ( -
-
- - - - - -
- -
- - - -
- -
- - -
- ( - onChange(v ?? '')} - /> + + +
+ ( + + Full Name + + + + + )} /> - -
-
-
- -
-
+ + ); }; diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index 47f423d76..354584f6e 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -1,11 +1,8 @@ 'use client'; -import { useState } from 'react'; - import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -13,9 +10,15 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZResetPasswordFormSchema = z @@ -40,15 +43,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - - const { - register, - reset, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { password: '', repeatedPassword: '', @@ -56,6 +51,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) resolver: zodResolver(ZResetPasswordFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation(); const onFormSubmit = async ({ password }: Omit) => { @@ -65,7 +62,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) token, }); - reset(); + form.reset(); toast({ title: 'Password updated', @@ -93,81 +90,45 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) }; return ( -
-
- - -
- + +
+ ( + + Password + + + + + + )} /> - -
- - -
- -
- - -
- + - -
- - -
- - -
+ + + ); }; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 0d7dd723f..4e671a569 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -12,9 +12,16 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input, PasswordInput } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ERROR_MESSAGES: Partial> = { @@ -52,12 +59,7 @@ export const SignInForm = ({ className }: SignInFormProps) => { 'totp' | 'backup' >('totp'); - const { - register, - handleSubmit, - setValue, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { email: '', password: '', @@ -67,9 +69,11 @@ export const SignInForm = ({ className }: SignInFormProps) => { resolver: zodResolver(ZSignInFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const onCloseTwoFactorAuthenticationDialog = () => { - setValue('totpCode', ''); - setValue('backupCode', ''); + form.setValue('totpCode', ''); + form.setValue('backupCode', ''); setIsTwoFactorAuthenticationDialogOpen(false); }; @@ -78,11 +82,11 @@ export const SignInForm = ({ className }: SignInFormProps) => { const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp'; if (method === 'totp') { - setValue('backupCode', ''); + form.setValue('backupCode', ''); } if (method === 'backup') { - setValue('totpCode', ''); + form.setValue('totpCode', ''); } setTwoFactorAuthenticationMethod(method); @@ -113,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => { if (result?.error && isErrorCode(result.error)) { if (result.error === TwoFactorEnabledErrorCode) { setIsTwoFactorAuthenticationDialogOpen(true); - return; } @@ -156,64 +159,68 @@ export const SignInForm = ({ className }: SignInFormProps) => { }; return ( -
-
- - - - - -
- -
- - - - - -
- - +
+ ( + + Email + + + + + + )} + /> -
-
- Or continue with -
-
+ ( + + Password + + + + + + )} + /> +
- + +
+
+ Or continue with +
+
+ + + { Two-Factor Authentication -
- {twoFactorAuthenticationMethod === 'totp' && ( -
- - - +
+ {twoFactorAuthenticationMethod === 'totp' && ( + ( + + Authentication Token + + + + + + )} /> + )} - -
- )} - - {twoFactorAuthenticationMethod === 'backup' && ( -
- - - ( + + Backup Code + + + + + + )} /> - - -
- )} + )} +
- + ); }; diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 862f4f83e..b91b4a9fd 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -1,11 +1,8 @@ 'use client'; -import { useState } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { signIn } from 'next-auth/react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; @@ -13,9 +10,16 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -38,14 +42,8 @@ export type SignUpFormProps = { export const SignUpForm = ({ className }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); - const [showPassword, setShowPassword] = useState(false); - const { - control, - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { name: '', email: '', @@ -55,6 +53,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { resolver: zodResolver(ZSignUpFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: signup } = trpc.auth.signup.useMutation(); const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { @@ -90,93 +90,83 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { }; return ( -
-
- - - - - {errors.name && {errors.name.message}} -
- -
- - - - - {errors.email && {errors.email.message}} -
- -
- - -
- + +
+ ( + + Name + + + + + + )} /> - -
- -
+ /> -
- + ( + + Password + + + + + + )} + /> -
- ( - onChange(v ?? '')} - /> + + Sign Here + + onChange(v ?? '')} + /> + + + + )} /> -
+ - -
- - -
+ + + ); }; diff --git a/apps/web/src/helpers/truncate-title.ts b/apps/web/src/helpers/truncate-title.ts new file mode 100644 index 000000000..2ad25c39a --- /dev/null +++ b/apps/web/src/helpers/truncate-title.ts @@ -0,0 +1,10 @@ +export const truncateTitle = (title: string, maxLength: number = 16) => { + if (title.length <= maxLength) { + return title; + } + + const start = title.slice(0, maxLength / 2); + const end = title.slice(-maxLength / 2); + + return `${start}.....${end}`; +}; diff --git a/apps/web/src/providers/next-auth.tsx b/apps/web/src/providers/next-auth.tsx index 8f7d099a5..db8676d99 100644 --- a/apps/web/src/providers/next-auth.tsx +++ b/apps/web/src/providers/next-auth.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { Session } from 'next-auth'; +import type { Session } from 'next-auth'; import { SessionProvider } from 'next-auth/react'; export type NextAuthProviderProps = { diff --git a/docker/compose.yml b/docker/compose.yml index b427f419c..9d4f0e951 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -33,7 +33,6 @@ services: - SMTP_MAIL_USER=username - SMTP_MAIL_PASSWORD=password - MAIL_FROM=admin@example.com - - NEXT_PUBLIC_ALLOW_SIGNUP=true ports: - 3000:3000 volumes: diff --git a/lint-staged.config.cjs b/lint-staged.config.cjs index 419fa099a..5d9eea9f9 100644 --- a/lint-staged.config.cjs +++ b/lint-staged.config.cjs @@ -1,6 +1,7 @@ +/** @type {import('lint-staged').Config} */ module.exports = { - '**/*.{ts,tsx,cts,mts}': ['eslint --fix'], - '**/*.{js,jsx,cjs,mjs}': ['prettier --write'], - '**/*.{yml,mdx}': ['prettier --write'], - '**/*/package.json': ['npm run precommit'], + '**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`, + '**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`, + '**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`, + '**/*/package.json': 'npm run precommit', }; diff --git a/package-lock.json b/package-lock.json index 61c4749e6..e3c1139f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "react-hook-form": "^7.43.9", "react-icons": "^4.11.0", "recharts": "^2.7.2", - "sharp": "0.32.5", + "sharp": "0.33.1", "typescript": "5.2.2", "zod": "^3.22.4" }, @@ -71,6 +71,45 @@ "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==", "dev": true }, + "apps/marketing/node_modules/sharp": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", + "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.1", + "@img/sharp-darwin-x64": "0.33.1", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.1", + "@img/sharp-linux-arm64": "0.33.1", + "@img/sharp-linux-s390x": "0.33.1", + "@img/sharp-linux-x64": "0.33.1", + "@img/sharp-linuxmusl-arm64": "0.33.1", + "@img/sharp-linuxmusl-x64": "0.33.1", + "@img/sharp-wasm32": "0.33.1", + "@img/sharp-win32-ia32": "0.33.1", + "@img/sharp-win32-x64": "0.33.1" + } + }, "apps/marketing/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -116,7 +155,7 @@ "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", - "sharp": "0.32.5", + "sharp": "0.33.1", "ts-pattern": "^5.0.5", "typescript": "5.2.2", "uqr": "^0.1.2", @@ -136,6 +175,45 @@ "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==", "dev": true }, + "apps/web/node_modules/sharp": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", + "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.1", + "@img/sharp-darwin-x64": "0.33.1", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.1", + "@img/sharp-linux-arm64": "0.33.1", + "@img/sharp-linux-s390x": "0.33.1", + "@img/sharp-linux-x64": "0.33.1", + "@img/sharp-linuxmusl-arm64": "0.33.1", + "@img/sharp-linuxmusl-x64": "0.33.1", + "@img/sharp-wasm32": "0.33.1", + "@img/sharp-win32-ia32": "0.33.1", + "@img/sharp-win32-x64": "0.33.1" + } + }, "apps/web/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -1889,6 +1967,15 @@ "resolved": "https://registry.npmjs.org/@effect-ts/system/-/system-0.57.5.tgz", "integrity": "sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==" }, + "node_modules/@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -2171,6 +2258,437 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.1.tgz", + "integrity": "sha512-esr2BZ1x0bo+wl7Gx2hjssYhjrhUsD88VQulI0FrG8/otRQUOxLWHMBd1Y1qo2Gfg2KUvXNpT0ASnV9BzJCexw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.1.tgz", + "integrity": "sha512-YrnuB3bXuWdG+hJlXtq7C73lF8ampkhU3tMxg5Hh+E7ikxbUVOU9nlNtVTloDXz6pRHt2y2oKJq7DY/yt+UXYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.1.tgz", + "integrity": "sha512-Ii4X1vnzzI4j0+cucsrYA5ctrzU9ciXERfJR633S2r39CiD8npqH2GMj63uFZRCFt3E687IenAdbwIpQOJ5BNA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.1.tgz", + "integrity": "sha512-59B5GRO2d5N3tIfeGHAbJps7cLpuWEQv/8ySd9109ohQ3kzyCACENkFVAnGPX00HwPTQcaBNF7HQYEfZyZUFfw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.1.tgz", + "integrity": "sha512-tRGrb2pHnFUXpOAj84orYNxHADBDIr0J7rrjwQrTNMQMWA4zy3StKmMvwsI7u3dEZcgwuMMooIIGWEWOjnmG8A==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.1.tgz", + "integrity": "sha512-4y8osC0cAc1TRpy02yn5omBeloZZwS62fPZ0WUAYQiLhSFSpWJfY/gMrzKzLcHB9ulUV6ExFiu2elMaixKDbeg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.1.tgz", + "integrity": "sha512-D3lV6clkqIKUizNS8K6pkuCKNGmWoKlBGh5p0sLO2jQERzbakhu4bVX1Gz+RS4vTZBprKlWaf+/Rdp3ni2jLfA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.1.tgz", + "integrity": "sha512-LOGKNu5w8uu1evVqUAUKTix2sQu1XDRIYbsi5Q0c/SrXhvJ4QyOx+GaajxmOg5PZSsSnCYPSmhjHHsRBx06/wQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.1.tgz", + "integrity": "sha512-vWI/sA+0p+92DLkpAMb5T6I8dg4z2vzCUnp8yvxHlwBpzN8CIcO3xlSXrLltSvK6iMsVMNswAv+ub77rsf25lA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.44.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.1.tgz", + "integrity": "sha512-/xhYkylsKL05R+NXGJc9xr2Tuw6WIVl2lubFJaFYfW4/MQ4J+dgjIo/T4qjNRizrqs/szF/lC9a5+updmY9jaQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.1.tgz", + "integrity": "sha512-XaM69X0n6kTEsp9tVYYLhXdg7Qj32vYJlAKRutxUsm1UlgQNx6BOhHwZPwukCGXBU2+tH87ip2eV1I/E8MQnZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -6101,6 +6619,15 @@ "@types/node": "*" } }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, "node_modules/@types/hast": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.8.tgz", @@ -6138,6 +6665,11 @@ "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.10.tgz", "integrity": "sha512-Rllzc5KHk0Al5/WANwgSPl1/CwjqCy+AZrGd78zuK+jO9aDM6ffblZ+zIjgPNAaEBmlO0RYDvLNh7wD0zKVgEg==" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -6431,6 +6963,11 @@ "crypto-js": "^4.2.0" } }, + "node_modules/@vvo/tzdb": { + "version": "6.117.0", + "resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.117.0.tgz", + "integrity": "sha512-vZkfoag1kHqItK/zebxT0Fkt3R/zscjgD+Ib7kaAdum0Sz9psXDfVHPW1Benv91d02zPWlLIvZtjBmzX4a+6fw==" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -6901,11 +7438,6 @@ "dequal": "^2.0.3" } }, - "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -8350,14 +8882,6 @@ "node": ">=8" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8453,6 +8977,14 @@ "node": ">=6" } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -8461,6 +8993,14 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -9582,22 +10122,6 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-package-json": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.1.5.tgz", - "integrity": "sha512-WEWQHMrKi3XHw5HKsykNO0ui1VQ+Au1H0WcgWU3Kgt/S7yTu9SW5dPUu/pliZ+tbHO0PNWV+tURNkDYL+fxEpA==", - "dependencies": { - "disparity": "^3.0.0", - "package-json-validator": "^0.6.3", - "requireindex": "^1.2.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "peerDependencies": { - "eslint": ">=4.7.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", @@ -9997,14 +10521,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -10039,11 +10555,6 @@ "node": ">=6.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fast-folder-size": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/fast-folder-size/-/fast-folder-size-1.6.1.tgz", @@ -10373,11 +10884,6 @@ "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, "node_modules/fs-extra": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", @@ -10628,6 +11134,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/git-hooks-list": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-1.0.3.tgz", + "integrity": "sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ==", + "funding": { + "url": "https://github.com/fisker/git-hooks-list?sponsor=1" + } + }, "node_modules/git-raw-commits": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", @@ -10647,11 +11161,6 @@ "node": ">=10" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -13739,11 +14248,6 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -13868,11 +14372,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -14023,17 +14522,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-abi": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz", - "integrity": "sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -14986,111 +15474,6 @@ "preact": ">=10" } }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/prebuild-install/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/prebuild-install/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/prebuild-install/node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15385,11 +15768,6 @@ } ] }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, "node_modules/quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -15430,28 +15808,6 @@ "node": ">= 0.8" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/re-resizable": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", @@ -16758,82 +17114,6 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" }, - "node_modules/sharp": { - "version": "0.32.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz", - "integrity": "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sharp/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sharp/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/sharp/node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -16904,7 +17184,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "optional": true }, "node_modules/simple-get": { "version": "3.1.1", @@ -16966,6 +17247,53 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sort-object-keys": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz", + "integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==" + }, + "node_modules/sort-package-json": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-1.57.0.tgz", + "integrity": "sha512-FYsjYn2dHTRb41wqnv+uEqCUvBpK3jZcTp9rbz2qDTmel7Pmdtf+i2rLaaPMRZeSVM60V3Se31GyWFpmKs4Q5Q==", + "dependencies": { + "detect-indent": "^6.0.0", + "detect-newline": "3.1.0", + "git-hooks-list": "1.0.3", + "globby": "10.0.0", + "is-plain-obj": "2.1.0", + "sort-object-keys": "^1.1.3" + }, + "bin": { + "sort-package-json": "cli.js" + } + }, + "node_modules/sort-package-json/node_modules/globby": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.0.tgz", + "integrity": "sha512-3LifW9M4joGZasyYPz2A1U74zbC/45fvpXUvO/9KbSa+VV0aGZarWkfdgKyR9sExNP0t0x0ss/UMJpNpcaTspw==", + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sort-package-json/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -17130,15 +17458,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.15.5", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.5.tgz", - "integrity": "sha512-9thPGMkKC2GctCzyCUjME3yR03x2xNo0GPKGkRw2UMYN+gqWa9uqpyNWhmsNCutU5zHmkUum0LsCRQTXUgUCAg==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -17626,26 +17945,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", - "dependencies": { - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - } - }, - "node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -17990,17 +18289,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/turbo": { "version": "1.10.16", "resolved": "https://registry.npmjs.org/turbo/-/turbo-1.10.16.tgz", @@ -19320,13 +19608,30 @@ "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.1.4", + "eslint-plugin-package-json": "^0.2.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-unused-imports": "^3.0.0", "typescript": "5.2.2" } }, + "packages/eslint-config/node_modules/eslint-plugin-package-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.2.0.tgz", + "integrity": "sha512-JQulhbH8M3gnyEKekqt9+4MKQtK8GRLBQlTvTiqyNSkbF+cDpq6GojCdGN6ov11wE+8iHjZlFDeg8u+gXfjhGA==", + "dependencies": { + "disparity": "^3.2.0", + "package-json-validator": "^0.6.3", + "requireindex": "^1.2.0", + "sort-package-json": "^1.57.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "eslint": ">=4.7.0" + } + }, "packages/eslint-config/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -19359,6 +19664,7 @@ "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", "@upstash/redis": "^1.20.6", + "@vvo/tzdb": "^6.117.0", "bcrypt": "^5.1.0", "luxon": "^3.4.0", "nanoid": "^4.0.2", @@ -19455,7 +19761,7 @@ "license": "MIT", "dependencies": { "autoprefixer": "^10.4.13", - "postcss": "^8.4.21", + "postcss": "^8.4.32", "tailwindcss": "3.3.2", "tailwindcss-animate": "^1.0.5" }, @@ -19463,6 +19769,33 @@ "@tailwindcss/typography": "^0.5.9" } }, + "packages/tailwind-config/node_modules/postcss": { + "version": "8.4.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", + "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "packages/trpc": { "name": "@documenso/trpc", "version": "1.0.0", diff --git a/package.json b/package.json index 780f76793..30076100f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "turbo": "^1.9.3" }, "name": "@documenso/root", - "packageManager": "npm@8.19.2", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 548ad108a..f256c6356 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,10 +1,10 @@ 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 { SubscriptionStatus } from '@documenso/prisma/client'; +import { getPricesByType } from '../stripe/get-prices-by-type'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; @@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { let quota = structuredClone(FREE_PLAN_LIMITS); let remaining = structuredClone(FREE_PLAN_LIMITS); - // Since we store details and allow for past due plans we need to check if the subscription is active. - if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) { - const { product } = await stripe.prices - .retrieve(user.Subscription.priceId, { - expand: ['product'], - }) - .catch((err) => { - console.error(err); - throw err; - }); + const activeSubscriptions = user.Subscription.filter( + ({ status }) => status === SubscriptionStatus.ACTIVE, + ); - if (typeof product === 'string') { - throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED); + if (activeSubscriptions.length > 0) { + const individualPrices = await getPricesByType('individual'); + + for (const subscription of activeSubscriptions) { + const price = individualPrices.find((price) => price.id === subscription.priceId); + if (!price || typeof price.product === 'string' || price.product.deleted) { + continue; + } + + const currentQuota = ZLimitsSchema.parse( + 'metadata' in price.product ? price.product.metadata : {}, + ); + + // Use the subscription with the highest quota. + if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) { + quota = currentQuota; + remaining = structuredClone(quota); + } } - - quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {}); - remaining = structuredClone(quota); } const documents = await prisma.document.count({ diff --git a/packages/ee/server-only/stripe/create-customer.ts b/packages/ee/server-only/stripe/create-customer.ts deleted file mode 100644 index 175298d0b..000000000 --- a/packages/ee/server-only/stripe/create-customer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; -import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; -import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; - -export type CreateCustomerOptions = { - user: User; -}; - -export const createCustomer = async ({ user }: CreateCustomerOptions) => { - const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); - - if (existingSubscription) { - throw new Error('User already has a subscription'); - } - - const customer = await stripe.customers.create({ - name: user.name ?? undefined, - email: user.email, - metadata: { - userId: user.id, - }, - }); - - return await prisma.subscription.create({ - data: { - userId: user.id, - customerId: customer.id, - }, - }); -}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index 11e782966..c85488e6f 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -1,4 +1,8 @@ import { stripe } from '@documenso/lib/server-only/stripe'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; + +import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; export const getStripeCustomerByEmail = async (email: string) => { const foundStripeCustomers = await stripe.customers.list({ @@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => { return null; } }; + +/** + * Get a stripe customer by user. + * + * Will create a Stripe customer and update the relevant user if one does not exist. + */ +export const getStripeCustomerByUser = async (user: User) => { + if (user.customerId) { + const stripeCustomer = await getStripeCustomerById(user.customerId); + + if (!stripeCustomer) { + throw new Error('Missing Stripe customer'); + } + + return { + user, + stripeCustomer, + }; + } + + let stripeCustomer = await getStripeCustomerByEmail(user.email); + + const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted); + + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + name: user.name ?? undefined, + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + customerId: stripeCustomer.id, + }, + }); + + // Sync subscriptions if the customer already exists for back filling the DB + // and local development. + if (isSyncRequired) { + await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => { + console.error(e); + }); + } + + return { + user: updatedUser, + stripeCustomer, + }; +}; + +const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { + const stripeSubscriptions = await stripe.subscriptions.list({ + customer: stripeCustomerId, + }); + + await Promise.all( + stripeSubscriptions.data.map(async (subscription) => + onSubscriptionUpdated({ + userId, + subscription, + }), + ), + ); +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index f621425cc..a5578a813 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -1,4 +1,4 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; @@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; export type PriceIntervals = Record; -export const getPricesByInterval = async () => { +export type GetPricesByIntervalOptions = { + /** + * Filter products by their meta 'type' attribute. + */ + type?: 'individual'; +}; + +export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => { let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], @@ -19,8 +26,10 @@ export const getPricesByInterval = async () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const product = price.product as Stripe.Product; + const filter = !type || product.metadata?.type === type; + // Filter out prices for products that are not active. - return product.active; + return product.active && filter; }); const intervals: PriceIntervals = { diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts new file mode 100644 index 000000000..22124562c --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-type.ts @@ -0,0 +1,11 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getPricesByType = async (type: 'individual') => { + const { data: prices } = await stripe.prices.search({ + query: `metadata['type']:'${type}' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + return prices; +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 3058ed261..047de7962 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -75,23 +75,23 @@ export const stripeWebhookHandler = async ( // Finally, attempt to get the user ID from the subscription within the database. if (!userId && customerId) { - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - userId = result.userId; + userId = result.id; } const subscriptionId = @@ -124,23 +124,23 @@ export const stripeWebhookHandler = async ( ? subscription.customer : subscription.customer.id; - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -182,23 +182,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, @@ -233,23 +233,23 @@ export const stripeWebhookHandler = async ( }); } - const result = await prisma.subscription.findFirst({ + const result = await prisma.user.findFirst({ select: { - userId: true, + id: true, }, where: { customerId, }, }); - if (!result?.userId) { + if (!result?.id) { return res.status(500).json({ success: false, message: 'User not found', }); } - await onSubscriptionUpdated({ userId: result.userId, subscription }); + await onSubscriptionUpdated({ userId: result.id, subscription }); return res.status(200).json({ success: true, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts index 27ff0cf4d..df1b36f55 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts @@ -1,4 +1,4 @@ -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = { }; export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => { - const customerId = - typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id; - await prisma.subscription.update({ where: { - customerId, + planId: subscription.id, }, data: { status: SubscriptionStatus.INACTIVE, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index dfa22d128..d7ce7b062 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -1,6 +1,6 @@ import { match } from 'ts-pattern'; -import { Stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; @@ -13,9 +13,6 @@ 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) @@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({ await prisma.subscription.upsert({ where: { - customerId, + planId: subscription.id, }, create: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), userId, + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, update: { - customerId, status: status, planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), + cancelAtPeriodEnd: subscription.cancel_at_period_end, }, }); }; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index f80719aa1..d519a3362 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -13,7 +13,7 @@ "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.1.4", + "eslint-plugin-package-json": "^0.2.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-unused-imports": "^3.0.0", diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index fd843c6a0..8b5a8a528 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -1,4 +1,5 @@ -import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; +import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; export const getRecipientType = (recipient: Recipient) => { if ( diff --git a/packages/lib/constants/date-formats.ts b/packages/lib/constants/date-formats.ts new file mode 100644 index 000000000..5b36cefdf --- /dev/null +++ b/packages/lib/constants/date-formats.ts @@ -0,0 +1,79 @@ +import { DateTime } from 'luxon'; + +import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones'; + +export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a'; + +export const DATE_FORMATS = [ + { + key: 'yyyy-MM-dd_hh:mm_a', + label: 'YYYY-MM-DD HH:mm a', + value: DEFAULT_DOCUMENT_DATE_FORMAT, + }, + { + key: 'YYYYMMDD', + label: 'YYYY-MM-DD', + value: 'YYYY-MM-DD', + }, + { + key: 'DDMMYYYY', + label: 'DD/MM/YYYY', + value: 'dd/MM/yyyy hh:mm a', + }, + { + key: 'MMDDYYYY', + label: 'MM/DD/YYYY', + value: 'MM/dd/yyyy hh:mm a', + }, + { + key: 'YYYYMMDDHHmm', + label: 'YYYY-MM-DD HH:mm', + value: 'yyyy-MM-dd HH:mm', + }, + { + key: 'YYMMDD', + label: 'YY-MM-DD', + value: 'yy-MM-dd hh:mm a', + }, + { + key: 'YYYYMMDDhhmmss', + label: 'YYYY-MM-DD HH:mm:ss', + value: 'yyyy-MM-dd HH:mm:ss', + }, + { + key: 'MonthDateYear', + label: 'Month Date, Year', + value: 'MMMM dd, yyyy hh:mm a', + }, + { + key: 'DayMonthYear', + label: 'Day, Month Year', + value: 'EEEE, MMMM dd, yyyy hh:mm a', + }, + { + key: 'ISO8601', + label: 'ISO 8601', + value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + }, +]; + +export const convertToLocalSystemFormat = ( + customText: string, + dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT, + timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE, +): string => { + const coalescedDateFormat = dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT; + const coalescedTimeZone = timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE; + + const parsedDate = DateTime.fromFormat(customText, coalescedDateFormat, { + zone: coalescedTimeZone, + }); + + if (!parsedDate.isValid) { + return 'Invalid date'; + } + + const formattedDate = parsedDate.toLocal().toFormat(coalescedDateFormat); + + return formattedDate; +}; diff --git a/packages/lib/constants/keyboard-shortcuts.ts b/packages/lib/constants/keyboard-shortcuts.ts index 896b4abf5..34d3a02e6 100644 --- a/packages/lib/constants/keyboard-shortcuts.ts +++ b/packages/lib/constants/keyboard-shortcuts.ts @@ -1,2 +1,3 @@ export const SETTINGS_PAGE_SHORTCUT = 'N+S'; export const DOCUMENTS_PAGE_SHORTCUT = 'N+D'; +export const TEMPLATES_PAGE_SHORTCUT = 'N+T'; diff --git a/packages/lib/constants/time-zones.ts b/packages/lib/constants/time-zones.ts new file mode 100644 index 000000000..3ef2bdc8b --- /dev/null +++ b/packages/lib/constants/time-zones.ts @@ -0,0 +1,44 @@ +import { rawTimeZones, timeZonesNames } from '@vvo/tzdb'; + +export const TIME_ZONE_DATA = rawTimeZones; + +export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC'; + +export type TimeZone = { + name: string; + rawOffsetInMinutes: number; +}; + +export const minutesToHours = (minutes: number): string => { + const hours = Math.abs(Math.floor(minutes / 60)); + const min = Math.abs(minutes % 60); + const sign = minutes >= 0 ? '+' : '-'; + + return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`; +}; + +const getGMTOffsets = (timezones: TimeZone[]): string[] => { + const gmtOffsets: string[] = []; + + for (const timezone of timezones) { + const offsetValue = minutesToHours(timezone.rawOffsetInMinutes); + const gmtText = `(${offsetValue})`; + + gmtOffsets.push(`${timezone.name} ${gmtText}`); + } + + return gmtOffsets; +}; + +export const splitTimeZone = (input: string | null): string => { + if (input === null) { + return ''; + } + const [timeZone] = input.split('('); + + return timeZone.trim(); +}; + +export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA); + +export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames]; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 6d59b0666..3b9492807 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { return session; }, + + async signIn({ user }) { + // We do this to stop OAuth providers from creating an account + // when signups are disabled + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + const userData = await getUserByEmail({ email: user.email! }); + + return !!userData; + } + + return true; + }, }, }; diff --git a/packages/lib/package.json b/packages/lib/package.json index 41558e2e0..3fd14864e 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -31,6 +31,7 @@ "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", "@upstash/redis": "^1.20.6", + "@vvo/tzdb": "^6.117.0", "bcrypt": "^5.1.0", "luxon": "^3.4.0", "nanoid": "^4.0.2", diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index f24d0b5a2..07368b5a1 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -19,9 +19,11 @@ export const getRecipientsStats = async () => { results.forEach((result) => { const { readStatus, signingStatus, sendStatus, _count } = result; + stats[readStatus] += _count; stats[signingStatus] += _count; stats[sendStatus] += _count; + stats.TOTAL_RECIPIENTS += _count; }); diff --git a/packages/lib/server-only/admin/get-users-stats.ts b/packages/lib/server-only/admin/get-users-stats.ts index 13db21d83..09892171a 100644 --- a/packages/lib/server-only/admin/get-users-stats.ts +++ b/packages/lib/server-only/admin/get-users-stats.ts @@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => { return await prisma.user.count({ where: { Subscription: { - status: SubscriptionStatus.ACTIVE, + some: { + status: SubscriptionStatus.ACTIVE, + }, }, }, }); diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index e3cce2ea2..34c33e7cd 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -6,13 +6,26 @@ export type CreateDocumentMetaOptions = { documentId: number; subject: string; message: string; + timezone: string; + dateFormat: string; + userId: number; }; export const upsertDocumentMeta = async ({ subject, message, + timezone, + dateFormat, documentId, + userId, }: CreateDocumentMetaOptions) => { + await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + userId, + }, + }); + return await prisma.documentMeta.upsert({ where: { documentId, @@ -20,11 +33,15 @@ export const upsertDocumentMeta = async ({ create: { subject, message, + dateFormat, + timezone, documentId, }, update: { subject, message, + dateFormat, + timezone, }, }); }; diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 5d3bb9f9c..5986b4cfe 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -25,6 +25,8 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI select: { message: true, subject: true, + dateFormat: true, + timezone: true, }, }, }, diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index a27458a55..18600ebe6 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import type { FindResultSet } from '../../types/find-result-set'; -export interface FindDocumentsOptions { +export type FindDocumentsOptions = { userId: number; term?: string; status?: ExtendedDocumentStatus; @@ -19,7 +19,7 @@ export interface FindDocumentsOptions { direction: 'asc' | 'desc'; }; period?: '' | '7d' | '14d' | '30d'; -} +}; export const findDocuments = async ({ userId, diff --git a/packages/lib/server-only/document/get-document-meta-by-document-id.ts b/packages/lib/server-only/document/get-document-meta-by-document-id.ts new file mode 100644 index 000000000..575ba5d6e --- /dev/null +++ b/packages/lib/server-only/document/get-document-meta-by-document-id.ts @@ -0,0 +1,13 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetDocumentMetaByDocumentIdOptions { + id: number; +} + +export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => { + return await prisma.documentMeta.findFirstOrThrow({ + where: { + documentId: id, + }, + }); +}; diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index 7793c990a..5ad686860 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -1,6 +1,6 @@ 'use server'; -import { Prisma } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; diff --git a/packages/lib/server-only/field/get-fields-for-template.ts b/packages/lib/server-only/field/get-fields-for-template.ts new file mode 100644 index 000000000..c174d7eff --- /dev/null +++ b/packages/lib/server-only/field/get-fields-for-template.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetFieldsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => { + const fields = await prisma.field.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return fields; +}; diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 4a28e7627..ee472ec9f 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 664be3b91..bd14d49b2 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; -import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import type { FieldType } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; export interface SetFieldsForDocumentOptions { userId: number; diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts new file mode 100644 index 000000000..9431666bf --- /dev/null +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -0,0 +1,118 @@ +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; + +export type Field = { + id?: number | null; + type: FieldType; + signerEmail: string; + signerId?: number; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +}; + +export type SetFieldsForTemplateOptions = { + userId: number; + templateId: number; + fields: Field[]; +}; + +export const setFieldsForTemplate = async ({ + userId, + templateId, + fields, +}: SetFieldsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Template not found'); + } + + const existingFields = await prisma.field.findMany({ + where: { + templateId, + }, + include: { + Recipient: true, + }, + }); + + const removedFields = existingFields.filter( + (existingField) => + !fields.find( + (field) => + field.id === existingField.id || field.signerEmail === existingField.Recipient?.email, + ), + ); + + const linkedFields = fields.map((field) => { + const existing = existingFields.find((existingField) => existingField.id === field.id); + + return { + ...field, + _persisted: existing, + }; + }); + + const persistedFields = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedFields.map((field) => + prisma.field.upsert({ + where: { + id: field._persisted?.id ?? -1, + templateId, + }, + update: { + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + }, + create: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + customText: '', + inserted: false, + Template: { + connect: { + id: templateId, + }, + }, + Recipient: { + connect: { + templateId_email: { + templateId, + email: field.signerEmail.toLowerCase(), + }, + }, + }, + }, + }), + ), + ); + + if (removedFields.length > 0) { + await prisma.field.deleteMany({ + where: { + id: { + in: removedFields.map((field) => field.id), + }, + }, + }); + } + + return persistedFields; +}; diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 6640a6a07..62deccd5a 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -5,6 +5,9 @@ import { DateTime } from 'luxon'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; + export type SignFieldWithTokenOptions = { token: string; fieldId: number; @@ -33,6 +36,10 @@ export const signFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } @@ -54,6 +61,12 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } + const documentMeta = await prisma.documentMeta.findFirst({ + where: { + documentId: document.id, + }, + }); + const isSignatureField = field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE; @@ -63,7 +76,9 @@ export const signFieldWithToken = async ({ const typedSignature = isSignatureField && !isBase64 ? value : undefined; if (field.type === FieldType.DATE) { - customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a'); + customText = DateTime.now() + .setZone(documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE) + .toFormat(documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT); } if (isSignatureField && !signatureImageAsBase64 && !typedSignature) { diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts new file mode 100644 index 000000000..ab6f860eb --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -0,0 +1,25 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetRecipientsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getRecipientsForTemplate = async ({ + templateId, + userId, +}: GetRecipientsForTemplateOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return recipients; +}; diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts new file mode 100644 index 000000000..c21c8cbf9 --- /dev/null +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -0,0 +1,97 @@ +import { prisma } from '@documenso/prisma'; + +import { nanoid } from '../../universal/id'; + +export type SetRecipientsForTemplateOptions = { + userId: number; + templateId: number; + recipients: { + id?: number; + email: string; + name: string; + }[]; +}; + +export const setRecipientsForTemplate = async ({ + userId, + templateId, + recipients, +}: SetRecipientsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Template not found'); + } + + const normalizedRecipients = recipients.map((recipient) => ({ + ...recipient, + email: recipient.email.toLowerCase(), + })); + + const existingRecipients = await prisma.recipient.findMany({ + where: { + templateId, + }, + }); + + const removedRecipients = existingRecipients.filter( + (existingRecipient) => + !normalizedRecipients.find( + (recipient) => + recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, + ), + ); + + const linkedRecipients = normalizedRecipients.map((recipient) => { + const existing = existingRecipients.find( + (existingRecipient) => + existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + ); + + return { + ...recipient, + _persisted: existing, + }; + }); + + const persistedRecipients = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedRecipients.map((recipient) => + prisma.recipient.upsert({ + where: { + id: recipient._persisted?.id ?? -1, + templateId, + }, + update: { + name: recipient.name, + email: recipient.email, + templateId, + }, + create: { + name: recipient.name, + email: recipient.email, + token: nanoid(), + templateId, + }, + }), + ), + ); + + if (removedRecipients.length > 0) { + await prisma.recipient.deleteMany({ + where: { + id: { + in: removedRecipients.map((recipient) => recipient.id), + }, + }, + }); + } + + return persistedRecipients; +}; diff --git a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts b/packages/lib/server-only/subscription/get-subscription-by-user-id.ts deleted file mode 100644 index 772134f7c..000000000 --- a/packages/lib/server-only/subscription/get-subscription-by-user-id.ts +++ /dev/null @@ -1,15 +0,0 @@ -'use server'; - -import { prisma } from '@documenso/prisma'; - -export type GetSubscriptionByUserIdOptions = { - userId: number; -}; - -export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { - return await prisma.subscription.findFirst({ - where: { - userId, - }, - }); -}; diff --git a/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts new file mode 100644 index 000000000..33f6255bd --- /dev/null +++ b/packages/lib/server-only/subscription/get-subscriptions-by-user-id.ts @@ -0,0 +1,15 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type GetSubscriptionsByUserIdOptions = { + userId: number; +}; + +export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => { + return await prisma.subscription.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts new file mode 100644 index 000000000..1c23d8f85 --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -0,0 +1,75 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & { + userId: number; +}; + +export const createDocumentFromTemplate = async ({ + templateId, + userId, +}: CreateDocumentFromTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const document = await prisma.document.create({ + data: { + userId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + })), + }, + }, + + include: { + Recipient: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + documentId: document.id, + recipientId: documentRecipient?.id || null, + }; + }), + }); + + return document; +}; diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts new file mode 100644 index 000000000..d00526a64 --- /dev/null +++ b/packages/lib/server-only/template/create-template.ts @@ -0,0 +1,20 @@ +import { prisma } from '@documenso/prisma'; +import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateTemplateOptions = TCreateTemplateMutationSchema & { + userId: number; +}; + +export const createTemplate = async ({ + title, + userId, + templateDocumentDataId, +}: CreateTemplateOptions) => { + return await prisma.template.create({ + data: { + title, + userId, + templateDocumentDataId, + }, + }); +}; diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts new file mode 100644 index 000000000..f693bcec0 --- /dev/null +++ b/packages/lib/server-only/template/delete-template.ts @@ -0,0 +1,12 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type DeleteTemplateOptions = { + id: number; + userId: number; +}; + +export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => { + return await prisma.template.delete({ where: { id, userId } }); +}; diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts new file mode 100644 index 000000000..6078a1945 --- /dev/null +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -0,0 +1,74 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & { + userId: number; +}; + +export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const duplicatedTemplate = await prisma.template.create({ + data: { + userId, + title: template.title + ' (copy)', + templateDocumentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + })), + }, + }, + + include: { + Recipient: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const duplicatedTemplateRecipient = duplicatedTemplate.Recipient.find( + (doc) => doc.email === recipient?.email, + ); + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + templateId: duplicatedTemplate.id, + recipientId: duplicatedTemplateRecipient?.id || null, + }; + }), + }); + + return duplicatedTemplate; +}; diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts new file mode 100644 index 000000000..56f959a9b --- /dev/null +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -0,0 +1,18 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetTemplateByIdOptions { + id: number; + userId: number; +} + +export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => { + return await prisma.template.findFirstOrThrow({ + where: { + id, + userId, + }, + include: { + templateDocumentData: true, + }, + }); +}; diff --git a/packages/lib/server-only/template/get-templates.ts b/packages/lib/server-only/template/get-templates.ts new file mode 100644 index 000000000..5f802d278 --- /dev/null +++ b/packages/lib/server-only/template/get-templates.ts @@ -0,0 +1,35 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTemplatesOptions = { + userId: number; + page: number; + perPage: number; +}; + +export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => { + const [templates, count] = await Promise.all([ + prisma.template.findMany({ + where: { + userId, + }, + include: { + templateDocumentData: true, + Field: true, + }, + skip: Math.max(page - 1, 0) * perPage, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.template.count({ + where: { + userId, + }, + }), + ]); + + return { + templates, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index 46c43b93b..f7db60c85 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,9 +1,11 @@ import { hash } from 'bcrypt'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { prisma } from '@documenso/prisma'; import { IdentityProvider } from '@documenso/prisma/client'; import { SALT_ROUNDS } from '../../constants/auth'; +import { getFlag } from '../../universal/get-feature-flag'; export interface CreateUserOptions { name: string; @@ -13,6 +15,8 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { + const isBillingEnabled = await getFlag('app_billing'); + const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - return await prisma.user.create({ + let user = await prisma.user.create({ data: { name, email: email.toLowerCase(), @@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse identityProvider: IdentityProvider.DOCUMENSO, }, }); + + if (isBillingEnabled) { + try { + const stripeSession = await getStripeCustomerByUser(user); + user = stripeSession.user; + } catch (e) { + console.error(e); + } + } + + return user; }; diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts index da404830b..2e2bace3b 100644 --- a/packages/lib/utils/recipient-formatter.ts +++ b/packages/lib/utils/recipient-formatter.ts @@ -1,4 +1,4 @@ -import { Recipient } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; export const recipientInitials = (text: string) => text diff --git a/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql new file mode 100644 index 000000000..931815bf0 --- /dev/null +++ b/packages/prisma/migrations/20231206073509_add_multple_subscriptions/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + - Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + +*/ +-- Custom migration statement +DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL; + +-- DropIndex +DROP INDEX "Subscription_customerId_key"; + +-- DropIndex +DROP INDEX "Subscription_userId_key"; + +-- AlterTable +ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL, +ALTER COLUMN "priceId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "customerId" TEXT; +ALTER TABLE "Subscription" DROP COLUMN "customerId"; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId"); diff --git a/packages/prisma/migrations/20231207134820_add_document_meta_dateformat_timezone/migration.sql b/packages/prisma/migrations/20231207134820_add_document_meta_dateformat_timezone/migration.sql new file mode 100644 index 000000000..ed099417d --- /dev/null +++ b/packages/prisma/migrations/20231207134820_add_document_meta_dateformat_timezone/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a', +ADD COLUMN "timezone" TEXT DEFAULT 'Etc/UTC'; diff --git a/packages/prisma/migrations/20231221101005_add_templates/migration.sql b/packages/prisma/migrations/20231221101005_add_templates/migration.sql new file mode 100644 index 000000000..21b0a2918 --- /dev/null +++ b/packages/prisma/migrations/20231221101005_add_templates/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- DropForeignKey +ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; + +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" SERIAL NOT NULL, + "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', + "title" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "templateDocumentDataId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); + +-- CreateIndex +CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); + +-- CreateIndex +CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); + +-- AddForeignKey +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75c175adc..f0bfc6fda 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -21,6 +21,7 @@ enum Role { model User { id Int @id @default(autoincrement()) name String? + customerId String? @unique email String @unique emailVerified DateTime? password String? @@ -34,12 +35,13 @@ model User { accounts Account[] sessions Session[] Document Document[] - Subscription Subscription? + Subscription Subscription[] PasswordResetToken PasswordResetToken[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? VerificationToken VerificationToken[] + Template Template[] @@index([email]) } @@ -72,18 +74,16 @@ enum SubscriptionStatus { model Subscription { id Int @id @default(autoincrement()) status SubscriptionStatus @default(INACTIVE) - planId String? - priceId String? - customerId String + planId String @unique + priceId String periodEnd DateTime? - userId Int @unique + userId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) User User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([customerId]) @@index([userId]) } @@ -154,12 +154,15 @@ model DocumentData { data String initialData String Document Document? + Template Template? } model DocumentMeta { id String @id @default(cuid()) subject String? message String? + timezone String? @db.Text @default("Etc/UTC") + dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) } @@ -180,22 +183,26 @@ enum SigningStatus { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) token String expired DateTime? signedAt DateTime? readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT) - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] @@unique([documentId, email]) + @@unique([templateId, email]) @@index([documentId]) + @@index([templateId]) @@index([token]) } @@ -210,7 +217,8 @@ enum FieldType { model Field { id Int @id @default(autoincrement()) - documentId Int + documentId Int? + templateId Int? recipientId Int? type FieldType page Int @@ -220,11 +228,13 @@ model Field { height Decimal @default(-1) customText String inserted Boolean - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Recipient Recipient? @relation(fields: [recipientId], references: [id]) Signature Signature? @@index([documentId]) + @@index([templateId]) @@index([recipientId]) } @@ -254,3 +264,25 @@ model DocumentShareLink { @@unique([documentId, email]) } + +enum TemplateType { + PUBLIC + PRIVATE +} + +model Template { + id Int @id @default(autoincrement()) + type TemplateType @default(PRIVATE) + title String + userId Int + templateDocumentDataId String + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + Recipient Recipient[] + Field Field[] + + @@unique([templateDocumentDataId]) +} diff --git a/packages/prisma/types/document-with-data.ts b/packages/prisma/types/document-with-data.ts index d8dd8a888..461d13e6c 100644 --- a/packages/prisma/types/document-with-data.ts +++ b/packages/prisma/types/document-with-data.ts @@ -1,4 +1,4 @@ -import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; +import type { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; export type DocumentWithData = Document & { documentData?: DocumentData | null; diff --git a/packages/prisma/types/document-with-recipient.ts b/packages/prisma/types/document-with-recipient.ts index 1db025279..c55b99e67 100644 --- a/packages/prisma/types/document-with-recipient.ts +++ b/packages/prisma/types/document-with-recipient.ts @@ -1,4 +1,4 @@ -import { Document, DocumentData, Recipient } from '@documenso/prisma/client'; +import type { Document, DocumentData, Recipient } from '@documenso/prisma/client'; export type DocumentWithRecipients = Document & { Recipient: Recipient[]; diff --git a/packages/prisma/types/field-with-signature.ts b/packages/prisma/types/field-with-signature.ts index a3f6d845c..c215a3fb0 100644 --- a/packages/prisma/types/field-with-signature.ts +++ b/packages/prisma/types/field-with-signature.ts @@ -1,4 +1,4 @@ -import { Field, Signature } from '@documenso/prisma/client'; +import type { Field, Signature } from '@documenso/prisma/client'; export type FieldWithSignature = Field & { Signature?: Signature | null; diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index af96dc595..d6827955f 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "autoprefixer": "^10.4.13", - "postcss": "^8.4.21", + "postcss": "^8.4.32", "tailwindcss": "3.3.2", "tailwindcss-animate": "^1.0.5" }, diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 59c51ade5..24dd272ee 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; export const authRouter = router({ signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => { try { + if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Signups are disabled.', + }); + } + const { name, email, password, signature } = input; const user = await createUser({ name, email, password, signature }); diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index fc6ea2377..b4a1b60e3 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -179,13 +179,16 @@ export const documentRouter = router({ .input(ZSendDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { documentId, email } = input; + const { documentId, meta } = input; - if (email.message || email.subject) { + if (meta.message || meta.subject || meta.timezone || meta.dateFormat) { await upsertDocumentMeta({ documentId, - subject: email.subject, - message: email.message, + subject: meta.subject, + message: meta.message, + dateFormat: meta.dateFormat, + timezone: meta.timezone, + userId: ctx.user.id, }); } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 71ee9766d..4559f65f3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -65,9 +65,11 @@ export type TSetFieldsForDocumentMutationSchema = z.infer< export const ZSendDocumentMutationSchema = z.object({ documentId: z.number(), - email: z.object({ + meta: z.object({ subject: z.string(), message: z.string(), + timezone: z.string(), + dateFormat: z.string(), }), }); diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 7d049df0d..07cdcd347 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -2,11 +2,13 @@ import { TRPCError } from '@trpc/server'; import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZAddFieldsMutationSchema, + ZAddTemplateFieldsMutationSchema, ZRemovedSignedFieldWithTokenMutationSchema, ZSignFieldWithTokenMutationSchema, } from './schema'; @@ -42,6 +44,27 @@ export const fieldRouter = router({ } }), + addTemplateFields: authenticatedProcedure + .input(ZAddTemplateFieldsMutationSchema) + .mutation(async ({ input, ctx }) => { + const { templateId, fields } = input; + + await setFieldsForTemplate({ + userId: ctx.user.id, + templateId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); + }), + signFieldWithToken: procedure .input(ZSignFieldWithTokenMutationSchema) .mutation(async ({ input }) => { diff --git a/packages/trpc/server/field-router/schema.ts b/packages/trpc/server/field-router/schema.ts index d9f207adb..9bd576667 100644 --- a/packages/trpc/server/field-router/schema.ts +++ b/packages/trpc/server/field-router/schema.ts @@ -21,6 +21,25 @@ export const ZAddFieldsMutationSchema = z.object({ export type TAddFieldsMutationSchema = z.infer; +export const ZAddTemplateFieldsMutationSchema = z.object({ + templateId: z.number(), + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddTemplateFieldsMutationSchema = z.infer; + export const ZSignFieldWithTokenMutationSchema = z.object({ token: z.string(), fieldId: z.number(), diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 913749dde..09097895c 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -2,9 +2,14 @@ import { TRPCError } from '@trpc/server'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template'; import { authenticatedProcedure, procedure, router } from '../trpc'; -import { ZAddSignersMutationSchema, ZCompleteDocumentWithTokenMutationSchema } from './schema'; +import { + ZAddSignersMutationSchema, + ZAddTemplateSignersMutationSchema, + ZCompleteDocumentWithTokenMutationSchema, +} from './schema'; export const recipientRouter = router({ addSigners: authenticatedProcedure @@ -32,6 +37,31 @@ export const recipientRouter = router({ } }), + addTemplateSigners: authenticatedProcedure + .input(ZAddTemplateSignersMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, signers } = input; + + return await setRecipientsForTemplate({ + userId: ctx.user.id, + templateId, + recipients: signers.map((signer) => ({ + id: signer.nativeId, + email: signer.email, + name: signer.name, + })), + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to sign this field. Please try again later.', + }); + } + }), + completeDocumentWithToken: procedure .input(ZCompleteDocumentWithTokenMutationSchema) .mutation(async ({ input }) => { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index ca177a3d5..8920e7672 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -23,6 +23,29 @@ export const ZAddSignersMutationSchema = z export type TAddSignersMutationSchema = z.infer; +export const ZAddTemplateSignersMutationSchema = z + .object({ + templateId: z.number(), + signers: z.array( + z.object({ + nativeId: z.number().optional(), + email: z.string().email().min(1), + name: z.string(), + }), + ), + }) + .refine( + (schema) => { + const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Signers must have unique emails', path: ['signers__root'] }, + ); + +export type TAddTemplateSignersMutationSchema = z.infer; + export const ZCompleteDocumentWithTokenMutationSchema = z.object({ token: z.string(), documentId: z.number(), diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index bf8a03ce1..77d18e06d 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -19,6 +20,7 @@ export const appRouter = router({ shareLink: shareLinkRouter, singleplayer: singleplayerRouter, twoFactorAuthentication: twoFactorAuthenticationRouter, + template: templateRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 65888c835..8e2266fcc 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -63,6 +63,7 @@ export const singleplayerRouter = router({ // Dummy data. id: -1, documentId: -1, + templateId: null, recipientId: -1, }); } diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts new file mode 100644 index 000000000..e18f4cb4a --- /dev/null +++ b/packages/trpc/server/template-router/router.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; + +import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; +import { createTemplate } from '@documenso/lib/server-only/template/create-template'; +import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZCreateDocumentFromTemplateMutationSchema, + ZCreateTemplateMutationSchema, + ZDeleteTemplateMutationSchema, + ZDuplicateTemplateMutationSchema, +} from './schema'; + +export const templateRouter = router({ + createTemplate: authenticatedProcedure + .input(ZCreateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { title, templateDocumentDataId } = input; + + return await createTemplate({ + title, + userId: ctx.user.id, + templateDocumentDataId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this template. Please try again later.', + }); + } + }), + + createDocumentFromTemplate: authenticatedProcedure + .input(ZCreateDocumentFromTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await createDocumentFromTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this document. Please try again later.', + }); + } + }), + + duplicateTemplate: authenticatedProcedure + .input(ZDuplicateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await duplicateTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to duplicate the template. Please try again later.', + }); + } + }), + + deleteTemplate: authenticatedProcedure + .input(ZDeleteTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + const userId = ctx.user.id; + + return await deleteTemplate({ id, userId }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete this template. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts new file mode 100644 index 000000000..bc7161f74 --- /dev/null +++ b/packages/trpc/server/template-router/schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZCreateTemplateMutationSchema = z.object({ + title: z.string().min(1), + templateDocumentDataId: z.string().min(1), +}); + +export const ZCreateDocumentFromTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDuplicateTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDeleteTemplateMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TCreateTemplateMutationSchema = z.infer; +export type TCreateDocumentFromTemplateMutationSchema = z.infer< + typeof ZCreateDocumentFromTemplateMutationSchema +>; + +export type TDuplicateTemplateMutationSchema = z.infer; +export type TDeleteTemplateMutationSchema = z.infer; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index a382e3511..9b958a5a5 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -3,7 +3,7 @@ import SuperJSON from 'superjson'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; -import { TrpcContext } from './context'; +import type { TrpcContext } from './context'; const t = initTRPC.context().create({ transformer: SuperJSON, diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 717f13ade..badc05931 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,8 +10,6 @@ declare namespace NodeJS { NEXT_PRIVATE_ENCRYPTION_KEY: string; 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; @@ -55,6 +53,8 @@ declare namespace NodeJS { NEXT_PRIVATE_SMTP_FROM_NAME?: string; NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string; + NEXT_PUBLIC_DISABLE_SIGNUP?: string; + /** * Vercel environment variables */ diff --git a/packages/ui/lib/utils.ts b/packages/ui/lib/utils.ts index e57f98028..568578c5a 100644 --- a/packages/ui/lib/utils.ts +++ b/packages/ui/lib/utils.ts @@ -1,4 +1,5 @@ -import { ClassValue, clsx } from 'clsx'; +import type { ClassValue } from 'clsx'; +import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 31df69dee..5754b35a5 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { Loader } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -63,8 +64,8 @@ const Button = React.forwardRef( ); } - const showLoader = loading === true; - const isDisabled = props.disabled || showLoader; + const isLoading = loading === true; + const isDisabled = props.disabled || isLoading; return ( ); diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx index 85f86056d..9ba113b67 100644 --- a/packages/ui/primitives/combobox.tsx +++ b/packages/ui/primitives/combobox.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; -import { Check, ChevronsUpDown } from 'lucide-react'; - -import { Role } from '@documenso/prisma/client'; +import { Check, ChevronDown } from 'lucide-react'; import { cn } from '../lib/utils'; import { Button } from './button'; @@ -10,34 +8,31 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from ' import { Popover, PopoverContent, PopoverTrigger } from './popover'; type ComboboxProps = { - listValues: string[]; - onChange: (_values: string[]) => void; + className?: string; + options: string[]; + value: string | null; + onChange: (_value: string | null) => void; + placeholder?: string; + disabled?: boolean; }; -const Combobox = ({ listValues, onChange }: ComboboxProps) => { +const Combobox = ({ + className, + options, + value, + onChange, + disabled = false, + placeholder, +}: ComboboxProps) => { const [open, setOpen] = React.useState(false); - const [selectedValues, setSelectedValues] = React.useState([]); - const dbRoles = Object.values(Role); - React.useEffect(() => { - setSelectedValues(listValues); - }, [listValues]); - - const allRoles = [...new Set([...dbRoles, ...selectedValues])]; - - const handleSelect = (currentValue: string) => { - let newSelectedValues; - if (selectedValues.includes(currentValue)) { - newSelectedValues = selectedValues.filter((value) => value !== currentValue); - } else { - newSelectedValues = [...selectedValues, currentValue]; - } - - setSelectedValues(newSelectedValues); - onChange(newSelectedValues); + const onOptionSelected = (newValue: string) => { + onChange(newValue === value ? null : newValue); setOpen(false); }; + const placeholderValue = placeholder ?? 'Select an option'; + return ( @@ -45,26 +40,28 @@ const Combobox = ({ listValues, onChange }: ComboboxProps) => { variant="outline" role="combobox" aria-expanded={open} - className="w-[200px] justify-between" + className={cn('my-2 w-full justify-between', className)} + disabled={disabled} > - {selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'} - + {value ? value : placeholderValue} + - + + - + + No value found. - - {allRoles.map((value: string, i: number) => ( - handleSelect(value)}> + + + {options.map((option, index) => ( + onOptionSelected(option)}> - {value} + + {option} ))} diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 67cd3f487..cbc306c66 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; -import { DialogProps } from '@radix-ui/react-dialog'; +import type { DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; import { Search } from 'lucide-react'; diff --git a/packages/ui/primitives/constants.ts b/packages/ui/primitives/constants.ts new file mode 100644 index 000000000..9771eb35a --- /dev/null +++ b/packages/ui/primitives/constants.ts @@ -0,0 +1,5 @@ +export const THEMES_TYPE = { + DARK: 'dark', + LIGHT: 'light', + SYSTEM: 'system' +}; \ No newline at end of file diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx index f75e9cdec..47982ab09 100644 --- a/packages/ui/primitives/dialog.tsx +++ b/packages/ui/primitives/dialog.tsx @@ -20,7 +20,7 @@ const DialogPortal = ({ }: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
void | Promise; + type?: 'document' | 'template'; [key: string]: unknown; }; @@ -86,6 +97,8 @@ export const DocumentDropzone = ({ className, onDrop, disabled, + disabledMessage = 'You cannot upload documents at this time.', + type = 'document', ...props }: DocumentDropzoneProps) => { const { getRootProps, getInputProps } = useDropzone({ @@ -104,11 +117,12 @@ export const DocumentDropzone = ({ return ( */}
@@ -136,7 +150,7 @@ export const DocumentDropzone = ({
@@ -157,10 +171,12 @@ export const DocumentDropzone = ({

- Add a document + {DocumentDescription[type].headline}

-

Drag & drop your document here.

+

+ {disabled ? disabledMessage : 'Drag & drop your document here.'} +

diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index e4e5d9253..5accdca16 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -7,11 +7,13 @@ import { DateTime } from 'luxon'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { Field } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { FieldToolTip } from '../../components/field/field-tooltip'; import { cn } from '../../lib/utils'; @@ -34,7 +36,6 @@ import { SinglePlayerModeCustomTextField, SinglePlayerModeSignatureField, } from './single-player-mode-fields'; -import type { DocumentFlowStep } from './types'; export type AddSignatureFormProps = { defaultValues?: TAddSignatureFormSchema; @@ -140,7 +141,7 @@ export const AddSignatureFormPartial = ({ return match(field.type) .with(FieldType.DATE, () => ({ ...field, - customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'), + customText: DateTime.now().toFormat(DEFAULT_DOCUMENT_DATE_FORMAT), inserted: true, })) .with(FieldType.EMAIL, () => ({ diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 881d59c74..8fef8af7b 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -1,11 +1,30 @@ 'use client'; -import { useForm } from 'react-hook-form'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; +import { SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Combobox } from '../combobox'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; @@ -31,20 +50,25 @@ export type AddSubjectFormProps = { export const AddSubjectFormPartial = ({ documentFlow, - recipients: _recipients, - fields: _fields, + recipients: recipients, + fields: fields, document, onSubmit, }: AddSubjectFormProps) => { const { + control, register, handleSubmit, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, touchedFields }, + getValues, + setValue, } = useForm({ defaultValues: { - email: { + meta: { subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', + timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, }, }, }); @@ -52,6 +76,20 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); + const hasDateField = fields.find((field) => field.type === 'DATE'); + + const documentHasBeenSent = recipients.some( + (recipient) => recipient.sendStatus === SendStatus.SENT, + ); + + // We almost always want to set the timezone to the user's local timezone to avoid confusion + // when the document is signed. + useEffect(() => { + if (!touchedFields.meta?.timezone && !documentHasBeenSent) { + setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]); + return ( <> - +
@@ -86,14 +124,12 @@ export const AddSubjectFormPartial = ({ id="message" className="bg-background mt-2 h-32 resize-none" disabled={isSubmitting} - {...register('email.message')} + {...register('meta.message')} />
@@ -123,6 +159,65 @@ export const AddSubjectFormPartial = ({
+ + {hasDateField && ( + + + + Advanced Options + + + +
+ + + ( + + )} + /> +
+ +
+ + + ( + value && onChange(value)} + disabled={documentHasBeenSent} + /> + )} + /> +
+
+
+
+ )}
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts index 33e2dedfb..ea14f4c0f 100644 --- a/packages/ui/primitives/document-flow/add-subject.types.ts +++ b/packages/ui/primitives/document-flow/add-subject.types.ts @@ -1,9 +1,14 @@ import { z } from 'zod'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; + export const ZAddSubjectFormSchema = z.object({ - email: z.object({ + meta: z.object({ subject: z.string(), message: z.string(), + timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), + dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), }), }); diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx index 8c2a9dc7a..afce0d9e0 100644 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ b/packages/ui/primitives/document-flow/add-title.tsx @@ -64,7 +64,7 @@ export const AddTitleFormPartial = ({ diff --git a/packages/ui/primitives/document-flow/document-flow-root.tsx b/packages/ui/primitives/document-flow/document-flow-root.tsx index 42b70c58a..74a232e1d 100644 --- a/packages/ui/primitives/document-flow/document-flow-root.tsx +++ b/packages/ui/primitives/document-flow/document-flow-root.tsx @@ -22,12 +22,12 @@ export const DocumentFlowFormContainer = ({
-
{children}
+
{children}
); }; @@ -63,10 +63,7 @@ export const DocumentFlowFormContainerContent = ({ }: DocumentFlowFormContainerContentProps) => { return (
{children}
diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index 7583bd4b9..716768c18 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -121,6 +121,7 @@ export const FieldItem = ({ diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 677dc931b..82f5706e6 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({ formId: z.string().min(1), nativeId: z.number().optional(), type: z.nativeEnum(FieldType), - signerEmail: z.string().min(1), + signerEmail: z.string().min(1).optional(), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index ac739c984..1a5fba1bb 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; -import { Eye, EyeOff } from 'lucide-react'; - import { cn } from '../lib/utils'; -import { Button } from './button'; export type InputProps = React.InputHTMLAttributes; @@ -28,38 +25,4 @@ const Input = React.forwardRef( Input.displayName = 'Input'; -const PasswordInput = React.forwardRef( - ({ className, ...props }, ref) => { - const [showPassword, setShowPassword] = React.useState(false); - - return ( -
- - - -
- ); - }, -); - -PasswordInput.displayName = 'Input'; - -export { Input, PasswordInput }; +export { Input }; diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/packages/ui/primitives/multiselect-combobox.tsx new file mode 100644 index 000000000..bac87ce0b --- /dev/null +++ b/packages/ui/primitives/multiselect-combobox.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; + +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Role } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type ComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { + const [open, setOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + const dbRoles = Object.values(Role); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allRoles = [...new Set([...dbRoles, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setOpen(false); + }; + + return ( + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; + +export { MultiSelectCombobox }; diff --git a/packages/ui/primitives/password-input.tsx b/packages/ui/primitives/password-input.tsx new file mode 100644 index 000000000..502344a02 --- /dev/null +++ b/packages/ui/primitives/password-input.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { Eye, EyeOff } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Input, InputProps } from './input'; + +const PasswordInput = React.forwardRef>( + ({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ + + +
+ ); + }, +); + +PasswordInput.displayName = 'PasswordInput'; + +export { PasswordInput }; diff --git a/packages/ui/primitives/select.tsx b/packages/ui/primitives/select.tsx index 0d4789550..fba05f7ef 100644 --- a/packages/ui/primitives/select.tsx +++ b/packages/ui/primitives/select.tsx @@ -42,7 +42,7 @@ const SelectContent = React.forwardRef< (null); const [isPressed, setIsPressed] = useState(false); - const [points, setPoints] = useState([]); + const [lines, setLines] = useState([]); + const [currentLine, setCurrentLine] = useState([]); const perfectFreehandOptions = useMemo(() => { const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; @@ -54,26 +56,7 @@ export const SignaturePad = ({ const point = Point.fromEvent(event, DPI, $el.current); - const newPoints = [...points, point]; - - setPoints(newPoints); - - if ($el.current) { - const ctx = $el.current.getContext('2d'); - - if (ctx) { - ctx.save(); - - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)), - ); - - ctx.fill(pathData); - } - } + setCurrentLine([point]); }; const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { @@ -87,31 +70,36 @@ export const SignaturePad = ({ const point = Point.fromEvent(event, DPI, $el.current); - if (point.distanceTo(points[points.length - 1]) > 5) { - const newPoints = [...points, point]; - - setPoints(newPoints); + if (point.distanceTo(currentLine[currentLine.length - 1]) > 5) { + setCurrentLine([...currentLine, point]); + // Update the canvas here to draw the lines if ($el.current) { const ctx = $el.current.getContext('2d'); if (ctx) { ctx.restore(); - ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)), - ); + lines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx.fill(pathData); + }); + + const pathData = new Path2D( + getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)), + ); ctx.fill(pathData); } } } }; - const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => { + const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => { if (event.cancelable) { event.preventDefault(); } @@ -120,15 +108,16 @@ export const SignaturePad = ({ const point = Point.fromEvent(event, DPI, $el.current); - const newPoints = [...points]; + const newLines = [...lines]; - if (addPoint) { - newPoints.push(point); - - setPoints(newPoints); + if (addLine && currentLine.length > 0) { + newLines.push([...currentLine, point]); + setCurrentLine([]); } - if ($el.current && newPoints.length > 0) { + setLines(newLines); + + if ($el.current && newLines.length > 0) { const ctx = $el.current.getContext('2d'); if (ctx) { @@ -137,19 +126,18 @@ export const SignaturePad = ({ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - const pathData = new Path2D( - getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)), - ); + newLines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx.fill(pathData); + }); - ctx.fill(pathData); + onChange?.($el.current.toDataURL()); ctx.save(); } - - onChange?.($el.current.toDataURL()); } - - setPoints([]); }; const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => { @@ -179,7 +167,29 @@ export const SignaturePad = ({ onChange?.(null); - setPoints([]); + setLines([]); + setCurrentLine([]); + }; + + const onUndoClick = () => { + if (lines.length === 0) { + return; + } + + const newLines = [...lines]; + newLines.pop(); // Remove the last line + setLines(newLines); + + // Clear the canvas + if ($el.current) { + const ctx = $el.current.getContext('2d'); + ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + + newLines.forEach((line) => { + const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); + ctx?.fill(pathData); + }); + } }; useEffect(() => { @@ -222,12 +232,26 @@ export const SignaturePad = ({
+ + {lines.length > 0 && ( +
+ +
+ )}
); }; diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx new file mode 100644 index 000000000..bb9c304d9 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Caveat } from 'next/font/google'; + +import { ChevronsUpDown } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { nanoid } from '@documenso/lib/universal/id'; +import type { Field, Recipient } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +import { useStep } from '../stepper'; +// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; + +const fontCaveat = Caveat({ + weight: ['500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-caveat', +}); + +const DEFAULT_HEIGHT_PERCENT = 5; +const DEFAULT_WIDTH_PERCENT = 15; + +const MIN_HEIGHT_PX = 60; +const MIN_WIDTH_PX = 200; + +export type AddTemplateFieldsFormProps = { + documentFlow: DocumentFlowStep; + hideRecipients?: boolean; + recipients: Recipient[]; + fields: Field[]; + onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; +}; + +export const AddTemplateFieldsFormPartial = ({ + documentFlow, + hideRecipients = false, + recipients, + fields, + onSubmit, +}: AddTemplateFieldsFormProps) => { + const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); + + const { currentStep, totalSteps, previousStep } = useStep(); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + fields: fields.map((field) => ({ + nativeId: field.id, + formId: `${field.id}-${field.templateId}`, + pageNumber: field.page, + type: field.type, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + signerId: field.recipientId ?? -1, + signerEmail: + recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + signerToken: + recipients.find((recipient) => recipient.id === field.recipientId)?.token ?? '', + })), + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append, + remove, + update, + fields: localFields, + } = useFieldArray({ + control, + name: 'fields', + }); + + const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); + + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); + const [coords, setCoords] = useState({ + x: 0, + y: 0, + }); + + const fieldBounds = useRef({ + height: 0, + width: 0, + }); + + const onMouseMove = useCallback( + (event: MouseEvent) => { + setIsFieldWithinBounds( + isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ), + ); + + setCoords({ + x: event.clientX - fieldBounds.current.width / 2, + y: event.clientY - fieldBounds.current.height / 2, + }); + }, + [isWithinPageBounds], + ); + + const onMouseClick = useCallback( + (event: MouseEvent) => { + if (!selectedField || !selectedSigner) { + return; + } + + const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); + + if ( + !$page || + !isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ) + ) { + setSelectedField(null); + return; + } + + const { top, left, height, width } = getBoundingClientRect($page); + + const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); + + // Calculate x and y as a percentage of the page width and height + let pageX = ((event.pageX - left) / width) * 100; + let pageY = ((event.pageY - top) / height) * 100; + + // Get the bounds as a percentage of the page width and height + const fieldPageWidth = (fieldBounds.current.width / width) * 100; + const fieldPageHeight = (fieldBounds.current.height / height) * 100; + + // And center it based on the bounds + pageX -= fieldPageWidth / 2; + pageY -= fieldPageHeight / 2; + + append({ + formId: nanoid(12), + type: selectedField, + pageNumber, + pageX, + pageY, + pageWidth: fieldPageWidth, + pageHeight: fieldPageHeight, + signerEmail: selectedSigner.email, + signerId: selectedSigner.id, + signerToken: selectedSigner.token ?? '', + }); + + setIsFieldWithinBounds(false); + setSelectedField(null); + }, + [append, isWithinPageBounds, selectedField, selectedSigner, getPage], + ); + + const onFieldResize = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + pageWidth, + pageHeight, + }); + }, + [getFieldPosition, localFields, update], + ); + + const onFieldMove = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { x: pageX, y: pageY } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + }); + }, + [getFieldPosition, localFields, update], + ); + + useEffect(() => { + if (selectedField) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseClick); + } + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseClick); + }; + }, [onMouseClick, onMouseMove, selectedField]); + + useEffect(() => { + const observer = new MutationObserver((_mutations) => { + const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + fieldBounds.current = { + height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), + width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + }; + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + setSelectedSigner(recipients[0]); + }, [recipients]); + + return ( + <> + +
+ {selectedField && ( + + + {FRIENDLY_FIELD_TYPE[selectedField]} + + + )} + + {localFields.map((field, index) => ( + onFieldResize(options, index)} + onMove={(options) => onFieldMove(options, index)} + onRemove={() => remove(index)} + /> + ))} + + {!hideRecipients && ( + + + + + + + + + + + No recipient matching this description was found. + + + + + {recipients.map((recipient, index) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > + {/* {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} */} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + )} + +
+
+ + + + + + + +
+
+
+
+ + + + + { + previousStep(); + remove(); + }} + onGoNextClick={() => void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-fields.types.ts b/packages/ui/primitives/template-flow/add-template-fields.types.ts new file mode 100644 index 000000000..4406f82a0 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZAddTemplateFieldsFormSchema = z.object({ + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + signerToken: z.string(), + signerId: z.number().optional(), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddTemplateFieldsFormSchema = z.infer; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx new file mode 100644 index 000000000..ebe48b562 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -0,0 +1,193 @@ +'use client'; + +import React, { useId, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Plus, Trash } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; + +import { nanoid } from '@documenso/lib/universal/id'; +import type { Field, Recipient } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; + +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '../document-flow/document-flow-root'; +import type { DocumentFlowStep } from '../document-flow/types'; +import { useStep } from '../stepper'; +import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; +import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; + +export type AddTemplatePlaceholderRecipientsFormProps = { + documentFlow: DocumentFlowStep; + recipients: Recipient[]; + fields: Field[]; + onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; +}; + +export const AddTemplatePlaceholderRecipientsFormPartial = ({ + documentFlow, + recipients, + fields: _fields, + onSubmit, +}: AddTemplatePlaceholderRecipientsFormProps) => { + const initialId = useId(); + const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => + recipients.length > 1 ? recipients.length + 1 : 2, + ); + + const { currentStep, totalSteps, previousStep } = useStep(); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), + defaultValues: { + signers: + recipients.length > 0 + ? recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + })) + : [ + { + formId: initialId, + name: `Recipient 1`, + email: `recipient.1@documenso.com`, + }, + ], + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append: appendSigner, + fields: signers, + remove: removeSigner, + } = useFieldArray({ + control, + name: 'signers', + }); + + const onAddPlaceholderRecipient = () => { + appendSigner({ + formId: nanoid(12), + name: `Recipient ${placeholderRecipientCount}`, + email: `recipient.${placeholderRecipientCount}@documenso.com`, + }); + + setPlaceholderRecipientCount((count) => count + 1); + }; + + const onRemoveSigner = (index: number) => { + removeSigner(index); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddPlaceholderRecipient(); + } + }; + + return ( + <> + +
+ + {signers.map((signer, index) => ( + +
+ + + +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+
+ ))} +
+
+ + + +
+ +
+
+ + + + + 1} + onGoBackClick={() => previousStep()} + onGoNextClick={() => void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts new file mode 100644 index 000000000..780405a0c --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZAddTemplatePlacholderRecipientsFormSchema = z + .object({ + signers: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ), + }) + .refine( + (schema) => { + const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Signers must have unique emails', path: ['signers__root'] }, + ); + +export type TAddTemplatePlacholderRecipientsFormSchema = z.infer< + typeof ZAddTemplatePlacholderRecipientsFormSchema +>; diff --git a/packages/ui/primitives/theme-switcher.tsx b/packages/ui/primitives/theme-switcher.tsx index 7aa570749..ab7a7d2bd 100644 --- a/packages/ui/primitives/theme-switcher.tsx +++ b/packages/ui/primitives/theme-switcher.tsx @@ -4,6 +4,8 @@ import { useTheme } from 'next-themes'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { THEMES_TYPE } from './constants'; + export const ThemeSwitcher = () => { const { theme, setTheme } = useTheme(); const isMounted = useIsMounted(); @@ -12,11 +14,11 @@ export const ThemeSwitcher = () => {