mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { Building2Icon, PlusIcon } from 'lucide-react';
|
|
import { useForm } from 'react-hook-form';
|
|
|
|
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@documenso/ui/primitives/dialog';
|
|
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 { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
|
|
|
|
const MotionCard = motion(Card);
|
|
|
|
export type BillingPlansProps = {
|
|
plans: InternalClaimPlans;
|
|
};
|
|
|
|
export const BillingPlans = ({ plans }: BillingPlansProps) => {
|
|
const isMounted = useIsMounted();
|
|
|
|
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
|
|
|
const pricesToDisplay = useMemo(() => {
|
|
const prices = [];
|
|
|
|
for (const plan of Object.values(plans)) {
|
|
if (plan[interval] && plan[interval].isVisibleInApp) {
|
|
prices.push({
|
|
...plan[interval],
|
|
memberCount: plan.memberCount,
|
|
claim: plan.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
return prices;
|
|
}, [plans, interval]);
|
|
|
|
return (
|
|
<div>
|
|
<Tabs
|
|
value={interval}
|
|
onValueChange={(value) => setInterval(value as 'monthlyPrice' | 'yearlyPrice')}
|
|
>
|
|
<TabsList>
|
|
<TabsTrigger className="min-w-[150px]" value="monthlyPrice">
|
|
<Trans>Monthly</Trans>
|
|
</TabsTrigger>
|
|
<TabsTrigger className="min-w-[150px]" value="yearlyPrice">
|
|
<Trans>Yearly</Trans>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
|
<AnimatePresence mode="wait">
|
|
{pricesToDisplay.map((price) => (
|
|
<MotionCard
|
|
key={price.id}
|
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
|
>
|
|
<CardContent className="flex h-full flex-col p-6">
|
|
<CardTitle>{price.product.name}</CardTitle>
|
|
|
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
|
{price.friendlyPrice + ' '}
|
|
<span className="text-xs">
|
|
{interval === 'monthlyPrice' ? (
|
|
<Trans>per month</Trans>
|
|
) : (
|
|
<Trans>per year</Trans>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
|
{price.product.description}
|
|
</div>
|
|
|
|
{price.product.features && price.product.features.length > 0 && (
|
|
<div className="text-muted-foreground mt-4">
|
|
<div className="text-sm font-medium">Includes:</div>
|
|
|
|
<ul className="mt-1 divide-y text-sm">
|
|
{price.product.features.map((feature, index) => (
|
|
<li key={index} className="py-2">
|
|
{feature.name}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<BillingDialog
|
|
priceId={price.id}
|
|
planName={price.product.name}
|
|
memberCount={price.memberCount}
|
|
claim={price.claim}
|
|
/>
|
|
</CardContent>
|
|
</MotionCard>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BillingDialog = ({
|
|
priceId,
|
|
planName,
|
|
claim,
|
|
}: {
|
|
priceId: string;
|
|
planName: string;
|
|
memberCount: number;
|
|
claim: string;
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
const { t } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const organisation = useCurrentOrganisation();
|
|
|
|
const [subscriptionOption, setSubscriptionOption] = useState<'update' | 'create'>(
|
|
organisation.type === 'PERSONAL' && claim === INTERNAL_CLAIM_ID.INDIVIDUAL
|
|
? 'update'
|
|
: 'create',
|
|
);
|
|
|
|
const [step, setStep] = useState(0);
|
|
|
|
const form = useForm({
|
|
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
|
defaultValues: {
|
|
name: '',
|
|
},
|
|
});
|
|
|
|
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
|
|
trpc.billing.subscription.create.useMutation();
|
|
|
|
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
|
|
trpc.organisation.create.useMutation();
|
|
|
|
const isPending = isCreatingSubscription || isCreatingOrganisation;
|
|
|
|
const onSubscribeClick = async () => {
|
|
try {
|
|
let redirectUrl = '';
|
|
|
|
if (subscriptionOption === 'update') {
|
|
const createSubscriptionResponse = await createSubscription({
|
|
organisationId: organisation.id,
|
|
priceId,
|
|
});
|
|
|
|
redirectUrl = createSubscriptionResponse.redirectUrl;
|
|
} else {
|
|
const createOrganisationResponse = await createOrganisation({
|
|
name: form.getValues('name'),
|
|
priceId,
|
|
});
|
|
|
|
if (!createOrganisationResponse.paymentRequired) {
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
redirectUrl = createOrganisationResponse.checkoutUrl;
|
|
}
|
|
|
|
window.location.href = redirectUrl;
|
|
} catch (_err) {
|
|
toast({
|
|
title: t`Something went wrong`,
|
|
description: t`An error occurred while trying to create a checkout session.`,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<Trans>Subscribe</Trans>
|
|
</Button>
|
|
</DialogTrigger>
|
|
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<Trans>Subscribe</Trans>
|
|
</DialogTitle>
|
|
|
|
<DialogDescription>
|
|
<Trans>You are about to subscribe to the {planName}</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{step === 0 ? (
|
|
<div>
|
|
<RadioGroup
|
|
className="space-y-2"
|
|
value={subscriptionOption}
|
|
onValueChange={(value) => setSubscriptionOption(value as 'update' | 'create')}
|
|
>
|
|
<div className="flex items-start space-x-3 space-y-0">
|
|
<RadioGroupItem value="update" id="update" />
|
|
<div className="space-y-1.5 leading-none">
|
|
<Label htmlFor="update" className="flex items-center gap-2 font-medium">
|
|
<Building2Icon className="h-4 w-4" />
|
|
<Trans>Update current organisation</Trans>
|
|
</Label>
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>
|
|
Upgrade <strong>{organisation.name}</strong> to {planName}
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start space-x-3 space-y-0">
|
|
<RadioGroupItem value="create" id="create" />
|
|
<div className="space-y-1.5 leading-none">
|
|
<Label htmlFor="create" className="flex items-center gap-2 font-medium">
|
|
<PlusIcon className="h-4 w-4" />
|
|
<Trans>Create separate organisation</Trans>
|
|
</Label>
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>
|
|
Create a new organisation with {planName} plan. Keep your current organisation
|
|
on it's current plan
|
|
</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
) : (
|
|
<Form {...form}>
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel required>
|
|
<Trans>Organisation Name</Trans>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</Form>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<DialogClose>
|
|
<Button disabled={isPending} variant="secondary">
|
|
<Trans>Close</Trans>
|
|
</Button>
|
|
</DialogClose>
|
|
|
|
{subscriptionOption === 'create' && step === 0 ? (
|
|
<Button className="mt-4" loading={isPending} onClick={() => setStep(1)}>
|
|
<Trans>Continue</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button className="mt-4" loading={isPending} onClick={() => void onSubscribeClick()}>
|
|
<Trans>Checkout</Trans>
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|