diff --git a/.changeset/sweet-humans-poke.md b/.changeset/sweet-humans-poke.md new file mode 100644 index 00000000000..b6c6294b1f2 --- /dev/null +++ b/.changeset/sweet-humans-poke.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Hide CTA for `` when the user is does not have an active organization selected. diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index 40ef79be015..8c34bcd7af9 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -1,4 +1,4 @@ -import { useClerk, useSession } from '@clerk/shared/react'; +import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/types'; import * as React from 'react'; @@ -24,6 +24,7 @@ import { import { Check, Plus } from '../../icons'; import { common, InternalThemeProvider } from '../../styledSystem'; import { SubscriptionBadge } from '../Subscriptions/badge'; +import { getPricingFooterState } from './utils/pricing-footer-state'; interface PricingTableDefaultProps { plans?: BillingPlanResource[] | null; @@ -103,6 +104,7 @@ function Card(props: CardProps) { const { isSignedIn } = useSession(); const { mode = 'mounted', ctaPosition: ctxCtaPosition } = usePricingTableContext(); const subscriberType = useSubscriberTypeContext(); + const { organization } = useOrganization(); const ctaPosition = pricingTableProps.ctaPosition || ctxCtaPosition || 'bottom'; const collapseFeatures = pricingTableProps.collapseFeatures || false; @@ -129,35 +131,14 @@ function Card(props: CardProps) { ); const hasFeatures = plan.features.length > 0; - const showStatusRow = !!subscription; - let shouldShowFooter = false; - let shouldShowFooterNotice = false; - - if (!subscription) { - shouldShowFooter = true; - shouldShowFooterNotice = false; - } else if (subscription.status === 'upcoming') { - shouldShowFooter = true; - shouldShowFooterNotice = true; - } else if (subscription.status === 'active') { - if (subscription.canceledAt) { - shouldShowFooter = true; - shouldShowFooterNotice = false; - } else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0) { - shouldShowFooter = true; - shouldShowFooterNotice = false; - } else if (plan.freeTrialEnabled && subscription.isFreeTrial) { - shouldShowFooter = true; - shouldShowFooterNotice = true; - } else { - shouldShowFooter = false; - shouldShowFooterNotice = false; - } - } else { - shouldShowFooter = false; - shouldShowFooterNotice = false; - } + const { shouldShowFooter, shouldShowFooterNotice } = getPricingFooterState({ + subscription, + plan, + planPeriod, + forOrganizations: pricingTableProps.forOrganizations, + hasActiveOrganization: !!organization, + }); return ( ) : undefined } diff --git a/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.spec.ts b/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.spec.ts new file mode 100644 index 00000000000..90964d76b85 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.spec.ts @@ -0,0 +1,126 @@ +import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types'; +import { describe, expect, it } from 'vitest'; + +import { getPricingFooterState } from './pricing-footer-state'; + +const basePlan: BillingPlanResource = { + id: 'plan_1', + name: 'Pro', + fee: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + annualFee: { amount: 10000, amountFormatted: '100.00', currency: 'USD', currencySymbol: '$' }, + annualMonthlyFee: { amount: 833, amountFormatted: '8.33', currency: 'USD', currencySymbol: '$' }, + description: 'desc', + isDefault: false, + isRecurring: true, + hasBaseFee: true, + forPayerType: 'user', + publiclyVisible: true, + slug: 'pro', + avatarUrl: '', + features: [], + freeTrialDays: 14, + freeTrialEnabled: true, + pathRoot: '', + reload: async () => undefined as any, +}; + +const makeSub = (overrides: Partial): BillingSubscriptionItemResource => ({ + id: 'si_1', + plan: basePlan, + planPeriod: 'month', + status: 'active', + createdAt: new Date('2021-01-01'), + paymentSourceId: 'src_1', + pastDueAt: null, + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2021-01-31'), + canceledAt: null, + isFreeTrial: false, + cancel: async () => undefined as any, + pathRoot: '', + reload: async () => undefined as any, + ...overrides, +}); + +const run = (args: { + subscription?: BillingSubscriptionItemResource; + plan?: BillingPlanResource; + planPeriod?: BillingSubscriptionPlanPeriod; + forOrganizations?: boolean; + hasActiveOrganization?: boolean; +}) => + getPricingFooterState({ + subscription: args.subscription, + plan: args.plan ?? basePlan, + planPeriod: args.planPeriod ?? 'month', + forOrganizations: args.forOrganizations, + hasActiveOrganization: args.hasActiveOrganization ?? false, + }); + +describe('usePricingFooterState', () => { + it('hides footer when org plans and no active org', () => { + const res = run({ subscription: undefined, forOrganizations: true, hasActiveOrganization: false }); + expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); + }); + + it('shows footer when no subscription and user plans', () => { + const res = run({ subscription: undefined, forOrganizations: false }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); + }); + + it('shows notice when subscription is upcoming', () => { + const res = run({ subscription: makeSub({ status: 'upcoming' }) }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true }); + }); + + it('shows footer when active but canceled', () => { + const res = run({ subscription: makeSub({ status: 'active', canceledAt: new Date('2021-02-01') }) }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); + }); + + it('shows footer when switching period to paid annual', () => { + const res = run({ + subscription: makeSub({ status: 'active', planPeriod: 'month' }), + planPeriod: 'annual', + plan: basePlan, + }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); + }); + + it('shows notice when active free trial', () => { + const res = run({ subscription: makeSub({ status: 'active', isFreeTrial: true }) }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: true }); + }); + + it('hides footer when active and matching period without trial', () => { + const res = run({ subscription: makeSub({ status: 'active', planPeriod: 'month', isFreeTrial: false }) }); + expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); + }); + + it('shows footer when switching period to paid monthly', () => { + const res = run({ + subscription: makeSub({ status: 'active', planPeriod: 'annual' }), + planPeriod: 'month', + plan: basePlan, + }); + expect(res).toEqual({ shouldShowFooter: true, shouldShowFooterNotice: false }); + }); + + it('does not show footer when switching period if annualMonthlyFee is 0', () => { + const freeAnnualPlan: BillingPlanResource = { + ...basePlan, + annualMonthlyFee: { ...basePlan.annualMonthlyFee, amount: 0, amountFormatted: '0.00' }, + }; + const res = run({ + subscription: makeSub({ status: 'active', planPeriod: 'month' }), + planPeriod: 'annual', + plan: freeAnnualPlan, + }); + expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); + }); + + it('hides footer when subscription is past_due', () => { + const res = run({ subscription: makeSub({ status: 'past_due' }) }); + expect(res).toEqual({ shouldShowFooter: false, shouldShowFooterNotice: false }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.ts b/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.ts new file mode 100644 index 00000000000..441b55829f3 --- /dev/null +++ b/packages/clerk-js/src/ui/components/PricingTable/utils/pricing-footer-state.ts @@ -0,0 +1,58 @@ +import type { BillingPlanResource, BillingSubscriptionItemResource, BillingSubscriptionPlanPeriod } from '@clerk/types'; + +type UsePricingFooterStateParams = { + subscription: BillingSubscriptionItemResource | undefined; + plan: BillingPlanResource; + planPeriod: BillingSubscriptionPlanPeriod; + forOrganizations?: boolean; + hasActiveOrganization: boolean; +}; + +/** + * Calculates the correct show/hide state for the footer of a card in the `` component. + * @returns [shouldShowFooter, shouldShowFooterNotice] + */ +const valueResolution = (params: UsePricingFooterStateParams): [boolean, boolean] => { + const { subscription, plan, planPeriod, forOrganizations, hasActiveOrganization } = params; + const show_with_notice: [boolean, boolean] = [true, true]; + const show_without_notice: [boolean, boolean] = [true, false]; + const hide: [boolean, boolean] = [false, false]; + + // No subscription + if (!subscription) { + if (forOrganizations && !hasActiveOrganization) { + return hide; + } + return show_without_notice; + } + + // Upcoming subscription + if (subscription.status === 'upcoming') { + return show_with_notice; + } + + // Active subscription + if (subscription.status === 'active') { + const isCanceled = !!subscription.canceledAt; + const isSwitchingPaidPeriod = planPeriod !== subscription.planPeriod && plan.annualMonthlyFee.amount > 0; + const isActiveFreeTrial = plan.freeTrialEnabled && subscription.isFreeTrial; + + if (isCanceled || isSwitchingPaidPeriod) { + return show_without_notice; + } + + if (isActiveFreeTrial) { + return show_with_notice; + } + + return hide; + } + return hide; +}; + +export const getPricingFooterState = ( + params: UsePricingFooterStateParams, +): { shouldShowFooter: boolean; shouldShowFooterNotice: boolean } => { + const [shouldShowFooter, shouldShowFooterNotice] = valueResolution(params); + return { shouldShowFooter, shouldShowFooterNotice }; +};