diff --git a/.changeset/little-cases-knock.md b/.changeset/little-cases-knock.md new file mode 100644 index 00000000000..b04bc7f52b7 --- /dev/null +++ b/.changeset/little-cases-knock.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Improvements of flows for switching between plans diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index c29df0179b4..29558e15910 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "68.3KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "110KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "52KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "104.1KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "104.20KB" }, { "path": "./dist/vendors*.js", "maxSize": "39.5KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index a384d16b2d2..556ec7e5a5f 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -109,8 +109,7 @@ function Card(props: CardProps) { const canManageBilling = useProtect( has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', ); - - const { buttonPropsForPlan, upcomingSubscriptionsExist, activeOrUpcomingSubscription } = usePlansContext(); + const { buttonPropsForPlan, activeOrUpcomingSubscriptionBasedOnPlanPeriod } = usePlansContext(); const showPlanDetails = (event?: React.MouseEvent) => { const portalRoot = getClosestProfileScrollBox(mode, event); @@ -123,21 +122,38 @@ function Card(props: CardProps) { }); }; - const subscription = activeOrUpcomingSubscription(plan); - const hasFeatures = plan.features.length > 0; + const subscription = React.useMemo( + () => activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod), + [plan, planPeriod, activeOrUpcomingSubscriptionBasedOnPlanPeriod], + ); const isPlanActive = subscription?.status === 'active'; + const hasFeatures = plan.features.length > 0; const showStatusRow = !!subscription; - const isEligibleForSwitchToAnnual = plan.annualMonthlyAmount > 0 && planPeriod === 'annual'; - const shouldShowFooter = - !subscription || - subscription?.status === 'upcoming' || - subscription?.canceledAt || - (planPeriod !== subscription?.planPeriod && !plan.isDefault && isEligibleForSwitchToAnnual); - const shouldShowFooterNotice = - subscription?.status === 'upcoming' && (planPeriod === subscription.planPeriod || plan.isDefault); + let shouldShowFooter = false; + let shouldShowFooterNotice = false; - const planPeriodSameAsSelectedPlanPeriod = !upcomingSubscriptionsExist && subscription?.planPeriod === planPeriod; + 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.annualMonthlyAmount > 0) { + shouldShowFooter = true; + shouldShowFooterNotice = false; + } else { + shouldShowFooter = false; + shouldShowFooterNotice = false; + } + } else { + shouldShowFooter = false; + shouldShowFooterNotice = false; + } return ( diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 127b9723e78..b9a3b8cea2c 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -176,6 +176,40 @@ export const usePlansContext = () => { [ctx.subscriptions], ); + // returns all subscriptions for a plan that are active or upcoming + const activeAndUpcomingSubscriptions = useCallback( + (plan: CommercePlanResource) => { + return ctx.subscriptions.filter(subscription => subscription.plan.id === plan.id); + }, + [ctx.subscriptions], + ); + + // return the active or upcoming subscription for a plan based on the plan period, if there is no subscription for the plan period, return the first subscription + const activeOrUpcomingSubscriptionWithPlanPeriod = useCallback( + (plan: CommercePlanResource, planPeriod: CommerceSubscriptionPlanPeriod = 'month') => { + const plansSubscriptions = activeAndUpcomingSubscriptions(plan); + // Handle multiple subscriptions for the same plan + if (plansSubscriptions.length > 1) { + const subscriptionBaseOnPanPeriod = plansSubscriptions.find(subscription => { + return subscription.planPeriod === planPeriod; + }); + + if (subscriptionBaseOnPanPeriod) { + return subscriptionBaseOnPanPeriod; + } + + return plansSubscriptions[0]; + } + + if (plansSubscriptions.length === 1) { + return plansSubscriptions[0]; + } + + return undefined; + }, + [activeAndUpcomingSubscriptions], + ); + const canManageSubscription = useCallback( ({ plan, subscription: sub }: { plan?: CommercePlanResource; subscription?: CommerceSubscriptionResource }) => { const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined); @@ -212,7 +246,8 @@ export const usePlansContext = () => { isDisabled: boolean; disabled: boolean; } => { - const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined); + const subscription = + sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined); let _selectedPlanPeriod = selectedPlanPeriod; if (_selectedPlanPeriod === 'annual' && sub?.plan.annualMonthlyAmount === 0) { _selectedPlanPeriod = 'month'; @@ -220,28 +255,55 @@ export const usePlansContext = () => { const isEligibleForSwitchToAnnual = (plan?.annualMonthlyAmount ?? 0) > 0; + const getLocalizationKey = () => { + // Handle subscription cases + if (subscription) { + if (_selectedPlanPeriod !== subscription.planPeriod && subscription.canceledAt) { + if (_selectedPlanPeriod === 'month') { + return localizationKeys('commerce.switchToMonthly'); + } + + if (isEligibleForSwitchToAnnual) { + return localizationKeys('commerce.switchToAnnual'); + } + } + + if (subscription.canceledAt) { + return localizationKeys('commerce.reSubscribe'); + } + + if (_selectedPlanPeriod !== subscription.planPeriod) { + if (_selectedPlanPeriod === 'month') { + return localizationKeys('commerce.switchToMonthly'); + } + + if (isEligibleForSwitchToAnnual) { + return localizationKeys('commerce.switchToAnnual'); + } + + return localizationKeys('commerce.manageSubscription'); + } + + return localizationKeys('commerce.manageSubscription'); + } + + // Handle non-subscription cases + const hasNonDefaultSubscriptions = + ctx.subscriptions.filter(subscription => !subscription.plan.isDefault).length > 0; + return hasNonDefaultSubscriptions + ? localizationKeys('commerce.switchPlan') + : localizationKeys('commerce.subscribe'); + }; + return { - localizationKey: subscription - ? subscription.canceledAt - ? localizationKeys('commerce.reSubscribe') - : selectedPlanPeriod !== subscription.planPeriod - ? selectedPlanPeriod === 'month' - ? localizationKeys('commerce.switchToMonthly') - : isEligibleForSwitchToAnnual - ? localizationKeys('commerce.switchToAnnual') - : localizationKeys('commerce.manageSubscription') - : localizationKeys('commerce.manageSubscription') - : // If there are no active or grace period subscriptions, show the get started button - ctx.subscriptions.filter(subscription => !subscription.plan.isDefault).length > 0 - ? localizationKeys('commerce.switchPlan') - : localizationKeys('commerce.subscribe'), + localizationKey: getLocalizationKey(), variant: isCompact ? 'bordered' : 'solid', colorScheme: isCompact ? 'secondary' : 'primary', isDisabled: !canManageBilling, disabled: !canManageBilling, }; }, - [activeOrUpcomingSubscription, canManageBilling, ctx.subscriptions], + [activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, ctx.subscriptions], ); const captionForSubscription = useCallback((subscription: CommerceSubscriptionResource) => { @@ -261,7 +323,7 @@ export const usePlansContext = () => { const portalRoot = getClosestProfileScrollBox(mode, event); - if (subscription && !subscription.canceledAt) { + if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAt) { clerk.__internal_openPlanDetails({ plan, subscriberType, @@ -303,6 +365,8 @@ export const usePlansContext = () => { ...ctx, componentName, activeOrUpcomingSubscription, + activeAndUpcomingSubscriptions, + activeOrUpcomingSubscriptionBasedOnPlanPeriod: activeOrUpcomingSubscriptionWithPlanPeriod, isDefaultPlanImplicitlyActiveOrUpcoming, handleSelectPlan, buttonPropsForPlan,