Skip to content

Commit 6ca071d

Browse files
authored
fix(clerk-js): Hide CTA for <PricingTable forOrganization/> and orgId is null (#6883)
1 parent 04a9e16 commit 6ca071d

File tree

4 files changed

+200
-30
lines changed

4 files changed

+200
-30
lines changed

.changeset/sweet-humans-poke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Hide CTA for `<PricingTable forOrganization/>` when the user is does not have an active organization selected.

packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useClerk, useSession } from '@clerk/shared/react';
1+
import { useClerk, useOrganization, useSession } from '@clerk/shared/react';
22
import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/types';
33
import * as React from 'react';
44

@@ -24,6 +24,7 @@ import {
2424
import { Check, Plus } from '../../icons';
2525
import { common, InternalThemeProvider } from '../../styledSystem';
2626
import { SubscriptionBadge } from '../Subscriptions/badge';
27+
import { getPricingFooterState } from './utils/pricing-footer-state';
2728

2829
interface PricingTableDefaultProps {
2930
plans?: BillingPlanResource[] | null;
@@ -103,6 +104,7 @@ function Card(props: CardProps) {
103104
const { isSignedIn } = useSession();
104105
const { mode = 'mounted', ctaPosition: ctxCtaPosition } = usePricingTableContext();
105106
const subscriberType = useSubscriberTypeContext();
107+
const { organization } = useOrganization();
106108

107109
const ctaPosition = pricingTableProps.ctaPosition || ctxCtaPosition || 'bottom';
108110
const collapseFeatures = pricingTableProps.collapseFeatures || false;
@@ -129,35 +131,14 @@ function Card(props: CardProps) {
129131
);
130132

131133
const hasFeatures = plan.features.length > 0;
132-
const showStatusRow = !!subscription;
133134

134-
let shouldShowFooter = false;
135-
let shouldShowFooterNotice = false;
136-
137-
if (!subscription) {
138-
shouldShowFooter = true;
139-
shouldShowFooterNotice = false;
140-
} else if (subscription.status === 'upcoming') {
141-
shouldShowFooter = true;
142-
shouldShowFooterNotice = true;
143-
} else if (subscription.status === 'active') {
144-
if (subscription.canceledAt) {
145-
shouldShowFooter = true;
146-
shouldShowFooterNotice = false;
147-
} else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0) {
148-
shouldShowFooter = true;
149-
shouldShowFooterNotice = false;
150-
} else if (plan.freeTrialEnabled && subscription.isFreeTrial) {
151-
shouldShowFooter = true;
152-
shouldShowFooterNotice = true;
153-
} else {
154-
shouldShowFooter = false;
155-
shouldShowFooterNotice = false;
156-
}
157-
} else {
158-
shouldShowFooter = false;
159-
shouldShowFooterNotice = false;
160-
}
135+
const { shouldShowFooter, shouldShowFooterNotice } = getPricingFooterState({
136+
subscription,
137+
plan,
138+
planPeriod,
139+
forOrganizations: pricingTableProps.forOrganizations,
140+
hasActiveOrganization: !!organization,
141+
});
161142

162143
return (
163144
<Box
@@ -185,7 +166,7 @@ function Card(props: CardProps) {
185166
planPeriod={planPeriod}
186167
setPlanPeriod={setPlanPeriod}
187168
badge={
188-
showStatusRow ? (
169+
subscription ? (
189170
<SubscriptionBadge subscription={subscription.isFreeTrial ? { status: 'free_trial' } : subscription} />
190171
) : undefined
191172
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { getPricingFooterState } from './pricing-footer-state';
5+
6+
const basePlan: BillingPlanResource = {
7+
id: 'plan_1',
8+
name: 'Pro',
9+
fee: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
10+
annualFee: { amount: 10000, amountFormatted: '100.00', currency: 'USD', currencySymbol: '$' },
11+
annualMonthlyFee: { amount: 833, amountFormatted: '8.33', currency: 'USD', currencySymbol: '$' },
12+
description: 'desc',
13+
isDefault: false,
14+
isRecurring: true,
15+
hasBaseFee: true,
16+
forPayerType: 'user',
17+
publiclyVisible: true,
18+
slug: 'pro',
19+
avatarUrl: '',
20+
features: [],
21+
freeTrialDays: 14,
22+
freeTrialEnabled: true,
23+
pathRoot: '',
24+
reload: async () => undefined as any,
25+
};
26+
27+
const makeSub = (overrides: Partial<BillingSubscriptionItemResource>): BillingSubscriptionItemResource => ({
28+
id: 'si_1',
29+
plan: basePlan,
30+
planPeriod: 'month',
31+
status: 'active',
32+
createdAt: new Date('2021-01-01'),
33+
paymentSourceId: 'src_1',
34+
pastDueAt: null,
35+
periodStart: new Date('2021-01-01'),
36+
periodEnd: new Date('2021-01-31'),
37+
canceledAt: null,
38+
isFreeTrial: false,
39+
cancel: async () => undefined as any,
40+
pathRoot: '',
41+
reload: async () => undefined as any,
42+
...overrides,
43+
});
44+
45+
const run = (args: {
46+
subscription?: BillingSubscriptionItemResource;
47+
plan?: BillingPlanResource;
48+
planPeriod?: BillingSubscriptionPlanPeriod;
49+
forOrganizations?: boolean;
50+
hasActiveOrganization?: boolean;
51+
}) =>
52+
getPricingFooterState({
53+
subscription: args.subscription,
54+
plan: args.plan ?? basePlan,
55+
planPeriod: args.planPeriod ?? 'month',
56+
forOrganizations: args.forOrganizations,
57+
hasActiveOrganization: args.hasActiveOrganization ?? false,
58+
});
59+
60+
describe('usePricingFooterState', () => {
61+
it('hides footer when org plans and no active org', () => {
62+
const res = run({ subscription: undefined, forOrganizations: true, hasActiveOrganization: false });
63+
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false });
64+
});
65+
66+
it('shows footer when no subscription and user plans', () => {
67+
const res = run({ subscription: undefined, forOrganizations: false });
68+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false });
69+
});
70+
71+
it('shows notice when subscription is upcoming', () => {
72+
const res = run({ subscription: makeSub({ status: 'upcoming' }) });
73+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true });
74+
});
75+
76+
it('shows footer when active but canceled', () => {
77+
const res = run({ subscription: makeSub({ status: 'active', canceledAt: new Date('2021-02-01') }) });
78+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false });
79+
});
80+
81+
it('shows footer when switching period to paid annual', () => {
82+
const res = run({
83+
subscription: makeSub({ status: 'active', planPeriod: 'month' }),
84+
planPeriod: 'annual',
85+
plan: basePlan,
86+
});
87+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false });
88+
});
89+
90+
it('shows notice when active free trial', () => {
91+
const res = run({ subscription: makeSub({ status: 'active', isFreeTrial: true }) });
92+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true });
93+
});
94+
95+
it('hides footer when active and matching period without trial', () => {
96+
const res = run({ subscription: makeSub({ status: 'active', planPeriod: 'month', isFreeTrial: false }) });
97+
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false });
98+
});
99+
100+
it('shows footer when switching period to paid monthly', () => {
101+
const res = run({
102+
subscription: makeSub({ status: 'active', planPeriod: 'annual' }),
103+
planPeriod: 'month',
104+
plan: basePlan,
105+
});
106+
expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false });
107+
});
108+
109+
it('does not show footer when switching period if annualMonthlyFee is 0', () => {
110+
const freeAnnualPlan: BillingPlanResource = {
111+
...basePlan,
112+
annualMonthlyFee: { ...basePlan.annualMonthlyFee, amount: 0, amountFormatted: '0.00' },
113+
};
114+
const res = run({
115+
subscription: makeSub({ status: 'active', planPeriod: 'month' }),
116+
planPeriod: 'annual',
117+
plan: freeAnnualPlan,
118+
});
119+
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false });
120+
});
121+
122+
it('hides footer when subscription is past_due', () => {
123+
const res = run({ subscription: makeSub({ status: 'past_due' }) });
124+
expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false });
125+
});
126+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types';
2+
3+
type UsePricingFooterStateParams = {
4+
subscription: BillingSubscriptionItemResource | undefined;
5+
plan: BillingPlanResource;
6+
planPeriod: BillingSubscriptionPlanPeriod;
7+
forOrganizations?: boolean;
8+
hasActiveOrganization: boolean;
9+
};
10+
11+
/**
12+
* Calculates the correct show/hide state for the footer of a card in the `<PricingTableDefault/>` component.
13+
* @returns [shouldShowFooter, shouldShowFooterNotice]
14+
*/
15+
const valueResolution = (params: UsePricingFooterStateParams): [boolean, boolean] => {
16+
const { subscription, plan, planPeriod, forOrganizations, hasActiveOrganization } = params;
17+
const show_with_notice: [boolean, boolean] = [true, true];
18+
const show_without_notice: [boolean, boolean] = [true, false];
19+
const hide: [boolean, boolean] = [false, false];
20+
21+
// No subscription
22+
if (!subscription) {
23+
if (forOrganizations && !hasActiveOrganization) {
24+
return hide;
25+
}
26+
return show_without_notice;
27+
}
28+
29+
// Upcoming subscription
30+
if (subscription.status === 'upcoming') {
31+
return show_with_notice;
32+
}
33+
34+
// Active subscription
35+
if (subscription.status === 'active') {
36+
const isCanceled = !!subscription.canceledAt;
37+
const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0;
38+
const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial;
39+
40+
if (isCanceled || isSwitchingPaidPeriod) {
41+
return show_without_notice;
42+
}
43+
44+
if (isActiveFreeTrial) {
45+
return show_with_notice;
46+
}
47+
48+
return hide;
49+
}
50+
return hide;
51+
};
52+
53+
export const getPricingFooterState = (
54+
params: UsePricingFooterStateParams,
55+
): { shouldShowFooter: boolean; shouldShowFooterNotice: boolean } => {
56+
const [shouldShowFooter, shouldShowFooterNotice] = valueResolution(params);
57+
return { shouldShowFooter, shouldShowFooterNotice };
58+
};

0 commit comments

Comments
 (0)