Compare commits

...

4 Commits

4 changed files with 167 additions and 52 deletions

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@ -44,12 +44,22 @@ const MotionCard = motion(Card);
export type BillingPlansProps = {
plans: InternalClaimPlans;
selectedPlan?: string | null;
selectedCycle?: 'monthly' | 'yearly' | null;
isFromPricingPage?: boolean;
};
export const BillingPlans = ({ plans }: BillingPlansProps) => {
export const BillingPlans = ({
plans,
selectedPlan,
selectedCycle,
isFromPricingPage,
}: BillingPlansProps) => {
const isMounted = useIsMounted();
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const [interval, setInterval] = useState<'monthlyPrice' | 'yearlyPrice'>(
selectedCycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice',
);
const pricesToDisplay = useMemo(() => {
const prices = [];
@ -85,56 +95,65 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<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>
{pricesToDisplay.map((price) => {
const planId = price.claim.toLowerCase().replace('claim_', '');
const isSelected = selectedPlan && planId === selectedPlan?.toLowerCase();
<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>
return (
<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 } }}
className={isSelected ? 'ring-primary ring-2' : ''}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<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 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="flex-1" />
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
<BillingDialog
priceId={price.id}
planName={price.product.name}
memberCount={price.memberCount}
claim={price.claim}
/>
</CardContent>
</MotionCard>
))}
{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}
isSelected={isSelected || false}
isFromPricingPage={isFromPricingPage}
interval={interval}
/>
</CardContent>
</MotionCard>
);
})}
</AnimatePresence>
</div>
</div>
@ -145,14 +164,26 @@ const BillingDialog = ({
priceId,
planName,
claim,
isSelected,
isFromPricingPage,
interval,
}: {
priceId: string;
planName: string;
memberCount: number;
claim: string;
isSelected?: boolean;
isFromPricingPage?: boolean;
interval: 'monthlyPrice' | 'yearlyPrice';
}) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (isSelected && isFromPricingPage) {
setIsOpen(true);
}
}, [isSelected, isFromPricingPage]);
const { t } = useLingui();
const { toast } = useToast();
@ -227,11 +258,13 @@ const BillingDialog = ({
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Subscribe</Trans>
<Trans>
Subscribe to {planName} {interval === 'monthlyPrice' ? '(Monthly)' : '(Yearly)'}
</Trans>
</DialogTitle>
<DialogDescription>
<Trans>You are about to subscribe to the {planName}</Trans>
<Trans>Choose how to proceed with your subscription</Trans>
</DialogDescription>
</DialogHeader>

View File

@ -1,6 +1,7 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useSearchParams } from 'react-router';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
@ -19,9 +20,14 @@ export function meta() {
export default function TeamsSettingBillingPage() {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const organisation = useCurrentOrganisation();
const selectedPlan = searchParams.get('plan');
const selectedCycle = searchParams.get('cycle') as 'monthly' | 'yearly' | null;
const source = searchParams.get('source');
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.billing.subscription.get.useQuery({
organisationId: organisation.id,
@ -48,8 +54,21 @@ export default function TeamsSettingBillingPage() {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(stripeSubscription?.items.data[0].price.product as Stripe.Product | undefined)?.name;
const isFromPricingPage = source === 'pricing';
return (
<div>
{isFromPricingPage && selectedPlan && !subscription && (
<div className="bg-muted mb-4 rounded-lg p-4">
<p className="text-sm">
<Trans>
Select a plan below to upgrade <strong>{organisation.name}</strong> to the{' '}
{selectedPlan} plan
</Trans>
</p>
</div>
)}
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
@ -134,7 +153,14 @@ export default function TeamsSettingBillingPage() {
<hr className="my-4" />
{!subscription && canManageBilling && <BillingPlans plans={plans} />}
{!subscription && canManageBilling && (
<BillingPlans
plans={plans}
selectedPlan={selectedPlan}
selectedCycle={selectedCycle}
isFromPricingPage={source === 'pricing'}
/>
)}
<section className="mt-6">
<OrganisationBillingInvoicesTable

View File

@ -20,6 +20,8 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
const url = new URL(request.url);
const redirectParam = url.searchParams.get('redirect');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
@ -27,6 +29,9 @@ export async function loader({ request }: Route.LoaderArgs) {
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
if (isAuthenticated) {
if (redirectParam) {
throw redirect(redirectParam);
}
throw redirect('/');
}
@ -34,11 +39,12 @@ export async function loader({ request }: Route.LoaderArgs) {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
redirectTo: redirectParam,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel, redirectTo } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
@ -56,6 +62,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
returnTo={redirectTo || undefined}
/>
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (

View File

@ -0,0 +1,49 @@
import { redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { getOrganisations } from '@documenso/trpc/server/organisation-router/get-organisations';
import type { Route } from './+types/billing-redirect';
export async function loader({ request }: Route.LoaderArgs) {
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
const currentUrl = new URL(request.url);
const redirectParam = encodeURIComponent(currentUrl.pathname + currentUrl.search);
throw redirect(`/signin?redirect=${redirectParam}`);
}
const url = new URL(request.url);
const plan = url.searchParams.get('plan');
const cycle = url.searchParams.get('cycle');
const source = url.searchParams.get('source');
const queryParams = new URLSearchParams();
if (plan) {
queryParams.set('plan', plan);
}
if (cycle) {
queryParams.set('cycle', cycle);
}
if (source) {
queryParams.set('source', source);
}
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
const organisations = await getOrganisations({ userId: session.user.id });
if (isPersonalLayout(organisations)) {
return redirect(`/settings/billing${queryString}`);
}
const personalOrg = organisations.find((org) => org.type === 'PERSONAL') || organisations[0];
if (personalOrg) {
return redirect(`/o/${personalOrg.url}/settings/billing${queryString}`);
}
return redirect('/settings/profile');
}
export default function BillingRedirect() {
return null;
}