diff --git a/apps/marketing/content/blog/sunsetting-early-adopters.mdx b/apps/marketing/content/blog/sunsetting-early-adopters.mdx new file mode 100644 index 000000000..47958de99 --- /dev/null +++ b/apps/marketing/content/blog/sunsetting-early-adopters.mdx @@ -0,0 +1,49 @@ +--- +title: Sunsetting the Early Adopters Plan +description: We reached or Early Adopter cap and not transition to our regular pricing 🎉 +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-06-12 +tags: + - Early Adopters + - Pricing + - Open Startup +--- + +
+ + +
+ "Being early is, uh, good." -Unknown +
+
+ +> TLDR; The Early Adopters Plan ended, and we have a new pricing. If you are an Early Adopter, reach out for a Discord community badge 🏅 + +# The End of the Beginning +12 months, 13 releases, and 344 merged pull requests after announcing the Early Adopter plan in our first-ever launch week, and we hit the cap of 100. Documenso has changed and grown a lot since then. For us, this is a great milestone towards our broader mission of bringing open signing to the world since now we are joined by a community consisting of contributors and early customers all around the world. + +# The New Plans +Starting today, we are sunsetting the Early Adopter Plan in favor of our new, more nuanced pricing model. The Early Adopter plan will succeeded by the **Individual plan**, which is still priced at $30/mo. The Individual plans will still include unlimited signatures and recipients since this aligns with our core belief of empowering our users wherever possible. If you managed to grab an Early Adopter plan, reach out on X or Discord to receive a special community badge. Early Adopters are meant to get preferential treatment where possible. + +Previously soft-launched as part of Early Adopters, we are officially introducing the **Team Plan** to our pricing for customers requiring multi-user accounts. Priced at $50/ mo. for 5 users, this plan offers unlimited signature volume as well. Additional users can be added for $10/mo. as needed. We have carefully crafted the billing of teams to ensure that dynamic changes are accurately reflected at the end of each billing cycle, providing you with a fair-value pricing structure. + +Our **Free Plan** stays unchanged, offering coverage to casual users and an easy way to try out Documenso or start developing. + +Check out our [new pricing page here](https://documen.so/pricing). We also updated our [open page](https://documen.so/open) to reflect the end of Early Adopters. The metric now counts active subscriptions from Individuals and Teams. + +# API Access +All plans include access to the API as per our philosophy, making Documenso an open platform, and allowing everyone to build on it, no matter how big or small. Besides the Free Plan's 5 signatures per month limit, the API does not have access restrictions. Even the free plan can keep using the API after using its signature volume for non-signing operations like reading, editing, and even creating documents. Since the individual plan technically allows for running a Fortune 500 company for $30/ mo., plan we are adding a fair use clause here: You are free to use the API "a lot" if you are a big organization trying to stay on the Individual Plan we will ask to have a word about upgrading (which might make sense anyway considering your requirements). Fair use excludes Early Adopters, which we consider limitless by any measure. If you need clarification on whether your case is covered under fair use, you can contact us on Discord or support@documenso.com. It's probably fine, though. + +We also have a lot in the pipeline, and we are excited to share everything with you soon. A Big Shoutout to all Early Adopters. We salute you, and you will receive the preferred treatment where possible. + +If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). + +Best from Hamburg\ +Timur \ No newline at end of file diff --git a/apps/marketing/package.json b/apps/marketing/package.json index eb36b71bb..bacad9e0a 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -21,6 +21,9 @@ "@hookform/resolvers": "^3.1.0", "@openstatus/react": "^0.0.3", "contentlayer": "^0.3.4", + "embla-carousel": "^8.1.3", + "embla-carousel-autoplay": "^8.1.3", + "embla-carousel-react": "^8.1.3", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", "luxon": "^3.4.0", diff --git a/apps/marketing/public/blog/sunset.jpg b/apps/marketing/public/blog/sunset.jpg new file mode 100644 index 000000000..f3cf8b42e Binary files /dev/null and b/apps/marketing/public/blog/sunset.jpg differ diff --git a/apps/marketing/public/signing.mp4 b/apps/marketing/public/signing.mp4 new file mode 100644 index 000000000..1687873a7 Binary files /dev/null and b/apps/marketing/public/signing.mp4 differ diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 31990519e..d70b4253d 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -247,8 +247,8 @@ export default async function OpenPage() { data={EARLY_ADOPTERS_DATA} metricKey="earlyAdopters" - title="Early Adopters" - label="Early Adopters" + title="Total Customers" + label="Total Customers" className="col-span-12 lg:col-span-6" extraInfo={} /> diff --git a/apps/marketing/src/app/(marketing)/open/tooltip.tsx b/apps/marketing/src/app/(marketing)/open/tooltip.tsx index d077e7d35..2a77f45a1 100644 --- a/apps/marketing/src/app/(marketing)/open/tooltip.tsx +++ b/apps/marketing/src/app/(marketing)/open/tooltip.tsx @@ -29,7 +29,7 @@ export function OpenPageTooltip() { -

Active Subscriptions.

+

Customers with an Active Subscriptions.

diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index b98460d39..f4103df9c 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -9,6 +9,7 @@ import { } from '@documenso/ui/primitives/accordion'; import { Button } from '@documenso/ui/primitives/button'; +import { Enterprise } from '~/components/(marketing)/enterprise'; import { PricingTable } from '~/components/(marketing)/pricing-table'; export const metadata: Metadata = { @@ -42,6 +43,10 @@ export default function PricingPage() { +
+ +
+

None of these work for you? Try self-hosting! diff --git a/apps/marketing/src/components/(marketing)/callout.tsx b/apps/marketing/src/components/(marketing)/callout.tsx index dfd358c71..5e786abb4 100644 --- a/apps/marketing/src/components/(marketing)/callout.tsx +++ b/apps/marketing/src/components/(marketing)/callout.tsx @@ -34,17 +34,18 @@ export const Callout = ({ starCount }: CalloutProps) => { return (
- + + + { + const slides = SLIDES; + const [_isPlaying, setIsPlaying] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [progress, setProgress] = useState(0); + const videoRefs = useRef<(HTMLVideoElement | null)[]>([]); + const [autoplayDelay, setAutoplayDelay] = useState([]); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [ + Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 }), + ]); + const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel( + { + loop: true, + containScroll: 'keepSnaps', + dragFree: true, + }, + [Autoplay({ playOnInit: true, delay: autoplayDelay[selectedIndex] || 5000 })], + ); + + const onThumbClick = useCallback( + (index: number) => { + if (!emblaApi || !emblaThumbsApi) return; + emblaApi.scrollTo(index); + }, + [emblaApi, emblaThumbsApi], + ); + + const onSelect = useCallback(() => { + if (!emblaApi || !emblaThumbsApi) return; + setSelectedIndex(emblaApi.selectedScrollSnap()); + emblaThumbsApi.scrollTo(emblaApi.selectedScrollSnap()); + + resetProgress(); + const autoplay = emblaApi.plugins()?.autoplay; + + if (autoplay) { + autoplay.reset(); + } + }, [emblaApi, emblaThumbsApi, setSelectedIndex]); + + const resetProgress = useCallback(() => { + setProgress(0); + }, []); + + useEffect(() => { + const setVideoDurations = async () => { + const durations = await Promise.all( + videoRefs.current.map( + async (video) => + new Promise((resolve) => { + if (video) { + video.onloadedmetadata = () => resolve(video.duration * 1000); + } else { + resolve(5000); + } + }), + ), + ); + + setAutoplayDelay(durations); + }; + + void setVideoDurations(); + }, [slides, mounted, resolvedTheme]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const video = entry.target as HTMLVideoElement; + video + .play() + .catch((error) => console.log('Error attempting to play the video:', error)); + } else { + const video = entry.target as HTMLVideoElement; + video.pause(); + } + }); + }, + { + threshold: 0.5, + }, + ); + + videoRefs.current.forEach((video) => { + if (video) { + observer.observe(video); + } + }); + + return () => { + observer.disconnect(); + }; + }, [slides, mounted, resolvedTheme]); + + useEffect(() => { + if (!emblaApi) return; + onSelect(); + + emblaApi.on('select', onSelect).on('reInit', onSelect); + }, [emblaApi, onSelect, mounted, resolvedTheme]); + + useEffect(() => { + const autoplay = emblaApi?.plugins()?.autoplay; + if (!autoplay) return; + + setIsPlaying(autoplay.isPlaying()); + emblaApi + .on('autoplay:play', () => setIsPlaying(true)) + .on('autoplay:stop', () => setIsPlaying(false)) + .on('reInit', () => setIsPlaying(autoplay.isPlaying())); + }, [emblaApi, mounted, resolvedTheme]); + + useEffect(() => { + if (autoplayDelay[selectedIndex] === undefined) return; + + const updateInterval = 50; + const increment = 100 / (autoplayDelay[selectedIndex] / updateInterval); + let progressValue = 0; + + const timer = setInterval(() => { + setProgress((prevProgress) => { + progressValue = prevProgress + increment; + if (progressValue >= 100) { + clearInterval(timer); + if (emblaApi) { + emblaApi.scrollNext(); + } + return 100; + } + return progressValue; + }); + }, updateInterval); + + return () => clearInterval(timer); + }, [selectedIndex, autoplayDelay, emblaApi, mounted, resolvedTheme]); + + useEffect(() => { + if (!emblaApi) return; + + const resetCarousel = () => { + emblaApi.reInit(); + emblaApi.scrollTo(0); + }; + + resetCarousel(); + }, [emblaApi, autoplayDelay, mounted, resolvedTheme]); + + // Ensure the component renders only after mounting to avoid theme issues + if (!mounted) return null; + return ( + <> + +
+
+ {slides.map((slide, index) => ( +
+ {slide.type === 'video' && ( + + )} +
+ ))} +
+
+ +
+ + {selectedIndex + 1}/{slides.length} + + +
+
+ +
+
+ {slides.map((slide, index) => ( + onThumbClick(index)} + selected={index === selectedIndex} + index={index} + label={slide.label} + /> + ))} +
+
+ + ); +}; diff --git a/apps/marketing/src/components/(marketing)/enterprise.tsx b/apps/marketing/src/components/(marketing)/enterprise.tsx new file mode 100644 index 000000000..a9ddd3606 --- /dev/null +++ b/apps/marketing/src/components/(marketing)/enterprise.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Link from 'next/link'; + +import { usePlausible } from 'next-plausible'; + +import { Button } from '@documenso/ui/primitives/button'; + +export const Enterprise = () => { + const event = usePlausible(); + + return ( +
+

+ Enterprise Compliance, License or Technical Needs? +

+ +

+ Our Enterprise License is great large organizations looking to switch to Documenso for all + their signing needs. It's availible for our cloud offering as well as self-hosted setups and + offer a wide range of compliance and Adminstration Features. +

+ +
+ event('enterprise-contact')} + > + + +
+
+ ); +}; diff --git a/apps/marketing/src/components/(marketing)/hero.tsx b/apps/marketing/src/components/(marketing)/hero.tsx index 5809bd695..8af38f1c8 100644 --- a/apps/marketing/src/components/(marketing)/hero.tsx +++ b/apps/marketing/src/components/(marketing)/hero.tsx @@ -14,7 +14,7 @@ import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-fl import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Widget } from './widget'; +import { Carousel } from './carousel'; export type HeroProps = { className?: string; @@ -50,6 +50,21 @@ const HeroTitleVariants: Variants = { }, }; +const HeroCarouselVariants: Variants = { + initial: { + opacity: 0, + y: 60, + }, + animate: { + opacity: 1, + y: 0, + transition: { + delay: 0.5, + duration: 0.8, + }, + }, +}; + export const Hero = ({ className, ...props }: HeroProps) => { const event = usePlausible(); @@ -57,23 +72,6 @@ export const Hero = ({ className, ...props }: HeroProps) => { const heroMarketingCTA = getFlag('marketing_landing_hero_cta'); - const onSignUpClick = () => { - const el = document.getElementById('email'); - - if (el) { - const { top } = el.getBoundingClientRect(); - - window.scrollTo({ - top: top - 120, - behavior: 'smooth', - }); - - requestAnimationFrame(() => { - el.focus(); - }); - } - }; - return (
@@ -108,18 +106,18 @@ export const Hero = ({ className, ...props }: HeroProps) => { animate="animate" className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-4" > - - + + + event('view-github')}>
diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index b34173ba1..b36c4d7bf 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -58,7 +58,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { > Yearly
- Save $60 + Save $60 or $100
{period === 'YEARLY' && ( { data-plan="free" className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg" > -

Free Plan

+

Free

$0

@@ -102,10 +102,10 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

-

Early Adopters

+

Individual

{period === 'MONTHLY' && $30} @@ -114,12 +114,12 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

- For fast-growing companies that aim to scale across multiple teams. + Everything you need for a great signing experience.

-

- - Limited Time Offer: Read More - -

-

Unlimited Teams

-

Unlimited Users

-

Unlimited Documents per month

-

Includes all upcoming features

-

Email, Discord and Slack assistance

+

Unlimited Documents per Month

+

API Accesss

+

Email and Discord Support

+

Premium Profile Name

-

Enterprise

-

Pricing on request

+

Teams

+
+ + {period === 'MONTHLY' && $50} + {period === 'YEARLY' && $500} + +

- For large organizations that need extra flexibility and control. + For companies looking to scale across multiple teams.

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

Everything in Early Adopters, plus:

-

Custom Subdomain

-

Compliance Check

-

Guaranteed Uptime

-

Reporting & Analysis

-

24/7 Support

+

Unlimited Documents per Month

+

API Accesss

+

Email and Discord Support

+

Team Inbox

+

5 Users Included

+

Add More Users for $10/ mo.

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

Sign up to Early Adopter Plan

-

- with Timur Ercan & Lucas Smith from Documenso -

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

- {isValid ? 'Ready for Signing' : `${stepsRemaining} step(s) until signed`} -

- -

Minimise contract

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

- {signatureText} -

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

- non-legally binding, but heartfelt way.

-

You also unlock the option to purchase the early supporter plan including - everything we build this year for fixed price. -
- - - - - - - - -
-
- - ); -}; diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 1a5d2f554..78a192117 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const [{ users, totalPages }, individualPrices] = await Promise.all([ search(searchString, page, perPage), - getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []), + getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []), ]); const individualPriceIds = individualPrices.map((price) => price.id); diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 7865e2b5c..e686256e0 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -39,7 +39,7 @@ export default async function BillingSettingsPage() { const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([ getSubscriptionsByUserId({ userId: user.id }), - getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), + getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }), getPrimaryAccountPlanPrices(), ]); diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx index 8bb3756f4..2ef832dfc 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { Field } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client'; import type { TemplateWithDetails } from '@documenso/prisma/types/template'; @@ -47,6 +48,8 @@ export const DirectTemplatePageView = ({ const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role]; + const directTemplateFlow: Record = { configure: { title: 'General', @@ -54,8 +57,8 @@ export const DirectTemplatePageView = ({ stepIndex: 1, }, sign: { - title: 'Sign document', - description: 'Sign the document to complete the process.', + title: `${recipientRoleDescription.actionVerb} document`, + description: `${recipientRoleDescription.actionVerb} the document to complete the process.`, stepIndex: 2, }, }; diff --git a/package-lock.json b/package-lock.json index ca9ebe172..e10193e66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,9 @@ "@hookform/resolvers": "^3.1.0", "@openstatus/react": "^0.0.3", "contentlayer": "^0.3.4", + "embla-carousel": "^8.1.3", + "embla-carousel-autoplay": "^8.1.3", + "embla-carousel-react": "^8.1.3", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", "luxon": "^3.4.0", @@ -11952,6 +11955,39 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.593.tgz", "integrity": "sha512-c7+Hhj87zWmdpmjDONbvNKNo24tvmD4mjal1+qqTYTrlF0/sNpAcDlU0Ki84ftA/5yj3BF2QhSGEC0Rky6larg==" }, + "node_modules/embla-carousel": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.1.3.tgz", + "integrity": "sha512-GiRpKtzidV3v50oVMly8S+D7iE1r96ttt7fSlvtyKHoSkzrAnVcu8fX3c4j8Ol2hZSQlVfDqDIqdrFPs0u5TWQ==" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.1.3.tgz", + "integrity": "sha512-nMPuOZ+f3yp/RzUEYDOWjO7EkhdNHfdxEoRxfwqIvTGdSQ04LAFAnMLiLWSetAXzB1bP30L391mZb9keZXRcWQ==", + "peerDependencies": { + "embla-carousel": "8.1.3" + } + }, + "node_modules/embla-carousel-react": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.1.3.tgz", + "integrity": "sha512-YrezDPgxPDKa+OKMhSrwuPEU2OgF5147vFW473EWT3bx9DETV3W/RyWTxq0/2pf3M4VXkjqFNbS/W1xM8lTaVg==", + "dependencies": { + "embla-carousel": "8.1.3", + "embla-carousel-reactive-utils": "8.1.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.1.3.tgz", + "integrity": "sha512-D8tAK6NRQVEubMWb+b/BJ3VvGPsbEeEFOBM6cCCwfiyfLzNlacOAt0q2dtUEA9DbGxeWkB8ExgXzFRxhGV2Hig==", + "peerDependencies": { + "embla-carousel": "8.1.3" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/packages/ee/server-only/stripe/get-document-related-prices.ts.ts b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts index 81b32a7b9..9cdd227c0 100644 --- a/packages/ee/server-only/stripe/get-document-related-prices.ts.ts +++ b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts @@ -6,5 +6,5 @@ import { getPricesByPlan } from './get-prices-by-plan'; * Returns the Stripe prices of items that affect the amount of documents a user can create. */ export const getDocumentRelatedPrices = async () => { - return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); + return await getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); }; 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 1b528706a..94363e5b2 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -1,6 +1,7 @@ import type Stripe from 'stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; +import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; // Utility type to handle usage of the `expand` option. type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; @@ -11,7 +12,7 @@ export type GetPricesByIntervalOptions = { /** * Filter products by their meta 'plan' attribute. */ - plan?: 'community'; + plan?: STRIPE_PLAN_TYPE.COMMUNITY | STRIPE_PLAN_TYPE.REGULAR; }; export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => { diff --git a/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts index 0eb368ce7..ea299c618 100644 --- a/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts +++ b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts @@ -6,5 +6,5 @@ import { getPricesByPlan } from './get-prices-by-plan'; * Returns the prices of items that count as the account's primary plan. */ export const getPrimaryAccountPlanPrices = async () => { - return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); + return await getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); }; diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx index e1512d041..0c66f25af 100644 --- a/packages/email/templates/document-created-from-direct-template.tsx +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -1,3 +1,4 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import config from '@documenso/tailwind-config'; import { @@ -14,9 +15,11 @@ import { } from '../components'; import TemplateDocumentImage from '../template-components/template-document-image'; import { TemplateFooter } from '../template-components/template-footer'; +import { RecipientRole } from '.prisma/client'; export type DocumentCompletedEmailTemplateProps = { recipientName?: string; + recipientRole?: RecipientRole; documentLink?: string; documentName?: string; assetBaseUrl?: string; @@ -24,11 +27,14 @@ export type DocumentCompletedEmailTemplateProps = { export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ recipientName = 'John Doe', + recipientRole = RecipientRole.SIGNER, documentLink = 'http://localhost:3000', documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', }: DocumentCompletedEmailTemplateProps) => { - const previewText = `Completed Document`; + const action = RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned.toLowerCase(); + + const previewText = `Document created from direct template`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -61,7 +67,7 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
- {recipientName} signed a document by using one of your direct links + {recipientName} {action} a document by using one of your direct links
diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index 2e450de0c..830e3428a 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -18,7 +18,7 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, ''); downloadFile({ - filename: `${baseTitle}.pdf`, + filename: `${baseTitle}_signed.pdf`, data: blob, }); }; diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts index 0d8dee6e2..17178662d 100644 --- a/packages/lib/constants/billing.ts +++ b/packages/lib/constants/billing.ts @@ -4,6 +4,7 @@ export enum STRIPE_CUSTOMER_TYPE { } export enum STRIPE_PLAN_TYPE { + REGULAR = 'regular', TEAM = 'team', COMMUNITY = 'community', ENTERPRISE = 'enterprise', diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index 8ff575d65..964556c83 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,6 +1,7 @@ // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; -import { PDFDocument } from 'pdf-lib'; +import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib'; +import { match } from 'ts-pattern'; import { DEFAULT_HANDWRITING_FONT_SIZE, @@ -37,7 +38,32 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu throw new Error(`Page ${field.page} does not exist`); } - const { width: pageWidth, height: pageHeight } = page.getSize(); + const pageRotation = page.getRotation(); + + let pageRotationInDegrees = match(pageRotation.type) + .with(RotationTypes.Degrees, () => pageRotation.angle) + .with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle)) + .exhaustive(); + + // Round to the closest multiple of 90 degrees. + pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90; + + const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270; + + let { width: pageWidth, height: pageHeight } = page.getSize(); + + // PDFs can have pages that are rotated, which are correctly rendered in the frontend. + // However when we load the PDF in the backend, the rotation is applied. + // + // To account for this, we swap the width and height for pages that are rotated by 90/270 + // degrees. This is so we can calculate the virtual position the field was placed if it + // was correctly oriented in the frontend. + // + // Then when we insert the fields, we apply a transformation to the position of the field + // so it is rotated correctly. + if (isPageRotatedToLandscape) { + [pageWidth, pageHeight] = [pageHeight, pageWidth]; + } const fieldWidth = pageWidth * (Number(field.width) / 100); const fieldHeight = pageHeight * (Number(field.height) / 100); @@ -65,17 +91,31 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu imageWidth = imageWidth * scalingFactor; imageHeight = imageHeight * scalingFactor; - const imageX = fieldX + (fieldWidth - imageWidth) / 2; + let imageX = fieldX + (fieldWidth - imageWidth) / 2; let imageY = fieldY + (fieldHeight - imageHeight) / 2; // Invert the Y axis since PDFs use a bottom-left coordinate system imageY = pageHeight - imageY - imageHeight; + if (pageRotationInDegrees !== 0) { + const adjustedPosition = adjustPositionForRotation( + pageWidth, + pageHeight, + imageX, + imageY, + pageRotationInDegrees, + ); + + imageX = adjustedPosition.xPos; + imageY = adjustedPosition.yPos; + } + page.drawImage(image, { x: imageX, y: imageY, width: imageWidth, height: imageHeight, + rotate: degrees(pageRotationInDegrees), }); } else { const longestLineInTextForWidth = field.customText @@ -90,17 +130,31 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize); textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); - const textX = fieldX + (fieldWidth - textWidth) / 2; + let textX = fieldX + (fieldWidth - textWidth) / 2; let textY = fieldY + (fieldHeight - textHeight) / 2; // Invert the Y axis since PDFs use a bottom-left coordinate system textY = pageHeight - textY - textHeight; + if (pageRotationInDegrees !== 0) { + const adjustedPosition = adjustPositionForRotation( + pageWidth, + pageHeight, + textX, + textY, + pageRotationInDegrees, + ); + + textX = adjustedPosition.xPos; + textY = adjustedPosition.yPos; + } + page.drawText(field.customText, { x: textX, y: textY, size: fontSize, font, + rotate: degrees(pageRotationInDegrees), }); } @@ -117,3 +171,32 @@ export const insertFieldInPDFBytes = async ( return await pdfDoc.save(); }; + +const adjustPositionForRotation = ( + pageWidth: number, + pageHeight: number, + xPos: number, + yPos: number, + pageRotationInDegrees: number, +) => { + if (pageRotationInDegrees === 270) { + xPos = pageWidth - xPos; + [xPos, yPos] = [yPos, xPos]; + } + + if (pageRotationInDegrees === 90) { + yPos = pageHeight - yPos; + [xPos, yPos] = [yPos, xPos]; + } + + // Invert all the positions since it's rotated by 180 degrees. + if (pageRotationInDegrees === 180) { + xPos = pageWidth - xPos; + yPos = pageHeight - yPos; + } + + return { + xPos, + yPos, + }; +}; diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index eeb639bb8..229851729 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -480,6 +480,7 @@ export const createDocumentFromDirectTemplate = async ({ // Send email to template owner. const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { recipientName: directRecipientEmail, + recipientRole: directTemplateRecipient.role, documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`, documentName: document.title, assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',