2023-06-09 18:21:18 +10:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { HTMLAttributes, KeyboardEvent, 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 { 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';
|
2023-08-23 11:22:13 +10:00
|
|
|
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
2023-06-09 18:21:18 +10:00
|
|
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
|
|
|
|
|
|
|
|
import { claimPlan } from '~/api/claim-plan/fetcher';
|
|
|
|
|
|
|
|
|
|
|
|
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().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().min(1),
|
|
|
|
|
|
}),
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
|
|
|
|
|
|
|
|
|
|
|
|
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
|
|
|
|
|
|
|
|
export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|
|
|
|
|
const { toast } = useToast();
|
|
|
|
|
|
const event = usePlausible();
|
|
|
|
|
|
|
|
|
|
|
|
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
|
|
|
|
|
|
const [showSigningDialog, setShowSigningDialog] = useState(false);
|
|
|
|
|
|
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
control,
|
|
|
|
|
|
register,
|
|
|
|
|
|
handleSubmit,
|
|
|
|
|
|
setValue,
|
|
|
|
|
|
trigger,
|
|
|
|
|
|
watch,
|
|
|
|
|
|
formState: { errors, isSubmitting, isValid },
|
|
|
|
|
|
} = useForm<TWidgetFormSchema>({
|
|
|
|
|
|
mode: 'onChange',
|
|
|
|
|
|
defaultValues: {
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
signatureDataUrl: null,
|
|
|
|
|
|
signatureText: '',
|
|
|
|
|
|
},
|
|
|
|
|
|
resolver: zodResolver(ZWidgetFormSchema),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const signatureDataUrl = watch('signatureDataUrl');
|
|
|
|
|
|
const signatureText = watch('signatureText');
|
|
|
|
|
|
|
|
|
|
|
|
const stepsRemaining = useMemo(() => {
|
|
|
|
|
|
if (step === 'NAME') {
|
|
|
|
|
|
return 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (step === 'SIGN') {
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 3;
|
|
|
|
|
|
}, [step]);
|
|
|
|
|
|
|
|
|
|
|
|
const onNextStepClick = () => {
|
|
|
|
|
|
if (step === 'EMAIL') {
|
|
|
|
|
|
setStep('NAME');
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
document.querySelector<HTMLElement>('#name')?.focus();
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (step === 'NAME') {
|
|
|
|
|
|
setStep('SIGN');
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
document.querySelector<HTMLElement>('#signatureText')?.focus();
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onEnterPress = (callback: () => void) => {
|
|
|
|
|
|
return (e: KeyboardEvent) => {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
callback();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onSignatureConfirmClick = () => {
|
|
|
|
|
|
setValue('signatureDataUrl', draftSignatureDataUrl);
|
|
|
|
|
|
setValue('signatureText', '');
|
|
|
|
|
|
|
2023-08-29 13:01:19 +10:00
|
|
|
|
void trigger('signatureDataUrl');
|
2023-06-09 18:21:18 +10:00
|
|
|
|
setShowSigningDialog(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onFormSubmit = async ({
|
|
|
|
|
|
email,
|
|
|
|
|
|
name,
|
|
|
|
|
|
signatureDataUrl,
|
|
|
|
|
|
signatureText,
|
|
|
|
|
|
}: TWidgetFormSchema) => {
|
|
|
|
|
|
try {
|
2023-08-29 13:01:19 +10:00
|
|
|
|
const delay = new Promise<void>((resolve) => {
|
|
|
|
|
|
setTimeout(resolve, 1000);
|
|
|
|
|
|
});
|
2023-06-09 18:21:18 +10:00
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
|
|
|
|
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Card
|
|
|
|
|
|
className={cn('mx-auto w-full max-w-4xl rounded-3xl before:rounded-3xl', className)}
|
|
|
|
|
|
gradient
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid grid-cols-12 gap-y-8 overflow-hidden p-2 lg:gap-x-8">
|
|
|
|
|
|
<div className="col-span-12 flex flex-col gap-y-4 p-4 text-xs leading-relaxed text-[#727272] lg:col-span-7">
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form
|
|
|
|
|
|
className="col-span-12 flex flex-col rounded-2xl bg-[#F7F7F7] p-6 lg:col-span-5"
|
|
|
|
|
|
onSubmit={handleSubmit(onFormSubmit)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 className="text-2xl font-semibold">Sign up for the community plan</h3>
|
|
|
|
|
|
<p className="mt-2 text-xs text-[#AFAFAF]">
|
|
|
|
|
|
with Timur Ercan & Lucas Smith from Documenso
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<hr className="mb-6 mt-4" />
|
|
|
|
|
|
|
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
<motion.div key="email">
|
|
|
|
|
|
<label htmlFor="email" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
|
|
|
|
|
What’s your email?
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<Controller
|
|
|
|
|
|
control={control}
|
|
|
|
|
|
name="email"
|
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
|
<div className="relative mt-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="email"
|
|
|
|
|
|
type="email"
|
|
|
|
|
|
placeholder=""
|
|
|
|
|
|
className="w-full bg-white pr-16"
|
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
|
onKeyDown={(e) =>
|
|
|
|
|
|
field.value !== '' &&
|
|
|
|
|
|
!errors.email?.message &&
|
|
|
|
|
|
onEnterPress(onNextStepClick)(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
{...field}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-y-0 right-0 p-1.5">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="bg-primary h-full w-14 rounded"
|
|
|
|
|
|
disabled={!field.value || !!errors.email?.message}
|
|
|
|
|
|
onClick={() => onNextStepClick()}
|
|
|
|
|
|
>
|
|
|
|
|
|
Next
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<FormErrorMessage error={errors.email} className="mt-1" />
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
|
|
|
|
|
|
{(step === 'NAME' || step === 'SIGN') && (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key="name"
|
|
|
|
|
|
className="mt-4"
|
|
|
|
|
|
animate={{
|
|
|
|
|
|
opacity: 1,
|
|
|
|
|
|
transform: 'translateX(0)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
initial={{
|
|
|
|
|
|
opacity: 0,
|
|
|
|
|
|
transform: 'translateX(-25%)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
exit={{
|
|
|
|
|
|
opacity: 0,
|
|
|
|
|
|
transform: 'translateX(25%)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<label htmlFor="name" className="text-lg font-semibold text-slate-900 lg:text-xl">
|
|
|
|
|
|
and your name?
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<Controller
|
|
|
|
|
|
control={control}
|
|
|
|
|
|
name="name"
|
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
|
<div className="relative mt-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="name"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder=""
|
|
|
|
|
|
className="w-full bg-white pr-16"
|
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
|
onKeyDown={(e) =>
|
|
|
|
|
|
field.value !== '' &&
|
|
|
|
|
|
!errors.name?.message &&
|
|
|
|
|
|
onEnterPress(onNextStepClick)(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
{...field}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-y-0 right-0 p-1.5">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="bg-primary h-full w-14 rounded"
|
|
|
|
|
|
disabled={!field.value || !!errors.name?.message}
|
|
|
|
|
|
onClick={() => onNextStepClick()}
|
|
|
|
|
|
>
|
|
|
|
|
|
Next
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<FormErrorMessage error={errors.name} className="mt-1" />
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-12 flex-1" />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<p className="text-xs text-[#AFAFAF]">{stepsRemaining} step(s) until signed</p>
|
|
|
|
|
|
<p className="block text-xs text-[#AFAFAF] md:hidden">Minimise contract</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative mt-2.5 h-[2px] w-full bg-[#E9E9E9]">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn('bg-primary/60 absolute inset-y-0 left-0 duration-200', {
|
|
|
|
|
|
'w-1/3': stepsRemaining === 3,
|
|
|
|
|
|
'w-2/3': stepsRemaining === 2,
|
|
|
|
|
|
'w-11/12': stepsRemaining === 1,
|
|
|
|
|
|
})}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Card id="signature" className="mt-4" degrees={-140} gradient>
|
|
|
|
|
|
<CardContent
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
className="relative cursor-pointer pt-6"
|
|
|
|
|
|
onClick={() => setShowSigningDialog(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex h-28 items-center justify-center pb-6">
|
|
|
|
|
|
{!signatureText && signatureDataUrl && (
|
|
|
|
|
|
<img src={signatureDataUrl} alt="user signature" className="h-full" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{signatureText && (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'text-4xl font-semibold text-slate-900 [font-family:var(--font-caveat)]',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{signatureText}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="signatureText"
|
|
|
|
|
|
className="border-none p-0 text-sm text-slate-700 placeholder:text-[#D6D6D6] focus-visible:ring-0"
|
|
|
|
|
|
placeholder="Draw or type name here"
|
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
|
{...register('signatureText', {
|
|
|
|
|
|
onChange: (e) => {
|
|
|
|
|
|
if (e.target.value !== '') {
|
|
|
|
|
|
setValue('signatureDataUrl', null);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
className="h-8 disabled:bg-[#ECEEED] disabled:text-[#C6C6C6] disabled:hover:bg-[#ECEEED]"
|
|
|
|
|
|
disabled={!isValid || isSubmitting}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
|
|
|
|
|
|
Sign
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={showSigningDialog} onOpenChange={setShowSigningDialog}>
|
|
|
|
|
|
<DialogContent>
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>Add your signature</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
|
By signing you signal your support of Documenso's mission in a <br></br>
|
|
|
|
|
|
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
|
|
|
|
|
|
<br></br>You also unlock the option to purchase the early supporter plan including
|
|
|
|
|
|
everything we build this year for fixed price.
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
|
|
|
|
|
|
<SignaturePad
|
|
|
|
|
|
className="aspect-video w-full rounded-md border"
|
|
|
|
|
|
onChange={setDraftSignatureDataUrl}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button variant="ghost" onClick={() => setShowSigningDialog(false)}>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<Button onClick={() => onSignatureConfirmClick()}>Confirm</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|