Merge branch 'main' into feat/document-templates

This commit is contained in:
Lucas Smith
2023-12-21 14:25:22 +11:00
committed by GitHub
40 changed files with 371 additions and 384 deletions

View File

@ -6,8 +6,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: 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;

View File

@ -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<typeof ZClaimPlanDialogFormSchema>;
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<TClaimPlanDialogFormSchema>({
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((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 (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-muted-foreground">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the early adopters Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -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<HTMLDivElement>;
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 (
<div className={cn('', className)} {...props}>

View File

@ -26,6 +26,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
@ -48,13 +49,16 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
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 [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@ -81,11 +85,11 @@ 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') {
if (step === STEP.EMAIL) {
return 1;
}
@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
}, [step]);
const onNextStepClick = () => {
if (step === 'EMAIL') {
setStep('NAME');
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === 'NAME') {
setStep('SIGN');
if (step === STEP.NAME) {
setStep(STEP.SIGN);
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
@ -226,7 +230,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
</Button>
@ -238,7 +242,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === 'NAME' || step === 'SIGN') && (
{(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div
key="name"
className="mt-4"

View File

@ -0,0 +1,5 @@
export const STEP = {
EMAIL: 'EMAIL',
NAME: 'NAME',
SIGN: "SIGN"
} as const;

View File

@ -6,8 +6,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: 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;

View File

@ -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',

View File

@ -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 (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}

View File

@ -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';

View File

@ -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,

View File

@ -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,

View File

@ -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 (
<div>

View File

@ -13,12 +13,14 @@ export default function SignInPage() {
<SignInForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
<p className="mt-2.5 text-center">
<Link

View File

@ -1,8 +1,13 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>