diff --git a/.changeset/eager-goats-vanish.md b/.changeset/eager-goats-vanish.md new file mode 100644 index 00000000000..3ef96d8be31 --- /dev/null +++ b/.changeset/eager-goats-vanish.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add support for switching to the free plan diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 664d8116d31..54076404b42 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -79,7 +79,7 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer as='h2' textVariant='h2' localizationKey={ - checkout.subscription?.status === 'active' + checkout.totals.totalDueNow.amount > 0 ? localizationKeys('__experimental_commerce.checkout.title__paymentSuccessful') : localizationKeys('__experimental_commerce.checkout.title__subscriptionSuccessful') } @@ -89,7 +89,7 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer colorScheme='secondary' sx={t => ({ textAlign: 'center', paddingInline: t.space.$8, marginBlockStart: t.space.$2 })} localizationKey={ - checkout.subscription?.status === 'active' + checkout.totals.totalDueNow.amount > 0 ? localizationKeys('__experimental_commerce.checkout.description__paymentSuccessful') : localizationKeys('__experimental_commerce.checkout.description__subscriptionSuccessful') } @@ -113,14 +113,14 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer 0 ? localizationKeys('__experimental_commerce.checkout.lineItems.title__paymentMethod') : localizationKeys('__experimental_commerce.checkout.lineItems.title__subscriptionBegins') } /> 0 ? checkout.paymentSource ? `${capitalize(checkout.paymentSource.cardType)} ⋯ ${checkout.paymentSource.last4}` : '–' diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index d5c95322f00..0570238c163 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -31,13 +31,13 @@ export const PlanDetails = (props: __experimental_PlanDetailsProps) => { return ( - <_PlanDetails {...props} /> + ); }; -const _PlanDetails = ({ +const PlanDetailsInternal = ({ plan, onSubscriptionCancel, portalRoot, @@ -51,7 +51,8 @@ const _PlanDetails = ({ const [planPeriod, setPlanPeriod] = useState<__experimental_CommerceSubscriptionPlanPeriod>(_planPeriod); const { setIsOpen } = useDrawerContext(); - const { activeOrUpcomingSubscription, revalidate, buttonPropsForPlan } = usePlansContext(); + const { activeOrUpcomingSubscription, revalidate, buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } = + usePlansContext(); const subscriberType = useSubscriberTypeContext(); if (!plan) { @@ -202,7 +203,7 @@ const _PlanDetails = ({ ) : null} - {plan.amount > 0 ? ( + {!plan.isDefault || !isDefaultPlanImplicitlyActiveOrUpcoming ? ( {subscription ? ( subscription.canceledAt ? ( @@ -315,7 +316,11 @@ interface HeaderProps { const Header = React.forwardRef((props, ref) => { const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; - const { captionForSubscription } = usePlansContext(); + const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming, subscriptions } = usePlansContext(); + + const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; + + const showBadge = !!subscription || isImplicitlyActiveOrUpcoming; return ( ((props, ref) => { })} /> ) : null} - {subscription ? ( + {showBadge ? ( ({ marginBlockEnd: t.space.$3, })} > - {subscription.status === 'active' ? ( + {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( ((props, ref) => { ) : null} - {plan.amount > 0 ? ( - ({ - marginTop: t.space.$3, - columnGap: t.space.$1x5, - })} - > - <> - ({ + marginTop: t.space.$3, + columnGap: t.space.$1x5, + })} + > + <> + + {plan.currencySymbol} + {(subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' + ? plan.annualMonthlyAmountFormatted + : plan.amountFormatted} + + ({ + textTransform: 'lowercase', + ':before': { + content: '"/"', + marginInlineEnd: t.space.$1, + }, + })} + localizationKey={localizationKeys('__experimental_commerce.month')} + /> + {plan.annualMonthlyAmount > 0 ? ( + ({ + width: '100%', + display: 'grid', + gridTemplateRows: + (subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' ? '1fr' : '0fr', + }), + ]} + // @ts-ignore - Needed until React 19 support + inert={ + (subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' ? 'true' : undefined + } > - {plan.currencySymbol} - {(subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' - ? plan.annualMonthlyAmountFormatted - : plan.amountFormatted} - - ({ - textTransform: 'lowercase', - ':before': { - content: '"/"', - marginInlineEnd: t.space.$1, - }, - })} - localizationKey={localizationKeys('__experimental_commerce.month')} - /> - {plan.annualMonthlyAmount > 0 ? ( ({ - width: '100%', - display: 'grid', - gridTemplateRows: - (subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' ? '1fr' : '0fr', - }), - ]} - // @ts-ignore - Needed until React 19 support - inert={ - (subscription && subscription.planPeriod === 'annual') || planPeriod === 'annual' ? 'true' : undefined - } + elementDescriptor={descriptors.planDetailFeePeriodNoticeInner} + sx={{ + overflow: 'hidden', + minHeight: 0, + }} > - ({ + width: '100%', + display: 'flex', + alignItems: 'center', + columnGap: t.space.$1, + })} > - ({ - width: '100%', - display: 'flex', - alignItems: 'center', - columnGap: t.space.$1, - })} - > - {' '} - - - + {' '} + + - ) : null} - - - ) : null} + + ) : null} + + {!!subscription && ( 0; - const { buttonPropsForPlan } = usePlansContext(); + const { buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); const showPlanDetails = (event?: React.MouseEvent) => { const portalRoot = getClosestProfileScrollBox(mode, event); @@ -175,7 +175,7 @@ function Card(props: CardProps) { /> ) : null} - {!plan.isDefault && ( + {(!plan.isDefault || !isDefaultPlanImplicitlyActiveOrUpcoming) && ( ({ @@ -217,7 +217,7 @@ const CardHeader = React.forwardRef((props, ref const prefersReducedMotion = usePrefersReducedMotion(); const { animations: layoutAnimations } = useAppearance().parsedLayout; const { plan, isCompact, planPeriod, setPlanPeriod } = props; - const { name, avatarUrl, annualMonthlyAmount } = plan; + const { name, annualMonthlyAmount } = plan; const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; const pricingTableCardFeePeriodNoticeAnimation: ThemableCssProp = t => ({ transition: isMotionSafe @@ -231,11 +231,11 @@ const CardHeader = React.forwardRef((props, ref return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; }, [annualMonthlyAmount, planPeriod, plan.amountFormatted, plan.annualMonthlyAmountFormatted]); - const { activeOrUpcomingSubscription, isDefaultPlanImplicitlyActive } = usePlansContext(); + const { activeOrUpcomingSubscription, isDefaultPlanImplicitlyActiveOrUpcoming, subscriptions } = usePlansContext(); const subscription = activeOrUpcomingSubscription(plan); - const isImplicitlyActive = isDefaultPlanImplicitlyActive && plan.isDefault; + const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; - const showBadge = !!subscription || isImplicitlyActive; + const showBadge = !!subscription || isImplicitlyActiveOrUpcoming; return ( ((props, ref })} data-variant={isCompact ? 'compact' : 'default'} > - {avatarUrl || showBadge ? ( - ({ - marginBlockEnd: t.space.$3, - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - flexWrap: 'wrap', - gap: t.space.$3, - float: !avatarUrl && !showBadge ? 'right' : undefined, - })} - > - {avatarUrl ? ( - 40} - title={name} - initials={name[0]} - rounded={false} - imageUrl={avatarUrl} - /> - ) : null} - - {showBadge ? ( - - {isImplicitlyActive || subscription?.status === 'active' ? ( - - ) : ( - - )} - - ) : null} - - - ) : null} - ({ + width: '100%', + display: 'flex', + flexDirection: 'row-reverse', + alignItems: 'baseline', + justifyContent: 'flex-end', + flexWrap: 'wrap', + gap: t.space.$3, + marginBlockEnd: t.space.$3, + })} > - {plan.name} - + {showBadge ? ( + + {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( + + ) : ( + + )} + + ) : null} + + {name} + + {!isCompact && plan.description ? ( onClick={event => showPlanDetails(event)} variant='link' sx={t => ({ - marginBlockStart: t.space.$2, + marginBlockStart: 'auto', + paddingBlockStart: t.space.$2, gap: t.space.$1, })} > diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 6b99c243dbf..d61d22eccf1 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -111,8 +111,9 @@ export const usePlansContext = () => { ); // should the default plan be shown as active - const isDefaultPlanImplicitlyActive = useMemo(() => { - return ctx.subscriptions.length === 0; + const isDefaultPlanImplicitlyActiveOrUpcoming = useMemo(() => { + // are there no subscriptions or are all subscriptions canceled + return ctx.subscriptions.length === 0 || !ctx.subscriptions.some(subscription => !subscription.canceledAt); }, [ctx.subscriptions]); const canManageSubscription = useCallback( @@ -216,7 +217,7 @@ export const usePlansContext = () => { ...ctx, componentName, activeOrUpcomingSubscription, - isDefaultPlanImplicitlyActive, + isDefaultPlanImplicitlyActiveOrUpcoming, handleSelectPlan, buttonPropsForPlan, canManageSubscription, diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 5891bf54a15..e7b2601a08d 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -245,7 +245,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'pricingTableCardHeader', 'pricingTableCardTitle', 'pricingTableCardDescription', - 'pricingTableCardAvatarBadgeContainer', + 'pricingTableCardBadgeTitleContainer', 'pricingTableCardAvatar', 'pricingTableCardBadgeContainer', 'pricingTableCardBadge', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index a2d35d8055d..56209fc7eaf 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -370,7 +370,7 @@ export type ElementsConfig = { pricingTable: WithOptions; pricingTableCard: WithOptions; pricingTableCardHeader: WithOptions; - pricingTableCardAvatarBadgeContainer: WithOptions; + pricingTableCardBadgeTitleContainer: WithOptions; pricingTableCardAvatar: WithOptions; pricingTableCardBadgeContainer: WithOptions; pricingTableCardBadge: WithOptions;